import { StandardSchemaV1 } from '@standard-schema/spec'; import * as pino from 'pino'; import * as nodemailer from 'nodemailer'; import * as imapflow from 'imapflow'; import { TracerProvider } from '@opentelemetry/api'; /** * Accepted audience values for `jwt()` and `jwks()`. * Pass `"*"` to skip audience validation explicitly (opts out of cross-audience * replay protection; use only when the IdP does not emit `aud`). */ type JwtAudience = string | string[] | "*"; /** * Per-claim overrides for mapping a verified JWT payload to a {@link Principal}. * Each callback receives the decoded payload and returns the value to surface. * * Use when the IdP places identity claims under non-standard names * (e.g. Azure AD uses `oid` instead of `sub`, Keycloak nests roles under * `realm_access.roles`). */ interface ClaimMappers { /** Map to `Principal.subject`. Default: `payload.sub` then `client_id` then `azp`. */ subject?: (payload: Record) => string; /** Map to `Principal.clientId`. Default: `payload.client_id` then `azp`. */ clientId?: (payload: Record) => string; /** * Map to `Principal.scopes`. Default: space-split `payload.scope`. * * Supplying this replaces the default parse entirely, including its * undefined results: scopes grant authority, so a top-level `scope` * claim is never reinstated behind a mapper that decided against it. */ scopes?: (payload: Record) => string[] | undefined; /** * Map to `Principal.roles`. Default: `payload.roles` when it is an array * of strings (RFC 9068 section 2.2.3.1, semantics from SCIM). Override for * IdPs that nest roles elsewhere, e.g. Keycloak * (`realm_access.roles`) or WorkOS (`role`, a single string). * * Supplying this replaces the default parse entirely, including its * undefined results: roles grant authority, so a top-level `roles` claim * is never reinstated behind a mapper that decided against it. */ roles?: (payload: Record) => string[] | undefined; /** * Map to `Principal.actor` (the delegation chain). Default: the RFC 8693 * `act` claim, which REQUIRES a `sub` on every entry. * * Supplying this replaces the default parse entirely, which is the * supported way to accept an actor identified by a non-standard claim * (for example `client_id` on a client-credentials actor). Without a * mapper, a present-but-unparseable `act` claim rejects the token: the * parser must not silently drop an actor, because that would promote a * delegated token to a direct call and pass an `actor: 'none'` route. */ actor?: (payload: Record) => Principal | undefined; /** * Map to `Principal.mayAct`. Default: the RFC 8693 `may_act` claim (an * object, or an array of objects, each requiring `sub`). * * Supplying this replaces the default parse entirely. Without a mapper, a * present-but-unparseable `may_act` claim rejects the token: `may_act` is * a restriction, and an absent `mayAct` means "unrestricted", so dropping * a malformed one would invert the claim's meaning. */ mayAct?: (payload: Record) => ActorMatcher[] | undefined; } /** * What kind of entity a principal's subject is. Wire claim: `sub_profile` * (aligned with draft-mcguinness-oauth-actor-profile). Orthogonal to * `Principal.kind`, which records HOW the principal was authenticated; a * human and an agent can both arrive via `kind: "jwks"`. * * An absent profile means unclassified; policy should treat unclassified * principals restrictively rather than assuming `"user"`. */ type PrincipalProfile = "user" | "service" | "ai_agent" | (string & {}); /** * Matches an actor (or a permitted future actor) by identity. All provided * fields must match (AND). Actor identity is canonically the * `(issuer, subject)` pair: per RFC 7519 section 4.1.2 a `sub` is only * locally unique within its issuer, so matching on `subject` alone is * ambiguous the moment two issuers exist, and a same-named agent from * another issuer would match. Omitting `issuer` works but is intended only * for single-issuer deployments; set it wherever more than one issuer can * mint actors. * * A matcher with no fields set (`{}`) matches ANY actor, as does one whose * only field is an empty `roles` array. That is deliberate (it is the * degenerate case of "all constraints satisfied"), but it means a matcher * built from partially-populated config is permissive rather than * restrictive; validate config before turning it into a matcher. */ interface ActorMatcher { /** Actor subject(s) to accept. An array is an OR across values. */ subject?: string | string[]; /** Issuer the actor's subject is scoped to. */ issuer?: string; /** Entity profile(s) to accept. An array is an OR across values. */ profile?: PrincipalProfile | PrincipalProfile[]; /** Roles that must ALL be present on the actor's own `roles`. */ roles?: string[]; } /** * Authenticated principal resolved from an incoming request. * * The `kind` field records how the principal was authenticated (useful for * logs and analytics). All variants share the same flat shape -- callers * never need to type-narrow on `kind` to access identity fields. */ interface Principal { /** * How the principal was authenticated. Core verifiers use the known * values; ecosystem verifiers may use their own kind strings. The * `(string & {})` arm keeps autocomplete on the known set while * accepting any value. */ kind: "jwt" | "jwks" | "oauth" | "custom" | (string & {}); /** HTTP authentication scheme. `"bearer"` for token-based flows; may be another value for custom auth schemes. */ scheme: "bearer" | string; /** Stable identity for the authenticated entity (JWT `sub`, user id, etc.). */ subject: string; /** OAuth client ID (distinct from subject). */ clientId?: string; /** Token issuer (JWT `iss`). */ issuer?: string; /** Intended audiences (JWT `aud`). */ audience?: string[]; /** Email address from the `email` claim. */ email?: string; /** Display name from the `name` claim. */ name?: string; /** OAuth 2.0 / JWT scopes. */ scopes?: string[]; /** Roles from the `roles` claim. */ roles?: string[]; /** * Expiry as Unix epoch seconds (JWT `exp`). * Required by the MCP SDK bearer middleware when using the OAuth flow. */ expiresAt?: number; /** Full decoded JWT payload (when available). */ claims?: Record; /** * Raw OIDC userinfo response (when available). Populated only when * `mcpPlugin({ userinfo: ... })` runs in URL or auto-discovery mode and the * userinfo endpoint returns a non-empty JSON body. Distinct from `claims`, * which always carries the verified JWT payload. * * Function-mode enrichment is free to merge into this field directly if * the user wants the raw upstream response surfaced; the framework does * not populate it automatically for the function variant. */ userinfoClaims?: Record; /** * What kind of entity the subject is. Wire claim: `sub_profile`. Absent * means unclassified; treat restrictively. See {@link PrincipalProfile}. */ subjectProfile?: PrincipalProfile; /** * Current actor when this identity is being exercised by a delegate. * `subject` remains the party the action is taken ON BEHALF OF; `actor` * is the party PERFORMING it. Wire claim: `act` (RFC 8693 section 4.1). * * Nests to express a delegation chain: the outermost entry is the current * actor and the ONLY access-control input; nested entries are prior * actors, informational for audit only (RFC 8693 section 4.1 makes this a * MUST for token consumers). `authorize({ actor })` therefore matches the * outermost actor exclusively. * * Set only by `delegate()` (or parsed from a verified token's `act` * claim); there is no API that attaches an actor to an existing * principal, so authenticity always covers the whole chain. */ actor?: Principal; /** * Parties permitted to become this principal's actor. Wire claim: * `may_act` (RFC 8693 section 4.4). RFC 8693 defines it as input to the * authorization server at token-exchange time; Routecraft additionally * enforces the same semantics in-process at the `delegate()` boundary, * which throws RC5037 when the requested actor matches no entry. Absent * means no in-process restriction (route-level `authorize({ actor })` * still applies). */ mayAct?: ActorMatcher[]; /** * Identifier of the consent record (delegation grant) this principal was * minted under, when the delegating caller supplied one. Opaque to the * framework; carried for audit and revocation correlation. */ grantId?: string; } /** * Principal variant that guarantees a known token expiry. Used anywhere a * bearer-token lifecycle contract must be expressed at the type level * (most notably the OAuth flow: the MCP SDK's bearer middleware requires * `expiresAt`). */ type OAuthPrincipal = Principal & { expiresAt: number; }; /** * Verifies a bearer token and resolves the authenticated principal. * Throw to reject access; return a {@link Principal} to allow it. * May be synchronous or asynchronous. */ type TokenVerifier = (token: string) => Principal | Promise; /** * OAuth-flavoured {@link TokenVerifier}. Guarantees the returned principal * carries a known `expiresAt`, which the MCP SDK's bearer middleware * requires. Any verifier composed into `oauth({ verify })` must satisfy * this shape. */ type OAuthTokenVerifier = (token: string) => OAuthPrincipal | Promise; /** * Validator-based auth: a bearer token verified on every request. * Returned by `jwt()` and `jwks()` helpers; also accepted as a plain object * with a custom `validator` function. * * @example * ```ts * import { jwt } from "@routecraft/routecraft"; * auth: jwt({ secret: process.env.JWT_SECRET! }) * * // Custom validator -- throw to reject * auth: { * validator: async (token) => { * const user = await lookupApiKey(token); * if (!user) throw new Error("unknown token"); * return { kind: "custom", scheme: "bearer", subject: user.id }; * } * } * * // A single shared secret. Compare it in constant time: `===` returns as * // soon as two bytes differ, and that timing is measurable. * import { timingSafeStringEqual } from "@routecraft/routecraft"; * auth: { * validator: (token) => { * if (!timingSafeStringEqual(process.env.OPS_SECRET!, token)) { * throw new Error("unknown token"); * } * return { kind: "custom", scheme: "bearer", subject: "ops" }; * } * } * ``` */ interface ValidatorAuthOptions { /** Verifier called with the raw bearer token on every request. Throw to reject. */ validator: TokenVerifier; } /** * OAuth-flavoured {@link ValidatorAuthOptions}. The `validator` is guaranteed * to resolve an {@link OAuthPrincipal} (with `expiresAt`). Returned by * `jwt()` and `jwks()` because both helpers require `exp` on verified tokens. * * Structurally a subtype of `ValidatorAuthOptions`, so the same value is * also usable in non-OAuth contexts. */ interface OAuthValidatorAuthOptions { /** Verifier called with the raw bearer token on every request. Throw to reject. */ validator: OAuthTokenVerifier; /** * Expected token issuer(s), surfaced from the underlying helper (`jwt()` / * `jwks()`). Read by `mcpPlugin({ userinfo: true })` to locate the OIDC * Discovery document. Optional because custom validators may not declare an * issuer; if absent, OIDC auto-discovery cannot be used and the caller must * pass an explicit userinfo URL or function. */ issuer?: string | string[]; /** * Clock skew the underlying helper allowed when it checked `exp`, surfaced * so a downstream expiry check can apply the same tolerance. Without it a * consumer re-checking `expiresAt` would refuse the very token `jwt()` / * `jwks()` just accepted within skew. */ clockToleranceSec?: number; } declare module "@routecraft/routecraft" { interface RoutecraftHeaders { /** * Authenticated principal resolved from the request, when available. * * One header carries the entire structured `Principal` rather than ten * flat string keys (subject, issuer, audience, ...). The `ex.principal` * getter is sugar over reading this header. See * `.standards/exchange-state-model.md` for the rationale (cross-cutting * concerns get one header key, never a special field). */ "routecraft.auth.principal"?: Principal; } } /** * Supported HMAC algorithms for JWT signature verification. * Maps JWT `alg` header values to Node.js `crypto.createHmac` digest names. */ declare const HMAC_ALGORITHMS: { readonly HS256: "sha256"; readonly HS384: "sha384"; readonly HS512: "sha512"; }; /** * Supported RSA algorithms for JWT signature verification. * Maps JWT `alg` header values to Node.js `crypto.createVerify` algorithm names. */ declare const RSA_ALGORITHMS: { readonly RS256: "RSA-SHA256"; readonly RS384: "RSA-SHA384"; readonly RS512: "RSA-SHA512"; }; type HmacAlgorithm = keyof typeof HMAC_ALGORITHMS; type RsaAlgorithm = keyof typeof RSA_ALGORITHMS; /** * HMAC (symmetric) JWT options. * Uses a shared secret to sign and verify tokens. */ interface JwtHmacOptions { /** Shared secret used to verify HMAC signatures. */ secret: string; /** * HMAC algorithm to accept. Default: `"HS256"`. * The helper rejects tokens whose `alg` header does not match. */ algorithm?: HmacAlgorithm; /** * Clock skew tolerance in seconds for `exp` and `nbf` checks. * Default: `0` (no tolerance). */ clockToleranceSec?: number; /** * Expected `iss` claim. Tokens whose `iss` does not match are rejected. * Required to prevent cross-issuer token replay. */ issuer: string | string[]; /** * Expected `aud` claim. Pass `"*"` to explicitly skip audience validation * (cross-audience token replay becomes possible -- use only when the IdP * does not emit `aud`, e.g. Clerk with no API audience configured). * Required to prevent cross-audience token replay. */ audience: JwtAudience; /** Optional per-claim overrides for non-standard IdPs. */ claims?: ClaimMappers; } /** * RSA (asymmetric) JWT options. * Uses a public key to verify signatures. */ interface JwtRsaOptions { /** PEM-encoded public key or certificate used to verify RSA signatures. */ publicKey: string; /** * RSA algorithm to accept. Default: `"RS256"`. * The helper rejects tokens whose `alg` header does not match. */ algorithm?: RsaAlgorithm; /** * Clock skew tolerance in seconds for `exp` and `nbf` checks. * Default: `0` (no tolerance). */ clockToleranceSec?: number; /** * Expected `iss` claim. Tokens whose `iss` does not match are rejected. * Required to prevent cross-issuer token replay. */ issuer: string | string[]; /** * Expected `aud` claim. Pass `"*"` to explicitly skip audience validation * (cross-audience token replay becomes possible -- use only when the IdP * does not emit `aud`, e.g. Clerk with no API audience configured). * Required to prevent cross-audience token replay. */ audience: JwtAudience; /** Optional per-claim overrides for non-standard IdPs. */ claims?: ClaimMappers; } /** * Options for the built-in static-key JWT auth helper. * Supports HMAC (symmetric) and RSA (asymmetric) signing. * * Discriminated by key: pass `secret` for HMAC, `publicKey` for RSA. */ type JwtAuthOptions = JwtHmacOptions | JwtRsaOptions; /** * Built-in static-key JWT authentication helper. * Verifies HMAC or RSA signed JWTs using only `node:crypto` (no external * dependencies). * * Returns an {@link OAuthValidatorAuthOptions} that can be passed directly to * `mcpPlugin({ auth: jwt({ ... }) })` or `oauth({ verify: jwt({ ... }) })`. * Because the return type guarantees an {@link OAuthPrincipal}, it is also * structurally assignable wherever a plain `ValidatorAuthOptions` is accepted. * * On success the validator returns an {@link OAuthPrincipal} (`kind: "jwt"`) * populated from standard JWT claims (`sub`, `iss`, `aud`, `exp`, `scope`, * etc.) with the full decoded payload available in `claims`. * * Security: `issuer` is required to bind tokens to the expected IdP and prevent * cross-issuer replay. `audience` is required (or explicitly set to `"*"` to * opt out) to bind tokens to this resource and prevent cross-audience replay. * Tokens without an `exp` claim are always rejected. * * @example * ```ts * import { mcpPlugin, jwt } from "@routecraft/ai"; * * // HMAC (symmetric): shared secret * mcpPlugin({ * transport: "http", * auth: jwt({ * secret: process.env.JWT_SECRET!, * issuer: "https://idp.example.com", * audience: "https://mcp.example.com", * }), * }); * * // RSA (asymmetric): public key only * mcpPlugin({ * transport: "http", * auth: jwt({ * algorithm: "RS256", * publicKey: process.env.JWT_PUBLIC_KEY!, * issuer: "https://idp.example.com", * audience: "https://mcp.example.com", * }), * }); * ``` */ declare function jwt(options: JwtAuthOptions): OAuthValidatorAuthOptions; /** * Options for the JWKS-backed JWT auth helper. */ interface JwksOptions { /** * JWKS endpoint URL used to fetch the IdP's signing keys. * Keys are cached and rotated by `jose`'s `createRemoteJWKSet`. */ jwksUrl: string | URL; /** * Expected `iss` claim. Tokens whose issuer does not match are rejected, * preventing cross-issuer replay. */ issuer: string | string[]; /** * Expected `aud` claim. Pass `"*"` to explicitly skip audience validation * (cross-audience token replay becomes possible -- use only when the IdP * does not emit `aud`, e.g. Clerk with no API audience configured). * Required to prevent cross-audience token replay. */ audience: JwtAudience; /** * Accepted JWS algorithms. Defaults to the asymmetric `RS*`/`PS*`/`ES*`/`EdDSA` * family. Restrict further if the IdP only uses a single algorithm; expand * only if the IdP uses something outside the defaults. */ algorithms?: string[]; /** * Clock skew tolerance in seconds applied to `exp` and `nbf` validation. * Passed through to `jose`'s `jwtVerify`. Default: no tolerance. */ clockToleranceSec?: number; /** Optional per-claim overrides for non-standard IdPs. */ claims?: ClaimMappers; } /** * JWKS-backed JWT authentication helper. * Verifies bearer JWTs against a remote JWKS endpoint using `jose`. * Keys are cached and rotated automatically. Tokens without an `exp` claim * are always rejected (enforced via `jose`'s `requiredClaims`). * * Returns an {@link OAuthValidatorAuthOptions} that can be passed directly to * `mcpPlugin({ auth: jwks({ ... }) })` or `oauth({ verify: jwks({ ... }) })`. * Because the return type guarantees an {@link OAuthPrincipal}, it is also * structurally assignable wherever a plain `ValidatorAuthOptions` is accepted. * * On success the validator returns an {@link OAuthPrincipal} (`kind: "jwks"`) * populated from standard JWT claims (`sub`, `iss`, `aud`, `exp`, `scope`, * etc.) with the full decoded payload available in `claims`. * * Requires the optional peer dependency `jose`: * ```sh * bun add jose * ``` * * @example * ```ts * import { mcpPlugin, jwks } from "@routecraft/ai"; * * // Standalone: just verify bearer JWTs (no OAuth proxy flow) * mcpPlugin({ * transport: "http", * auth: jwks({ * jwksUrl: "https://idp.example.com/.well-known/jwks.json", * issuer: "https://idp.example.com", * audience: "https://mcp.example.com", * }), * }); * ``` */ declare function jwks(options: JwksOptions): OAuthValidatorAuthOptions; /** * Well-known error categories used by core codes. Ecosystem packages may * use their own category strings; the `(string & {})` arm keeps * autocomplete on the known set while accepting any value. */ type KnownErrorCategory = "Definition" | "DSL" | "Lifecycle" | "Adapter" | "Runtime"; type RCMeta = { category: KnownErrorCategory | (string & {}); message: string; suggestion?: string; docs: string; /** * Whether this error should be retried by the retry wrapper. * - `true`: Transient error, retry may succeed (e.g., network issues) * - `false`: Permanent error, retry will not help (e.g., validation, config) */ retryable: boolean; }; /** * Open error-code registry. Core declares its own `RC####` codes here; * ecosystem packages add namespaced codes via declaration merging plus a * runtime {@link registerErrorCodes} call: * * ```typescript * declare module "@routecraft/routecraft" { * interface ErrorCodeRegistry { * AI1001: RCMeta; * } * } * registerErrorCodes("AI", { AI1001: { ... } }, "@routecraft/ai"); * ``` * * The `RC` namespace is reserved for core. Each namespace is claimable by * exactly one owner package, which makes cross-package code collisions a * detectable package-identity conflict instead of a silent numbering * accident (TypeScript merges identical `X: RCMeta` declarations without * complaint, so the compiler alone cannot catch them). */ interface ErrorCodeRegistry { RC1001: RCMeta; RC1002: RCMeta; RC1003: RCMeta; RC1004: RCMeta; RC2001: RCMeta; RC2002: RCMeta; RC3001: RCMeta; RC3002: RCMeta; RC5001: RCMeta; RC5002: RCMeta; RC5003: RCMeta; RC5004: RCMeta; RC5010: RCMeta; RC5011: RCMeta; RC5012: RCMeta; RC5013: RCMeta; RC5014: RCMeta; RC5015: RCMeta; RC5016: RCMeta; RC5017: RCMeta; RC5018: RCMeta; RC5019: RCMeta; RC5020: RCMeta; RC5021: RCMeta; RC5022: RCMeta; RC5023: RCMeta; RC5024: RCMeta; RC5025: RCMeta; RC5026: RCMeta; RC5028: RCMeta; RC5029: RCMeta; RC5030: RCMeta; RC5031: RCMeta; RC5032: RCMeta; RC5033: RCMeta; RC5034: RCMeta; RC5035: RCMeta; RC5036: RCMeta; RC5037: RCMeta; RC5038: RCMeta; RC5039: RCMeta; RC5040: RCMeta; RC5041: RCMeta; RC5042: RCMeta; RC5043: RCMeta; RC5044: RCMeta; RC5045: RCMeta; RC5046: RCMeta; RC5047: RCMeta; RC5048: RCMeta; RC5049: RCMeta; RC5050: RCMeta; RC5051: RCMeta; RC5052: RCMeta; RC5053: RCMeta; RC5054: RCMeta; RC5055: RCMeta; RC5056: RCMeta; RC5057: RCMeta; RC5058: RCMeta; RC5059: RCMeta; RC5060: RCMeta; RC5061: RCMeta; RC5062: RCMeta; RC5063: RCMeta; RC5064: RCMeta; RC5065: RCMeta; RC5066: RCMeta; RC9901: RCMeta; } /** All known error codes: core `RC####` plus registered ecosystem namespaces. */ type RCCode = keyof ErrorCodeRegistry; /** * Core codes only: the `RC####` keys declared in this file. Ecosystem keys * merged into {@link ErrorCodeRegistry} are excluded so the `RC` const * below stays exhaustively checked against what core actually defines. */ type CoreErrorCode = keyof ErrorCodeRegistry & `RC${number}`; declare const RC: { [K in CoreErrorCode]: RCMeta; }; declare class RoutecraftError extends Error { readonly rc: RCCode; readonly meta: RCMeta; /** * Whether this error should be retried by the retry wrapper. */ readonly retryable: boolean; constructor(rc: RCCode, meta: RCMeta, cause?: unknown); toString(): string; /** * Used by pino and other serializers so log output includes rc, message, suggestion, docs, causeMessage, causeStack as searchable fields. */ toJSON(): Record; static parse(cause: unknown): { message: string; error: Error; }; } /** * Formats Standard Schema validation issues into a human-readable string. * Each issue becomes "path: message" (or just "message" when there is no path). * * @param issues - The raw issues value from a Standard Schema validation result * @returns A formatted string describing what failed * * @example * ``` * formatSchemaIssues([{ message: "Required", path: ["name"] }]) * // => '"name": Required' * ``` */ declare function formatSchemaIssues(issues: unknown): string; /** * Creates a RoutecraftError with the given code and optional cause/overrides. * * @param rc - Error code from the RC registry (e.g. "RC5001", "RC1002") * @param cause - Optional underlying error (stored as cause, message can be overridden) * @param overrides - Optional overrides for message, suggestion, docs, or * retryable (e.g. an adapter that knows a specific occurrence of a * normally-permanent code is transient, or vice versa) * @returns A RoutecraftError instance (branded, with retryable from RC meta) * * @example * ```typescript * throw rcError("RC5002", new Error("Invalid payload"), { message: "Validation failed" }); * ``` */ declare function rcError(rc: RCCode, cause?: unknown, overrides?: Partial>): RoutecraftError; /** * Register ecosystem error codes under a claimed namespace. * * Call once at module load time (typically from a side-effect import next * to the matching `declare module` augmentation of {@link ErrorCodeRegistry}). * Each namespace is claimable by exactly one owner package; a second claim * throws RC1003 naming both packages so consumers know which two packages * collide (they cannot fix the collision themselves). Re-registration by * the same owner is idempotent and replaces the previous codes, so module * re-evaluation (test runners, HMR) is safe. * * @param namespace - Unique uppercase prefix, e.g. "AI" (`RC` is reserved for core) * @param codes - Map of `${namespace}${4 digits}` codes to their metadata * @param owner - Owning package name, used in collision diagnostics * * @example * ```typescript * registerErrorCodes( * "AI", * { AI1001: { category: "Adapter", message: "...", docs: "...", retryable: false } }, * "@routecraft/ai", * ); * ``` */ declare function registerErrorCodes(namespace: string, codes: Record, owner: string): void; /** * How long a deferral stays resumable: milliseconds, or a duration * string with a unit suffix (`"500ms"`, `"30s"`, `"15m"`, `"72h"`, `"7d"`). * * The string form exists because the realistic values here are human ones: * an approval window is three days, not 259_200_000. */ type Duration = number | `${number}${DurationUnit}`; /** Unit suffixes accepted by {@link parseDuration}. */ type DurationUnit = "ms" | "s" | "m" | "h" | "d"; /** * Resolve a {@link Duration} to milliseconds. * * The single duration guard for the whole framework: every authored time * option parses here, so the accepted grammar cannot drift between the * surfaces that use it. * * @param value - Milliseconds, or a duration string * @param field - Option name quoted in the error message * @param min - Smallest accepted duration. `1` (the default) for a * deadline, where zero means "already expired" and is always a mistake; * `0` for a wait, where zero legitimately means "do not wait". * @returns The duration in milliseconds * @throws RC5003 when the value is not a duration an `expiresAt` `Date` * can represent: at least `min` milliseconds, and not past the end of * the representable time range */ declare function parseDuration(value: Duration, field: string, min?: 0 | 1): number; /** * Cron expression with autocomplete for nicknames and common patterns. * Accepts any valid cron string (5-field or 6-field) as well. */ type CronExpression = "@yearly" | "@annually" | "@monthly" | "@weekly" | "@daily" | "@midnight" | "@hourly" | "* * * * *" | "*/5 * * * *" | "*/15 * * * *" | "*/30 * * * *" | "0 * * * *" | "0 0 * * *" | "0 0 * * 0" | "0 0 1 * *" | "0 0 1 1 *" | (string & {}); /** * Configuration options for the cron source adapter. */ interface CronOptions { /** * IANA timezone for the cron schedule (e.g., "America/New_York") * @default undefined (system timezone) */ timezone?: string; /** * Maximum number of times the cron job will fire before stopping. * Delegated to croner's `maxRuns` option. * @default Infinity */ maxFires?: number; /** * Upper bound on a random delay added to each trigger, to prevent * synchronized spikes in distributed deployments. Each delay is drawn * uniformly from `[0, maxJitter)`. * Recommended: "1s" to "30s" for production workloads. * Can be set globally via `ADAPTER_CRON_OPTIONS` in the context store. * @default 0 */ maxJitter?: Duration; /** * Human-readable name for the cron job, used in headers for observability * @default undefined */ name?: string; /** * If true, prevents the job from running if the previous execution * (including any jitter delay) is still in progress. Delegated to * croner's `protect` option. * @default true */ protect?: boolean; /** * Date or ISO 8601 string at which the cron job should start running. * Ticks before this date are skipped. * @default undefined (starts immediately) */ startAt?: Date | string; /** * Date or ISO 8601 string at which the cron job should stop running. * The job is permanently stopped once this date is reached. * @default undefined (runs indefinitely) */ stopAt?: Date | string; } /** * Header keys the cron source sets on every emitted exchange. Keys live * under the reserved `routecraft.cron.*` namespace; the value types are * merged into `RoutecraftHeaders` below. */ declare const CronHeaders: { /** The cron expression that triggered this exchange */ readonly EXPRESSION: "routecraft.cron.expression"; /** The timestamp when the cron job fired, in ISO 8601 format */ readonly FIRED_TIME: "routecraft.cron.firedTime"; /** The next timestamp when the cron job will fire, in ISO 8601 format */ readonly NEXT_RUN: "routecraft.cron.nextRun"; /** The number of times the cron job has fired */ readonly COUNTER: "routecraft.cron.counter"; /** The IANA timezone for the cron schedule */ readonly TIMEZONE: "routecraft.cron.timezone"; /** The human-readable name for the cron job */ readonly NAME: "routecraft.cron.name"; }; declare module "@routecraft/routecraft" { interface RoutecraftHeaders { /** The cron expression that triggered this exchange */ [CronHeaders.EXPRESSION]?: string; /** The timestamp when the cron job fired, in ISO 8601 format */ [CronHeaders.FIRED_TIME]?: string; /** The next timestamp when the cron job will fire, in ISO 8601 format */ [CronHeaders.NEXT_RUN]?: string; /** The number of times the cron job has fired */ [CronHeaders.COUNTER]?: number; /** The IANA timezone for the cron schedule */ [CronHeaders.TIMEZONE]?: string; /** The human-readable name for the cron job */ [CronHeaders.NAME]?: string; } } /** * What an {@link EnablementPredicate} answers with. * * `true` enables the route. A string disables it and IS the reason, so ops * can report why without the author declaring the reason a second time * (which is the declaration that goes stale). `false` disables it with a * generic reason, for a predicate that has nothing useful to add. */ type EnablementVerdict = boolean | string; /** * Decides whether a route runs. * * Evaluated once as the context starts, and again on the cadence the route * declared via {@link EnablementOptions.refresh}. It is per-route lifecycle, * not per-exchange filtering: it never sees an exchange and never runs on * the hot path. * * A predicate that throws leaves the route disabled with the error message * as its reason and never fails the boot. A capability whose credentials are * missing is a configuration state, and a configuration state must not take * the process down. */ type EnablementPredicate = () => EnablementVerdict | Promise; /** * How often a route's predicate is re-evaluated. * * A {@link Duration} is a fixed interval; a cron expression is a schedule. * They are told apart by shape rather than by a mode flag: every cron * expression contains a space or starts with `@`, and no `Duration` does. */ type RefreshCadence = Duration | CronExpression | typeof MANUAL_REFRESH; /** * Explicit opt-out from any refresh cadence: the predicate is evaluated once * as the route starts and never again until something asks. * * Omitting `refresh` means the same thing, so this exists for the case where * the cadence is COMPUTED: `refresh: pollCadence ?? "manual"` says what it * means, where the alternative is assembling the options object * conditionally. It also restores the sentinel precedent `deferral` * already sets with `defaultTtl: "never"` and `retention: "never"`. */ declare const MANUAL_REFRESH = "manual"; /** * `drainGrace` value meaning "wait as long as it takes": the disable never * forces, and in-flight exchanges always reach a terminal outcome. * * The route stops intaking immediately either way, so this trades a bounded * transition for a guaranteed one. Choose it when the work is more expensive * to lose than the transition is to wait for, and only when the pipeline is * known to terminate; a step that can hang holds the disable open forever. */ declare const NEVER_FORCE = "never"; /** Options for the second argument of `.enabled()`. */ interface EnablementOptions { /** * Re-evaluate the predicate on this cadence. * * Omitted (the default) is MANUAL: the predicate runs once as the route * starts and never again until something explicitly asks. That keeps the * common case, an environment variable that cannot change without a * restart, free of any recurring cost, and it stops a predicate that * reaches the network from becoming an invisible repeating one. * * Pass `"manual"` to say so explicitly, which is what a computed cadence * wants: `refresh: pollCadence ?? "manual"`. */ refresh?: RefreshCadence; /** * How long in-flight exchanges may keep running after the route stops * intaking, before execution is abandoned. * * Defaults to the context's `shutdown.timeout`, because a disable is a * per-route shutdown and one knob for both is what an operator expects to * find. Set it per route when the author knows what this pipeline's * in-flight work actually costs: a route whose steps take a minute needs * longer than the 30 second default, and a route of fast steps can be * taken out of service far quicker. * * `"never"` ({@link NEVER_FORCE}) waits indefinitely, so every in-flight * exchange reaches a terminal outcome and nothing is abandoned. See * {@link NEVER_FORCE} for when that is the right trade. */ drainGrace?: Duration | typeof NEVER_FORCE; } /** * A route's enablement declaration, as stored on its definition. * * @internal The shape is internal; declare it with `.enabled()`. */ interface RouteEnablement { readonly predicate: EnablementPredicate; readonly refresh?: RefreshCadence; readonly drainGrace?: Duration | typeof NEVER_FORCE; } /** * Where a route stands: enabled, or disabled with the reason ops reports. * * A discriminated union rather than a `{ enabled, reason? }` pair so a * reader cannot consult `reason` on an enabled route, and so the disabled * arm can never be constructed without one. */ type EnablementState = { readonly enabled: true; } | { readonly enabled: false; readonly reason: string; }; /** * Type registries for compile-time safety via declaration merging. * * Users populate these empty interfaces in their project to enable * strict autocomplete and red-line errors for misconfigured endpoints, * providers, and event names. When a registry is empty, the corresponding * adapter falls back to accepting any string (no breaking change). * * @example * ```typescript * // In your project's types file: * declare module '@routecraft/routecraft' { * interface DirectEndpointRegistry { * 'payments': PaymentData; * 'orders': OrderData; * } * } * * // Now direct('payments', { ... }) autocompletes and * // direct('nonexistent', { ... }) shows a red line. * ``` */ /** * Registry for direct adapter endpoints. * * Keys are endpoint strings (e.g. 'payments'), values are the body type * for that endpoint. Populate via declaration merging to constrain * `direct()`, `ForwardFn`, and related APIs. */ interface DirectEndpointRegistry { } /** * Resolves a registry to its string keys when populated, * or falls back to `string` when the registry is empty. * * @template Registry - The registry interface to resolve */ type ResolveKey = keyof Registry extends never ? string : Extract; /** * Resolves the body type for a registry endpoint. * When the registry is populated and the key exists, returns the mapped value type. * Falls back to `Fallback` (default `unknown`) when the registry is empty or key is unregistered. * * @template Registry - The registry interface to look up * @template K - The endpoint key to resolve * @template Fallback - Fallback type when key is not found (default `unknown`) */ type ResolveBody = keyof Registry extends never ? Fallback : K extends keyof Registry ? Registry[K] : Fallback; /** * Resolved direct endpoint type. * Constrained to registered endpoints when `DirectEndpointRegistry` is populated, * falls back to `string` when empty. */ type RegisteredDirectEndpoint = ResolveKey; /** * A log file the logger was asked for and could not open. Read by the CLI, * which owns the setting and refuses to run with its logs diverted. * * @internal */ interface LogFileDiversion { /** The file as it was named. */ path: string; /** Where the name came from. */ source: "LOG_FILE" | "CRAFT_LOG_FILE" | "craft.log"; /** Why it could not be opened for appending. */ reason: string; } /** * The log file the logger could not open, if it was asked for one. * * The logger never fails over its destination: an embedding application * cannot be stopped by its logger, so a file that cannot be opened diverts * the logs to a file of the same name in the temporary directory, then to * standard error. A host that owns the setting reads this to refuse instead. * * @internal */ declare function logFileDiversion(): LogFileDiversion | undefined; /** * Framework-level pino logger instance. Configured via craft.log.{cjs,js} or environment variables. */ declare const logger: pino.Logger; /** * Metadata the engine passes to a source adapter at subscribe time. * * Carries the route's id and optional discovery bundle so adapters that * maintain registries (direct, mcp) can mirror route-level metadata into * their own registry entries without re-declaring it in adapter options. */ interface SourceMeta { /** ID of the route this source is subscribed to. */ routeId: string; /** Route-level discovery bundle, when set via the builder. */ discovery?: RouteDiscovery; /** * The route declares a route-entry `.authorize()`, so it cannot run * without a verified principal. Identity-capable transports (the http * dispatcher) use this to pull credential verification even on a mount * with no wall; transports without a credential channel ignore it and * the authorize step rejects at route entry as before. */ requiresPrincipal?: boolean; /** * The route's consumer may hold a message before the pipeline runs, as * `.batch()` does, so `emit()` returning does not mean work has started or * that the route counts it as in flight. * * A source that acknowledges a delivery on its own authority needs to know * this: it would otherwise report success for a message the route can still * discard at shutdown. Sources that simply await `emit()` can ignore it. */ bufferedConsumer?: boolean; } /** * Everything a source adapter needs from the engine, handed to * {@link CallableSource} as a single object. Adding a capability to this * interface is additive; the source contract never changes shape again. * * Sources that authenticate at their boundary (e.g. the MCP server with * `auth:` configured) forward the resolved identity by writing the * structured `Principal` into `headers["routecraft.auth.principal"]` * on the emitted message: state lives on `headers`, full stop. * * @template T - Body type of messages produced by this source (after parse) */ interface Subscription { /** The owning context (store access, logger, events). */ context: CraftContext; /** Fires when the route stops; sources must stop producing and return. */ signal: AbortSignal; /** Route id and discovery bundle for registry-maintaining sources. */ meta: SourceMeta; /** * Signal readiness: the source is wired and able to produce. Routes * emit `route:started` once every source has called this (emitting a * first message also marks readiness as a fallback). */ ready(): void; /** * Signal that this finite source has finished producing. Aborts the * source's controller so a single-source route completes; on a * multi-ingress route only this source's child controller aborts. * Pass a reason to record an abnormal completion as the abort reason; * to surface a fatal source error to the engine, reject from * `subscribe` instead (the route aborts with that error). Idempotent: * calling it again (or after the signal already aborted) is a no-op. */ complete(reason?: unknown): void; /** * Hand one message to the route. Resolves with the processed exchange * (or rejects with the pipeline error, which sources typically catch * and log so one bad message does not kill the source). * * The {@link Message} envelope carries the payload plus the optional * `parse` / `parseFailureMode` used by the synthetic parse step: when * `parse` is set, `message` is the RAW value (e.g. a JSON line string) * and the parsed result becomes the exchange body. See * `adapters/shared/parse.ts` for the `OnParseError` semantics. */ emit(msg: Message): Promise; } /** * Function form of a source: subscribes to data and emits messages until * the subscription's signal aborts. Use with `.from(callableSource)` or * adapters that implement {@link Source}. * * ```ts * .from(async (sub) => { * while (!sub.signal.aborted) { * const item = await poll(); * await sub.emit({ message: item, headers: { "x-origin": "poll" } }); * } * }) * ``` * * @template T - Body type of messages produced by this source (after parse) */ type CallableSource = (sub: Subscription) => Promise | void; /** * Source adapter: produces messages for a route. Used with `.from(source)`. * * @template T - Body type of messages produced */ interface Source extends Adapter { subscribe: CallableSource; } /** * Generator form of a source: an (async) generator function receiving the * {@link Subscription} and yielding message bodies. Each yielded value is * emitted sequentially (`await sub.emit(...)` per item, so the pipeline * applies natural backpressure); when the generator returns, the source * completes like any finite source. Use `sub.emit({ message, headers })` * directly inside the generator when a message needs headers. * * ```ts * .from(async function* (sub) { * while (!sub.signal.aborted) { * yield await poll(); * } * }) * ``` * * @template T - Body type of messages produced by this source */ type GeneratorSource = (sub: Subscription) => AsyncGenerator | Generator; /** * Anything `.from()` accepts and normalizes into a {@link Source}: * a Source adapter, a callable source, an (async) generator function, * or a bare (async) iterable of message bodies. * * @template T - Body type of messages produced */ type SourceLike = Source | CallableSource | GeneratorSource | AsyncIterable | Iterable; /** A non-empty list of sources, the type a helper returns so `.from(...helper())` compiles. */ type SourceList = [SourceLike, ...SourceLike[]]; /** * Symbol used by registerDsl to access the builder's internal addStep method. * Defined in a separate module to avoid circular imports between builder.ts * and dsl.ts. * * @internal */ declare const PUSH_STEP: unique symbol; /** * Symbol used by sub-pipeline builders (the shared PathBuilder for choice and * multicast paths) to hand their compiled step array back to their parent Step * without exposing a public `.steps()` API. Keeps the "no headless builder" * constraint on RouteBuilder intact. * * @internal */ declare const COLLECT_STEPS: unique symbol; /** * Symbol a step implements to expose the sub-pipelines it carries (choice * branches, multicast paths, dispatch targets) to framework-level walks of * a route's step tree, without widening the public `Step` contract or * making those step arrays writable from outside. * * The one walk today is the defer-site resolver, which has to know both * that a sub-pipeline exists and whether it rejoins the main flow. * * @internal */ declare const NESTED_STEPS: unique symbol; /** * Symbol a step implements to expose the step instance that hosts a * re-entrant defer site: the `.to()` / `.enrich()` step whose adapter * carries the defer-capable brand. Step-scope wrappers forward it to * their inner step (like {@link NESTED_STEPS}), so the defer-site walk * can store the site on the instance whose `execute` converts the defer * signal, however deeply the step is wrapped. * * @internal */ declare const DEFER_HOST: unique symbol; /** * Where a `.defer()` sits in a route, and what runs when it is resumed. * * Resolved once at `craft().build()` time and stored on the route * definition, for one reason: execution two happens in a different process, * so the continuation cannot be a closure captured at defer time. It has * to be re-derivable from the route definition alone, and a site is exactly * that derivation. */ interface DeferSite { /** * Stable address of the deferring step within the route. * * The index of the step in a pre-order walk of the route's step tree, not * an index into `definition.steps`: a defer inside a `.choice()` branch * is a real position that a flat index cannot name. Two processes running * the same route source derive the same numbers, which is all the address * has to guarantee. The record's `position` field carries it, and * `position + 1` onward is {@link DeferSite.continuation}, except for a * re-entrant site, whose continuation starts at `position` itself. */ readonly position: number; /** * The steps that run on resume, flattened in execution order. * * For a defer on the main flow this is simply the steps after it. For a * defer inside a `.choice()` branch it is the rest of that branch * followed by the steps after the choice, which is the same sequence the * executor would have run had the exchange never deferred (a matched branch * rejoins the main flow). * * For a re-entrant site the continuation additionally INCLUDES the * deferring step itself at its head, because the step must re-run to * finish the work it deferred in the middle of. That head is therefore also * covered by the continuation hash: editing a defer-capable step's own * definition (an inline agent's options, say) invalidates its deferred * exchanges, which is correct because that definition is what resumes. */ readonly continuation: ReadonlyArray>; /** * Resume re-enters the deferring step itself rather than the step after * it. Set on sites assigned to defer-capable `.to()` / `.enrich()` * steps; absent on static `.defer()` sites. * * Two consequences at revival, both deliberate: the payload is NOT * validated against a live schema (the schema the step raised at defer * time lives in its own code and cannot be read back off the route, so * `RC5049` never fires for a re-entrant site and the step is the * validator), and the stored schema descriptor is compared against * itself, so a changed schema cannot be detected. Both are the same * residue class as "the hash covers step definitions, never the behaviour * of what the tail calls". */ readonly reentrant?: boolean; } /** * Mark an adapter as able to raise a durable deferral from inside its own * execution (by throwing a `DeferSignal` from `fetch` / `send`). The * defer-site walk assigns `.to()` / `.enrich()` steps carrying a marked * adapter a re-entrant {@link DeferSite}. * * Core owns the brand; a consumer package (the agent tier is the shipped * case) marks its adapter with this helper and never mints its own symbol. */ declare function markDeferCapable(adapter: Adapter): void; /** * A `.to()` / `.enrich()` step that can host a re-entrant defer site. * The walk stores its verdict on the hosting instance, because that * instance's `execute` is where the defer signal is converted (before any * step-scope wrapper can observe the throw and, say, retry the step). * * @internal */ interface DeferCapableStep extends Step { /** Assigned by {@link resolveDeferSites} when the step sits on the primary flow. */ deferSite?: DeferSite; /** * Why no site was assigned, when the step sits somewhere a durable deferral * cannot be revived from (inside a `.split()` fan-out, or a * `.multicast()` path / `.dispatch()` target). Deferability of a * capable step is dynamic, so the refusal cannot fail the build the way a * static `.defer()` does; it fails the first actual deferral instead, * with this message, as `RC5051`. */ deferRefusal?: string; /** Identity through wrappers: the instance the walk stores the site on. */ [DEFER_HOST](): DeferCapableStep | undefined; } /** * What a `defer` outcome hands the executor. * * The step resolves the pieces (its schema, its expiry, its site) and the * executor owns the effects (serialize, hash, store, emit), which keeps * every scheduling decision in one place, as with every other outcome kind. * * It lives here rather than beside the operation because `types.ts` names * it on the `StepOutcome` union, and `types.ts` is the dependency root: it * does not import from `operations/`. * * @internal */ interface DeferRequest { /** * Live schema the eventual resume payload is validated against, when the site * declared one. Absent defers with no ingress validation, which is the * click-yes case: the route's own continuation is then the only reader of * whatever arrives. */ readonly schema?: StandardSchemaV1; /** Resolved TTL in milliseconds, when one was declared. */ readonly expiresInMs?: number; /** * Whatever the deferring step attached at deferral. Persisted verbatim and * handed to the resume route's `authorize` hook; never interpreted here. */ readonly meta?: unknown; /** * Identity of the call this deferral belongs to, when the deferring step * mints one credential per call. Persisted as the record's `callBinding` * and carried as the token's `sub` claim. */ readonly callBinding?: string; /** Where this defer sits, and what runs on resume. */ readonly site: DeferSite; /** * Closure state owned by the deferring step, persisted in the record's * `stepState` slot and handed back to a re-entrant step at revival. The * store never interprets it; the plain-JSON rule (`RC5042`) applies. */ readonly stepState?: unknown; } /** * A step that can defer the exchange. Implemented by the `.defer()` step; * declared here so the walk can recognise one without importing the * operation (which imports this module back). * * @internal */ interface DeferrableStep extends Step { readonly operation: OperationType.DEFER; /** * The live resume-payload schema, when the site declared one. Read back * off the step at resume time to validate the submitted payload, because a * Standard Schema cannot be persisted with the record. */ readonly schema?: StandardSchemaV1; /** Assigned by {@link resolveDeferSites}. Absent means the step is not reachable from a built route. */ site?: DeferSite; } /** * One nested sub-pipeline of a step, as reported through * {@link NESTED_STEPS}. * * `rejoins` is the load-bearing field. A `.choice()` branch flows back into * the main pipeline when it matches, so a defer inside one has a * well-defined continuation that spans the branch tail and the main tail. A * `.multicast()` path or a `.dispatch()` target runs as an isolated nested * pipeline whose exchange is not the route's primary flow, so there is no * such continuation and a defer inside one is refused. */ interface NestedSteps { readonly steps: ReadonlyArray>; readonly rejoins: boolean; /** * The predicate that selects this sub-pipeline, when one does (a * `.choice()` branch). It decides which branch shapes the body, so a * fingerprint of the pipeline has to see it. */ readonly predicate?: (exchange: Exchange) => boolean; } /** * Whether a built route can raise a durable deferral: statically (a * declared `.defer()`) or at runtime (a defer-capable step that MAY * defer). The predicate transports key on to advertise a `Deferred` * acknowledgment arm, owned here next to the fields it reads so a new way * for a route to defer updates every consumer in one edit. */ declare function routeCanDefer(definition: RouteDefinition): boolean; /** * Abstract base for "dual-mode wrapper" operations: a single concept * (`.error()`, `.retry()`, `.timeout()`, `.cache()`, ...) that * applies at either route scope (when staged before `.from()`) or step * scope (when chained after `.from()`). The route-scope path is wired * by the builder via existing fields on `RouteDefinition`. The * step-scope path is wired by wrapping the *immediately next* step in a * concrete `WrapperStep` subclass. * * Subclasses implement {@link WrapperStep.runInner}: run the inner step * and return its {@link StepOutcome} (usually unchanged), substitute a * recovery outcome on failure, or rethrow (signals an unrecoverable * error for the pipeline executor to handle). * * The template `execute()` handles the boilerplate: emitting the * inner step's `route:step:started` / `route:step:completed` events with the inner * label (so the wrapper is observationally invisible) around the * subclass's `runInner`, and passing the inner's outcome through to the * executor, which owns all scheduling. * * The `WrapperStep` class is exposed for forward-compat as wrapper * authors land additional operations (retry, cache, timeout, circuit * breaker, etc.). */ declare abstract class WrapperStep implements Step { protected readonly inner: Step; /** * Operation kind delegated from the inner step so observers see the * wrapped operation's identity, not a generic "wrapper". */ readonly operation: Step["operation"]; /** Adapter delegated from the inner step. */ readonly adapter: T; /** Display label delegated from the inner step. */ readonly label?: string; /** * The wrapper emits its own `route:step:started` / `route:step:completed` events * with the inner step's label, so the executor must not emit a generic * pair around the wrapper. */ readonly skipStepEvents = true; /** * Forward the inner step's sub-pipelines so a wrapper is transparent to * framework-level walks of the route's step tree. * * Without this a wrapped nesting step hides everything inside it: a * `.error(h).choice(when(p, (b) => b.defer(...)))` route resolved zero * defer sites, which silently disabled the build-time refusals AND the * startup check, and then failed at runtime, after the approver had * already been notified, with a diagnostic telling the user to build the * route through `craft()` (which they had). * * @internal */ [NESTED_STEPS](): ReadonlyArray; /** * Forward the defer-host protocol to the inner step, for the same * reason nested steps are forwarded: the defer-site walk sees the * wrapper (which delegates the inner adapter, so the capability brand is * visible), but the site must land on the instance whose `execute` * converts the defer signal, which is the innermost `.to()` / * `.enrich()` step. * * @internal */ [DEFER_HOST](): DeferCapableStep | undefined; constructor(inner: Step); /** * The step this wrapper folds around, for build-time walks that must see * through a stack of wrappers (e.g. assigning a step-scope cache its site). * * @internal */ get wrapped(): Step; /** * The options this wrapper was built with, as reported by * {@link describeOptions}, for build-time walks that fingerprint a * wrapper stack. * * @internal */ get fingerprintOptions(): unknown; /** * Report the options that define this wrapper, so a `.cache()` whose * cached computation includes it (a step-scope cache stacked above it, * or a route-scope cache over the pipeline) can fold them into its * default key. * Without them, editing a wrapper between a cache and its step (an * `.error()` handler's recovery value, a `.retry()` predicate deciding * which failures are recovered) would leave the key unchanged and an * external provider would keep replaying what the old wrapper produced. * * Return the configuration, not runtime state: callables as themselves * (their source text is hashed), plain data as plain data, and never a * controller, bucket or other live object (those hash as `[opaque]` and * add nothing). Return `null` when the wrapper takes no options. The * value goes through the same projection as an adapter's options in * `deferral/hash.ts`, so the same carriers and depth bound apply. * * Abstract so a new wrapper cannot silently opt out of the cache key. */ protected abstract describeOptions(): unknown; /** * Run the inner step with whatever extra behaviour the subclass adds * and return the outcome the pipeline should act on. Return the * inner's outcome unchanged when it succeeded; return a substitute * outcome (e.g. `continue` with a recovered exchange) when the * wrapper handled a failure. Throw to signal the wrapper could not * recover; the executor will then fall through to the route-level * error handler (or fail the exchange when none is set). * * There is no scheduling to intercept: the inner step never sees the * work queue, so a failed inner step has, by construction, scheduled * nothing. The pre-outcome engine required subclasses to capture and * replay inner queue pushes (and clear them on recovery); that whole * protocol is gone. * * @param exchange Live exchange to feed to the inner step. * @param ctx Executor capability passthrough for the inner step. * @returns Outcome the executor schedules. */ protected abstract runInner(exchange: Exchange, ctx: StepContext): Promise; /** * Template method called by the pipeline executor. Emits the inner * step's lifecycle events with the inner label, delegates to * {@link runInner}, and returns the outcome for the executor to * schedule. Wrapper-specific failure events are emitted by the * subclass before it rethrows. */ execute(exchange: Exchange, ctx: StepContext): Promise; } /** * Options for the `.retry()` wrapper (step scope and route scope). */ interface RetryOptions { /** * Maximum total attempts, including the first one. `3` means the * original execution plus up to two re-attempts. Must be at least 1. * Default: `3`. */ maxAttempts?: number; /** * Base wait between attempts. Default: `1000`. */ backoff?: Duration; /** * Growth multiplier applied to `backoff` each attempt: the wait * before attempt `n` is `backoff * factor^(n - 1)`. `1` (the default) * is fixed backoff; `2` doubles each time (`100, 200, 400, ...`); any * value `>= 1` is allowed for gentler or steeper curves. Replaces the * old `exponential` boolean (`exponential: true` is now `factor: 2`). */ factor?: number; /** * Upper bound (ms) on a single wait, so an exponential `factor` cannot * grow the backoff without limit at higher `maxAttempts`. The computed * wait is clamped to this BEFORE jitter is applied. Default: the * platform timer ceiling (`2_147_483_647`), i.e. effectively unbounded. */ maxBackoff?: Duration; /** * Randomise each wait to avoid synchronized retry storms across many * exchanges hitting the same downstream: * - `"none"` (default): use the computed wait exactly. * - `"full"`: pick uniformly in `[0, computed]` (AWS "full jitter"). * - a number in `[0, 1]`: subtract up to that fraction, i.e. pick in * `[computed * (1 - jitter), computed]` (`0.2` keeps 80-100% of the * wait). `"full"` is `1`; `"none"` is `0`. * * Jitter only ever reduces a wait, so it never exceeds `maxBackoff`. */ jitter?: "none" | "full" | number; /** * Decide whether a failed attempt is re-attempted. Default: skip * `RoutecraftError`s with `retryable: false` (validation, auth, * config errors fail the same way every time); everything else, * including unknown third-party errors, is retried. */ retryOn?: (error: Error) => boolean; } /** * {@link RetryOptions} with every field populated. This is the shape * stored on `RouteDefinition.retry` for route-scope `.retry()` (and is * therefore part of the public definition surface); internally it is * shared between the step-scope wrapper and the route-scope segment * step in the pipeline executor. */ interface ResolvedRetryOptions { maxAttempts: number; backoffMs: number; /** Growth multiplier per attempt; `1` is fixed backoff. */ factor: number; /** Hard cap on a single computed wait (before jitter). */ maxBackoffMs: number; /** Jitter fraction in `[0, 1]`: `0` none, `1` full. */ jitter: number; retryOn: (error: Error) => boolean; } /** * Step-scope `.retry()` wrapper. Re-attempts the wrapped step on * failure with configurable backoff. Each attempt receives the same * (frozen) exchange, so a re-attempt always starts from the input that * failed, not from partial output. * * The attempt counter is loop-local state inside one execution; it is * NOT written to exchange headers (headers carry persistent exchange * state, see `.standards/exchange-state-model.md`). Observers track * attempts via the `route:retry:attempt` events instead. * * Emits the pre-declared scope-aware lifecycle events: * - `route:retry:started` when the guarded execution begins. * - `route:retry:attempt` before each backoff wait + re-attempt. * - `route:retry:stopped` on final success or failure. */ declare class RetryWrapperStep extends WrapperStep { #private; constructor(inner: Step, options?: RetryOptions); protected describeOptions(): unknown; protected runInner(exchange: Exchange, ctx: StepContext): Promise; } /** * Route-scope `.timeout()` config. This is the shape stored on * `RouteDefinition.timeout` (and is therefore part of the public * definition surface); the builder stages it pre-`.from()` and the * pipeline executor's timeout segment consumes it. */ interface ResolvedTimeoutOptions { /** Deadline in milliseconds for each run of the bounded segment. */ timeoutMs: number; } /** * Step-scope `.timeout()` wrapper. Bounds the wrapped step with a * deadline: when the step settles in time its outcome passes through * unchanged; when the deadline fires first the wrapper throws `RC5011` * (Request timeout, `retryable: true`) so an outer `.retry()` wrapper * re-attempts it by default and `.error()` handlers can branch on the * code. * * The wrapped step's promise is not cancelled on expiry (promises * cannot be cancelled): its eventual settlement is discarded. What the * step DOES get is an `AbortSignal` on its `StepContext` that fires * when the deadline expires, with the `RC5011` error as the abort * reason. A step that forwards `ctx.signal` into cancellation-aware IO * (`fetch`, DB drivers) therefore aborts its abandoned work instead of * running it to completion in the background. The signal is linked to * any enclosing signal (a route-scope timeout's abandon signal, an * outer step-scope timeout), so whichever deadline fires first aborts * the innermost work. * * Emits scope-aware lifecycle events: * - `route:timeout:started` when the guarded execution begins. * - `route:timeout:stopped` when the step settles within the deadline. * - `route:timeout:expired` when the deadline fires (then throws). */ declare class TimeoutWrapperStep extends WrapperStep { #private; constructor(inner: Step, duration: Duration); protected describeOptions(): unknown; protected runInner(exchange: Exchange, ctx: StepContext): Promise; } /** * Base for a stateful operation that holds one piece of state PER ROUTE. * * A single step (or controller) instance is built once per `RouteDefinition`, * but that definition can be registered into multiple contexts, producing * several live `Route` instances. Keying the state by `Route` in a WeakMap * gives each Route its OWN state, so the contexts cannot cross-contaminate * each other (cross-sample, cross-suppress, cross-trip, cross-rate-limit). * A single `#routeless` fallback covers the route-less case (a step run in * isolation, e.g. a unit test) so behaviour stays bounded there too. * * Subclasses provide {@link createState}; the per-route lookup and lazy * creation live here once rather than being re-implemented per operation. * * @internal */ declare abstract class RouteScopedController { #private; /** * Build a fresh state instance. Called at most once per Route (and once * for the route-less fallback), on first use. */ protected abstract createState(): S; /** * Resolve the state for `route`, creating it on first use; or the shared * route-less instance when no Route is attached. */ stateFor(route: Route | undefined): S; } /** * The three states of a circuit breaker. * * - `closed`: calls flow through; failures within the sliding window are * counted, and the breaker trips to `open` once they reach the * threshold. * - `open`: calls fast-fail (fallback or `RC5025`) without running the * protected work, until `cooldown` has elapsed. * - `half-open`: a bounded number of probe calls are allowed through to * test whether the downstream has recovered; one success closes the * breaker, one failure re-opens it. */ type CircuitBreakerState = "closed" | "open" | "half-open"; /** * Options for the `.circuitBreaker()` operation (step scope and route * scope). A breaker tracks failures over a sliding window and short-circuits * execution when the target is known to be failing, so a flaky downstream * does not turn into a pile of slow, doomed calls. */ interface CircuitBreakerOptions { /** * Number of failures within `window` that trips the breaker from * `closed` to `open`. Must be a finite integer >= 1. */ failureThreshold: number; /** * Sliding window over which failures are counted. Failures older than * this no longer count toward the threshold. Default `60_000`. */ window?: Duration; /** * How long the breaker stays `open` before allowing a probe * (transition to `half-open`). Default `30_000`. */ cooldown?: Duration; /** * Maximum concurrent probe calls allowed in the `half-open` state. * Default `1`. Values above 1 are best-effort: the first probe to * succeed closes the breaker, so concurrent probes resolving after it * are accounted against the now-closed breaker. */ halfOpenMax?: number; /** * Produce the value to use when the breaker rejects a call (open, or * half-open at capacity). When set, the rejected exchange's body becomes * the result of `fallback` and the pipeline continues; when omitted, the * breaker throws `RC5025` so a `.error()` handler (or the default error * path) can react. * * The second argument is `forward`, the same direct-route caller the * `.error()` handler receives: `forward(endpoint, payload)` sends to a * direct route and resolves with that route's result, so a fallback can * be dynamic (serve from a cache route, a secondary provider, etc.) * rather than a static value. May be async. * * @example Static fallback * ```ts * .circuitBreaker({ failureThreshold: 5, fallback: () => ({ degraded: true }) }) * ``` * @example Dynamic fallback via a direct route * ```ts * .circuitBreaker({ * failureThreshold: 5, * fallback: (exchange, forward) => forward("recs-fallback", exchange.body), * }) * ``` */ fallback?: (exchange: Exchange, forward: ForwardFn) => unknown | Promise; /** * Decide whether a failed call counts toward the failure threshold. * Default: count everything except `RoutecraftError`s flagged * `retryable: false` (auth `RC5012`, validation `RC5002`, etc.), which * are deterministic and not evidence the downstream is unhealthy. This * mirrors `.retry()`'s `retryOn` default. */ isFailure?: (error: Error) => boolean; /** * Optional label carried on this breaker's `route:circuitBreaker:*` * events, so stacked or sibling breakers can be told apart in logs and * metrics. Has no effect on behaviour. Observe transitions by * subscribing to those events (`route:circuitBreaker:opened` / * `:halfOpen` / `:closed` / `:rejected`); the breaker exposes no * notification callback, consistent with the other operations. */ label?: string; } /** * {@link CircuitBreakerOptions} with every behavioural field populated. * This is the shape stored behind {@link CircuitBreakerController} and is * shared by the step-scope wrapper and the route-scope segment. * * @internal */ interface ResolvedCircuitBreakerOptions { failureThreshold: number; windowMs: number; cooldownMs: number; halfOpenMax: number; fallback?: (exchange: Exchange, forward: ForwardFn) => unknown | Promise; isFailure: (error: Error) => boolean; label?: string; } /** * Decision returned by {@link CircuitBreakerMachine.acquire}. `probe` is * true when the admitted call is a half-open probe, so the matching * record call knows to release the probe slot and apply half-open * transition semantics. * * @internal */ type CircuitBreakerDecision = { admitted: true; probe: boolean; } | { admitted: false; state: "open" | "half-open"; retryAfterMs: number; }; /** * Lifecycle hooks the breaker reports transitions to, so the step-scope * wrapper and the route-scope segment emit the same * `route:circuitBreaker:*` events with their own `scope` / `stepLabel` * bindings. * * @internal */ interface CircuitBreakerHooks { /** Closed/half-open -> open (the breaker tripped). */ onOpened(failureCount: number): void; /** Open -> half-open (cooldown elapsed; a probe is being admitted). */ onHalfOpen(): void; /** Half-open -> closed (a probe succeeded; the breaker recovered). */ onClosed(): void; /** A call was rejected because the breaker is open or half-open at capacity. */ onRejected(state: "open" | "half-open", retryAfterMs: number): void; } /** * The per-route state machine. Holds the sliding window of failure * timestamps (a ring buffer, pruned on access, not a flat counter so the * window genuinely slides), the current state, the open timestamp, and * the in-flight half-open probe count. One instance per Route (see * {@link CircuitBreakerController}), never one per exchange. * * @internal */ declare class CircuitBreakerMachine { #private; constructor(options: ResolvedCircuitBreakerOptions); /** Current breaker state (exposed for diagnostics and tests). */ get state(): CircuitBreakerState; /** * Decide whether the calling exchange may proceed. May transition * open -> half-open when the cooldown has elapsed. Emits `halfOpen` / * `rejected` via `hooks`. */ acquire(now: number, hooks: CircuitBreakerHooks): CircuitBreakerDecision; /** Record a successful call. A successful probe closes the breaker. */ recordSuccess(probe: boolean, hooks: CircuitBreakerHooks): void; /** * Record a failed call. `counts` is false for deterministic errors that * should not trip the breaker (the probe slot is still released so it * does not leak, but no state transition is applied). */ recordFailure(now: number, probe: boolean, counts: boolean, hooks: CircuitBreakerHooks): void; } /** * Owns the circuit-breaker state for one `.circuitBreaker()` across every * Route the enclosing step (or segment) runs in. Keyed by Route in a * WeakMap, so a single definition registered into multiple contexts gives * each Route its OWN breaker rather than one shared circuit (which would * let the contexts trip each other). Mirrors {@link ThrottleController}. * * @internal */ declare class CircuitBreakerController extends RouteScopedController { #private; constructor(options: ResolvedCircuitBreakerOptions); /** The resolved options (fallback, threshold, cooldown, label, ...). */ get options(): ResolvedCircuitBreakerOptions; /** Optional gate label, surfaced on the `route:circuitBreaker:*` events. */ get label(): string | undefined; protected createState(): CircuitBreakerMachine; acquire(route: Route | undefined, hooks: CircuitBreakerHooks): CircuitBreakerDecision; recordSuccess(route: Route | undefined, probe: boolean, hooks: CircuitBreakerHooks): void; recordFailure(route: Route | undefined, error: Error, probe: boolean, hooks: CircuitBreakerHooks): void; } /** * Step-scope `.circuitBreaker()` wrapper. Protects the immediately-next * step: counts its failures over a sliding window, trips after the * threshold, then fast-fails subsequent calls (fallback or `RC5025`) * until the cooldown elapses and a probe is allowed through. The breaker * state lives on a {@link CircuitBreakerController} keyed by Route, so * every exchange on a given Route shares one breaker while distinct * Routes (even from the same definition) stay isolated. This is the * deliberate per-ROUTE shared-state exception to the `WrapperStep` rule * (see `.standards/resilience-wrappers.md` section 8). * * Emits the `route:circuitBreaker:opened` / `:halfOpen` / `:closed` / * `:rejected` family with `scope: "step"`. */ declare class CircuitBreakerWrapperStep extends WrapperStep { #private; constructor(inner: Step, options: CircuitBreakerOptions); protected describeOptions(): unknown; protected runInner(exchange: Exchange, ctx: StepContext): Promise; } /** * A counting semaphore: bounds how many holders may run at once, queueing * the rest FIFO. The shared concurrency primitive behind `.concurrency()` * (the bulkhead wrapper) and, in future, bounded `.split()` fan-out. * * Synchronous-first, like {@link TokenBucket}: under the single-threaded * event loop a batch of concurrent exchanges each take a distinct slot (or * a distinct queue position) in the same tick, so admission order is * deterministic FIFO and the cap is never exceeded. * * State is in-memory and lives PER ROUTE (one `Semaphore` per Route, held * by a controller's WeakMap), never per exchange. A handed-out release * function is idempotent: calling it twice frees exactly one slot. * * @internal */ declare class Semaphore { #private; constructor(max: number); /** Configured maximum simultaneous holders. */ get max(): number; /** Slots currently held (admitted but not yet released). */ get inUse(): number; /** Exchanges currently queued, waiting for a slot to free. */ get waiting(): number; /** * Take a slot only if one is free right now. Returns a release function * on success, or `undefined` when all slots are in use (the caller fails * fast rather than waiting). Used by reject mode. */ tryAcquire(): (() => void) | undefined; /** * Acquire a slot, waiting FIFO until one frees when all are in use. * Resolves with a release function. Rejects with {@link SleepAbortedError} * the moment `signal` aborts (route shutdown), so a stopping route does * not strand an exchange in the wait queue. Used by queue mode. */ acquire(signal?: AbortSignal): Promise<() => void>; } /** * Options for the `.concurrency()` wrapper (step scope and route scope). * * A bulkhead: it bounds how many exchanges run the wrapped work AT ONCE, * as opposed to `.throttle()`, which bounds how many run PER TIME WINDOW. * The two compose but are not substitutes: a 10/sec throttle still allows * unbounded simultaneous in-flight exchanges if each is slow; a * concurrency of 5 caps simultaneity regardless of rate (protect a * connection pool, a memory-bound step, or a downstream with a hard * concurrency cap). */ interface ConcurrencyOptions { /** Maximum simultaneous in-flight exchanges. Must be a finite integer >= 1. */ max: number; /** * Behaviour when all slots are busy: * - `"queue"` (default): the exchange waits FIFO for a slot, applying * backpressure rather than dropping it. The wait is cancellable, so on * route shutdown a waiting exchange is admitted rather than stranded. * - `"reject"`: fail fast with `RC5026` (no slot, no wait) so the caller * sheds load instead of building an unbounded in-memory backlog. */ mode?: "queue" | "reject"; /** * Queue mode only: cap the wait line. When `max` slots are busy AND * `maxQueue` exchanges are already waiting, the next one fails fast with * `RC5026` instead of joining the queue. The middle ground between "wait * forever" (omit it) and "reject as soon as busy" (`mode: "reject"`). * Must be a finite integer >= 1; passing it in `reject` mode is rejected * at build time (use `mode: "reject"`, which is `maxQueue: 0`). Default: * unbounded. */ maxQueue?: number; /** * Partition the limit: each distinct key gets its own independent slot * pool, so you can cap concurrency per user / tenant / connection pool. * Omit for a single pool shared across the whole route. The selector runs * once per exchange and MUST return a string; coalesce missing values * (e.g. `?? "anonymous"`). A selector that throws fails the exchange. * * @example Per authenticated principal * ```ts * .concurrency({ max: 3, key: (ex) => ex.principal?.subject ?? "anonymous" }) * ``` */ key?: (exchange: Exchange) => string; /** * Maximum number of distinct keys to track concurrently when `key` is * set. Per-key semaphores live in an LRU, so memory stays bounded even * with an unbounded key space. Default `10_000`. Ignored when `key` is * not set. */ maxKeys?: number; /** * Optional label carried on this bulkhead's `route:concurrency:*` events, * so stacked or sibling limiters can be told apart in logs and metrics. * Has no effect on behaviour. */ label?: string; } /** * {@link ConcurrencyOptions} with every behavioural field populated. Shared * by the step-scope wrapper and the route-scope segment. * * @internal */ interface ResolvedConcurrencyOptions { max: number; mode: "queue" | "reject"; /** Wait-queue bound; `Number.POSITIVE_INFINITY` when unbounded. */ maxQueue: number; key?: (exchange: Exchange) => string; maxKeys: number; label?: string; } /** * Per-route bulkhead state for one `.concurrency()`. Holds a single shared * {@link Semaphore} when unkeyed, or an LRU of per-key semaphores when a * `key` selector is configured. One instance per Route (see * {@link ConcurrencyController}), never one per exchange. * * @internal */ declare class ConcurrencyLimiter { #private; constructor(options: ResolvedConcurrencyOptions); /** * Resolve the {@link Semaphore} for `exchange` (the shared one, or the * per-key one, creating it on first use), plus the partition key it * belongs to (absent when unkeyed). */ semaphoreFor(exchange: Exchange): { semaphore: Semaphore; key?: string; }; } /** * Lifecycle hooks the bulkhead reports to, so the step-scope wrapper and * the route-scope segment emit the same `route:concurrency:*` events with * their own `scope` / `stepLabel` bindings. * * @internal */ interface ConcurrencyHooks { /** Route abort signal; cancels the queue wait on shutdown. */ signal?: AbortSignal; /** All slots were busy; the exchange joins the wait queue at `queueDepth`. */ onQueued(queueDepth: number, key?: string): void; /** * A slot was acquired. `waited` is true when the exchange had to queue * first; `inUse` is the slot count after admission. */ onAcquired(waited: boolean, inUse: number, key?: string): void; /** The held slot was released; `heldMs` is how long the work held it. */ onReleased(heldMs: number, key?: string): void; /** * The exchange was failed fast (`RC5026`): `reason` is `"busy"` (reject * mode, all slots busy) or `"queue-full"` (queue mode, wait line at * `maxQueue`). */ onRejected(reason: "busy" | "queue-full", key?: string): void; } /** * Owns the bulkhead state for one `.concurrency()` across every Route the * enclosing step (or segment) runs in. Keyed by Route in a WeakMap, so a * single definition registered into multiple contexts gives each Route its * OWN slot pool rather than one shared bulkhead (which would let the * contexts steal each other's slots). Mirrors {@link ThrottleController}. * * @internal */ declare class ConcurrencyController extends RouteScopedController { #private; constructor(options: ResolvedConcurrencyOptions); /** Optional limiter label, surfaced on the `route:concurrency:*` events. */ get label(): string | undefined; protected createState(): ConcurrencyLimiter; /** * Acquire a slot for `exchange`. In `queue` mode (default) the caller * waits FIFO when the pool is full (bounded by `maxQueue`); the wait is * cancellable, so on route shutdown the exchange is admitted rather than * stranded. In `reject` mode a busy pool fails fast with `RC5026` without * waiting. Returns the (idempotent) release function plus the partition * key it was charged against (absent when unkeyed). * * `opts.mustWait` overrides BOTH modes for a caller that cannot absorb a * refusal, and deliberately ignores `maxQueue` as well: rejecting is the * outcome the flag exists to prevent, so a bounded wait line would * reintroduce it under a different name. The limit itself still holds, * because the semaphore is the route's own and is shared with every other * caller. Read `maxQueue` as bounding the exchanges that CAN be shed. * * Two gaps that flag does not close, both worth knowing before sizing * `max`: the wait is unbounded in TIME as well as in depth, so a saturated * pool holds each waiter (and whatever answered it) until a slot frees or * the route stops; and a `key` selector that throws still raises `RC5003` * from below, because the partition has to be resolved before the mode is * consulted. */ acquire(exchange: Exchange, route: Route | undefined, hooks: ConcurrencyHooks, opts?: { mustWait?: boolean; }): Promise<{ release: () => void; key?: string; }>; } /** * Step-scope `.concurrency()` wrapper. Bounds the wrapped step to `max` * simultaneous in-flight exchanges, queueing (backpressure) or fast-failing * (`RC5026`) the rest per `mode`. * * The slot pool lives on a {@link ConcurrencyController} keyed by Route, so * every exchange on a given Route shares one bulkhead while distinct Routes * (even from the same definition) stay isolated. This is the deliberate * per-ROUTE shared-state exception to the `WrapperStep` rule (see * `.standards/resilience-wrappers.md` section 8). * * Emits the `route:concurrency:queued` / `:acquired` / `:released` / * `:rejected` family with `scope: "step"`. */ declare class ConcurrencyWrapperStep extends WrapperStep { #private; constructor(inner: Step, options: ConcurrencyOptions); protected describeOptions(): unknown; protected runInner(exchange: Exchange, ctx: StepContext): Promise; } /** * How a source adapter handles a parse failure on an individual item. * * Source adapters that translate raw bytes into a structured body (json, * html, csv, jsonl, mail) accept this option to control what happens when * the parse step throws. * * All three modes are observable via the events bus, mirroring the * filter / validate operation patterns: * * | Mode | Lifecycle events fired | * |---------|----------------------------------------------| * | `fail` | `route:exchange:started` -> `route:exchange:failed` (or `route:error:caught` if `.error()` recovers) | * | `abort` | `route:exchange:started` -> `route:exchange:failed`, then `context:error` and the source dies | * | `drop` | `route:exchange:started` -> `route:exchange:dropped` (`reason: "parse-failed"`) | * * adopt the contract. */ type OnParseError = /** * Default. The exchange fails: the route's `.error()` handler is invoked * with an `RC5016` error, or `route:exchange:failed` is emitted when no handler * is set. Streaming adapters continue to the next item. * * Use when you want parse failures to be observable per item and the rest * of the source to keep flowing. */ "fail" /** * The source aborts on the first parse failure. The bad item still emits * `route:exchange:started` -> `route:exchange:failed` for per-item observability; * then the source's subscribe promise rejects and `context:error` fires. * * Use when partial-data is unacceptable and a malformed item should stop * the import (atomic-load semantics). */ | "abort" /** * The parse failure is dropped from the pipeline. The synthetic parse * step emits `route:exchange:started` -> `route:exchange:dropped` with * `reason: "parse-failed"` (matching `filter` / `validate` drop * semantics). Streaming adapters continue to the next item; no * `route:exchange:failed` and no `.error()` handler invocation. * * Use when malformed items are expected (scraping, lossy upstreams) and * you want them counted in `route:exchange:dropped` metrics rather than * surfaced as route errors. */ | "drop"; /** * Default `OnParseError` value applied when a parsing adapter does not set * one explicitly. */ declare const DEFAULT_ON_PARSE_ERROR: OnParseError; /** * Pluggable backend for the `.cache()` operation. Implementations decide * where cached values live (in-memory, Redis, file-backed, multi-tier, * etc.) and how eviction works. The contract is async-by-default so * remote backends fit without breaking the call signature. * * A reference implementation backed by `lru-cache` ships as * {@link MemoryCacheProvider}. Custom providers can be supplied * per-operation via `cache({ provider })`; a future change will allow a * global default to be set on `CraftConfig` (tracked in #112). * * Stampede protection (deduping concurrent computations for the same * key) is the provider's responsibility via {@link getOrCompute} so the * dedupe strategy can match the backend. In-memory providers track an * in-flight Promise per key; distributed providers like Redis can use a * lock key or rely on the underlying store's atomicity. */ interface CacheProvider { /** * Fetch a value by key. Returns `undefined` when there is no entry * for the key (cache miss) or when the entry has expired. Treat * `undefined` as "miss"; do not store `undefined` as a cached value. * * `null` is a valid cached value and is distinct from a miss: a * cached `null` returns `null`, not `undefined`. * * @param key The cache key. */ get(key: string): Promise; /** * Store a value under `key`. When `ttl` is provided, the entry * expires after `ttl` milliseconds; otherwise the provider's default * expiry applies (which may be "never"). * * `undefined` is not a storable value (it is the miss sentinel); * implementations must reject it. `null` is storable. * * @param key The cache key. * @param value The value to cache. Must not be `undefined`. * @param ttl Optional time to live in milliseconds. */ set(key: string, value: unknown, ttl?: number): Promise; /** Remove an entry by key. No-op if the key is absent. */ delete(key: string): Promise; /** Check existence without producing a "hit" side effect. */ has(key: string): Promise; /** * Atomic "get-or-compute" with stampede protection. On a hit, returns * the cached value. On a miss, runs `loader()` exactly once across * concurrent callers with the same key and caches the result before * resolving. If `loader` throws, the error propagates and nothing is * cached. * * Wrappers and adapters should prefer this over manual * `get` / `set` round-trips so the dedupe strategy stays with the * provider. * * If `loader` resolves to `undefined`, treat it as a non-value: * return it to the caller but do NOT store it (consistent with `get` * returning `undefined` for a miss). Do not throw in this case, even * though `set` rejects `undefined`. `null` is a value and is cached. * * @param key The cache key. * @param loader Producer for the cached value on a miss. * @param ttl Optional time to live for the produced value. */ getOrCompute(key: string, loader: () => Promise, ttl?: number): Promise; } /** * Construction options for {@link MemoryCacheProvider}. */ interface MemoryCacheProviderOptions { /** * Maximum number of entries to keep. When the cache is full, the * least-recently-used entry is evicted to make room. Defaults to * 1000. */ max?: number; /** * Default time to live in milliseconds, applied when a `set` call * omits `ttl`. Defaults to no expiry (entries live until evicted by * the LRU policy or explicitly deleted). */ ttl?: number; } /** * Default in-process cache provider. Backed by `lru-cache` for * size-bounded LRU eviction with optional TTL, plus an in-flight * Promise map for stampede protection. * * Two instances are independent stores; the framework does not share * state across `MemoryCacheProvider` instances. The module-level * `defaultMemoryCacheProvider` is shared by every `.cache()` call that * does not supply its own provider. * * Thread-safe within the JS event loop: `getOrCompute` reads, registers * an in-flight Promise, and resolves it atomically with respect to * other JS turns on the same key. Across workers / processes the cache * is local to the host. */ declare class MemoryCacheProvider implements CacheProvider { #private; constructor(options?: MemoryCacheProviderOptions); get(key: string): Promise; set(key: string, value: unknown, ttl?: number): Promise; delete(key: string): Promise; has(key: string): Promise; getOrCompute(key: string, loader: () => Promise, ttl?: number): Promise; /** * Drop all entries. Mainly for tests and explicit invalidation. * * Not part of the {@link CacheProvider} interface: clearing semantics * vary by backend (a Redis `FLUSHDB` is destructive and shared), so * this helper is specific to the in-memory provider. Construct a fresh * provider for test isolation rather than relying on `clear()` against * an arbitrary `CacheProvider`. */ clear(): void; /** * Number of live entries (excludes in-flight loaders). * * Not part of the {@link CacheProvider} interface; specific to the * in-memory provider and intended for tests and diagnostics. */ get size(): number; } /** * Options for `.cache()`, at route scope (before `.from()`) and step scope * (after `.from()`). * * @template Current Body type entering the cached route or step. */ interface CacheOptions { /** * Derive the cache key from the exchange. The returned string is the * identity used by the provider's `get` / `set`. * * When omitted, the key is a SHA-256 over the route id (plus, at step * scope, the cache's site, and at route scope, a fingerprint of the * cached pipeline: see `CacheKeyScope`), the principal's * `issuer` and `subject` plus those of every `actor` hop when the * exchange carries one, and a SHA-256 of `JSON.stringify(body)`. Two routes, two cached steps, or two * callers therefore never share an entry by accident. The route id is * part of the key, so give the route an `.id()`: an unnamed route gets * a fresh id on every start, and its entries in an external provider * stop matching after a restart. * * The default needs a body. An exchange whose body is `undefined` (a * bodiless `http()` GET, whose input lives in the * `routecraft.http.params` / `routecraft.http.query` headers) fails * with `RC5029`, as does a body `JSON.stringify` cannot represent * (a top-level function or symbol, a circular reference, a `BigInt`). * Nested functions and symbols are silently dropped by `JSON.stringify`, * so bodies differing only there share a key. Supply `key` for those. A * `null` body is keyable. * * A custom `key` is used VERBATIM: nothing is added to it. Every route * and step on the same provider shares entries for equal keys, and so * does every caller, so put the route (`ex.headers[HeadersKeys.ROUTE_ID]`) * and the caller's identity (`ex.principal?.issuer` and `subject`) in * the key, and drop the principal only when every caller sees the same * answer. On a route that admits delegation, add the current actor * (`ex.principal?.actor?.issuer` and `subject`) as well, or two delegates * acting for the same subject share entries. * * Performance: the default hashes a JSON serialisation of the body on * every exchange. For hot paths or large bodies (file contents, large * payloads), supply a `key` that returns a stable identifier already * to hand (an id field, a content hash in a header, a tuple of the * relevant fields) to avoid re-serialising and re-hashing. */ key?: (exchange: Exchange) => string; /** * Time to live. After expiry, the next execution with the same key * recomputes the value. When omitted, the provider's default applies * (the bundled in-memory provider keeps entries until LRU eviction). */ ttl?: Duration; /** * Cache backend. Defaults to a process-wide in-memory provider. Pass * a custom provider (Redis, multi-tier, file-backed, etc.) by * constructing an implementation of {@link CacheProvider} and * handing it in here. */ provider?: CacheProvider; } /** * Where a cache sits, which the default key namespaces its entries by. * * `site` is the JSON of `[index, stackIndex, below, fingerprint]`: the * step's pre-order index in the route's step tree, the cache's index in * that step's full wrapper stack, `[kind, optionsFingerprint]` for each * wrapper between the cache and the innermost step, and a fingerprint of * the innermost step's definition (operation, label, adapter id and * options, callable source). It derives from the route definition alone, * so every process running the same route source computes the same site * and shares entries in an external provider. Moving a step, reordering * its wrappers, or changing its definition or the options of a wrapper * below the cache (an `.error()` handler, a `.retry()` policy) changes the * site, so those edits cause misses rather than replays of entries the old * definition produced. * * Wrappers above the cache contribute their count (through `stackIndex`) * but not their options: they run outside the cached computation and none * rewrites the exchange it passes inward, so they cannot change what is * stored for a key. The fingerprints do not cover what a callable closes * over or config read at run time; put those in an explicit key when they * change the output. * * At route scope there is no site; `pipeline` is the * {@link pipelineFingerprint} of every step a hit skips, nested branches * and wrapper options included, so editing any of them misses rather than * replaying what the old pipeline produced. The route's `.input()` and * `.output()` schemas stay out: input validation runs before the cache * check (its result is the body the key hashes) and output validation runs * after the pipeline on a hit as on a miss, so neither shapes a stored * entry. * * @internal */ type CacheKeyScope = { readonly kind: "route"; readonly routeId: string; readonly pipeline: string; } | { readonly kind: "step"; readonly routeId: string; readonly site: string; }; /** * Internal resolved shape of {@link CacheOptions}: every field is * populated, with defaults filled in. Shared between the step-scope * wrapper and the route-scope filter steps. * * @internal */ interface ResolvedCacheOptions { key: (exchange: Exchange, scope: CacheKeyScope) => string; /** No custom `key` was supplied, so `key` is {@link defaultCacheKey}. */ usesDefaultKey: boolean; ttl: number | undefined; provider: CacheProvider; } /** * Step-scope `.cache()` wrapper. On a cache hit, replaces * `exchange.body` with the cached value and skips the wrapped step. On * a miss, runs the wrapped step, caches its produced body, and lets * the pipeline continue normally. Errors from the wrapped step are * NOT cached and propagate to outer wrappers / route-level handlers; an * error reply the step returns without throwing is cached (see * `StepBuilderBase.cache`). * * The default key is namespaced by route id and by this wrapper's site * ({@link CacheKeyScope}), assigned by {@link assignCacheSites} when the * route is built. A wrapper * that never went through `RouteBuilder.build()` (constructed by hand) * gets a random site on first use, so it never shares entries with * another cache, at the cost of not sharing them across restarts either. * * Concurrent exchanges with the same derived key share a single * computation via the provider's `getOrCompute`, so a slow underlying * operation runs at most once per key per TTL window. * * Emits cache lifecycle events on the route's event bus: * - `route:cache:hit` when a cached value is reused. * - `route:cache:miss` when the wrapped step runs. * - `route:cache:stored` when a fresh value is written. * - `route:cache:failed` when key derivation or the provider throws. * * Known limitation: concurrent exchanges that share a single * `getOrCompute` computation (stampede dedupe) currently report * `route:cache:hit` for the waiters rather than a distinct "deduped" signal, * which can inflate hit-rate metrics. Tracked separately; needs a * provider-interface change to report whether the current call ran the * loader. * * Ordering note: a step-scope `.error()` placed INSIDE the cache * (`.cache().error(h).to(d)`) feeds the handler's recovery value into * the cache, so a fallback becomes the permanent cached answer for that * key. Put `.error()` OUTSIDE the cache (`.error(h).cache().to(d)`) * unless caching recovery results is intended. * * Route scope (`.cache()` before `.from()`) does not use this class; it * runs as the `cacheCheck` / `cacheStore` filter steps. */ declare class CacheWrapperStep extends WrapperStep { #private; constructor(inner: Step, options?: CacheOptions); protected describeOptions(): unknown; /** * Whether this cache derives its key with {@link defaultCacheKey}, which * depends on the route id. * * @internal */ get usesDefaultKey(): boolean; /** * Record this wrapper's position in its route. Called by * {@link assignCacheSites}; see {@link CacheKeyScope}. * * @internal */ assignSite(site: string): void; protected runInner(exchange: Exchange, ctx: StepContext): Promise; } /** * Time window a `.throttle()` rate is measured over. * * Longer quota windows (week / month) are intentionally absent: this is * an IN-MEMORY limiter, so its state resets on process restart and is * not shared across instances. That is fine for second-to-day smoothing * but unsafe for a durable "N per month" quota, which needs persistent, * shared storage (a separate feature). */ type ThrottleTimeUnit = "second" | "minute" | "hour" | "day"; /** * Options for the `.throttle()` wrapper (step scope and route scope). */ interface ThrottleOptions { /** Allowed requests per `per` window. Must be a finite number > 0. */ rate: number; /** * Time window the `rate` is measured over. Default `"second"`. * * A unit word (`"minute"`) reads best for the common case; a * {@link Duration} covers windows a unit word cannot name, such as * `"90s"`. Note that a BARE NUMBER is milliseconds, as everywhere else a * `Duration` is accepted: `per: 60` is 60ms, not a minute. Write * `per: "60s"` (or `per: "minute"`) for that. */ per?: ThrottleTimeUnit | Duration; /** * Behaviour when an exchange exceeds the rate: * - `"delay"` (default): pace it, waiting until a token frees. Smooths * bursty traffic into a steady rate; never drops an exchange. * - `"reject"`: throw `RC5013` immediately so the caller fails fast * (e.g. a source returns 429) instead of being buffered. Avoids the * unbounded in-flight growth that delay can accumulate under a fast * source. A rejected exchange does NOT consume a token. */ mode?: "delay" | "reject"; /** * Burst allowance: the most calls admitted back-to-back after an idle * window before pacing kicks in (the token bucket's capacity). Default * is `rate` (one window's worth). Set it lower for stricter pacing, or * higher to tolerate spikes. Decoupled from `per`, so `{ rate: 600, * per: "minute" }` does not silently allow a 600-wide burst unless you * ask for it. */ burst?: number; /** * Partition the limit: each distinct key gets its own independent * bucket, so you can rate-limit per user, per IP, per tenant, etc. * Omit for a single bucket shared across the whole route (a global * limit). The selector runs once per exchange and MUST return a * string; coalesce missing values (e.g. `?? "anonymous"`). A selector * that throws fails the exchange (it is user code, like a transform), * so guard it rather than relying on the never-drops contract. * * @example Per authenticated principal * ```ts * .throttle({ rate: 10, key: (ex) => ex.principal?.subject ?? "anonymous" }) * ``` */ key?: (exchange: Exchange) => string; /** * Maximum number of distinct keys to track concurrently when `key` is * set. The per-key buckets live in an LRU, so memory stays bounded * even with an unbounded key space (the least-recently-used key's * bucket is evicted and resets when it next appears). Default * `10_000`. Ignored when `key` is not set. */ maxKeys?: number; /** * Optional label carried on this throttle's `route:throttle:*` events, * so stacked gates (e.g. a global limit plus a per-IP limit) can be * told apart in logs and metrics. Has no effect on behaviour. */ label?: string; } /** * {@link ThrottleOptions} normalised to what the limiter needs. Staged * on the builder's pending options for route-scope `.throttle()`. * * @internal */ interface ResolvedThrottleOptions { /** Token bucket capacity (burst allowance). */ capacity: number; /** Tokens replenished per millisecond (`rate / windowMs`). */ refillPerMs: number; /** On-limit behaviour. */ mode: "delay" | "reject"; /** Per-exchange partition selector; absent means a single shared bucket. */ key?: (exchange: Exchange) => string; /** LRU cap on distinct keys (only meaningful when `key` is set). */ maxKeys: number; /** Optional label for `route:throttle:*` events. */ label?: string; } /** * Step-scope `.throttle()` wrapper. Rate-limits the wrapped step to a * fixed number of calls per time window, pacing exchanges that exceed * the rate (backpressure) rather than dropping them. * * The limiter state lives on a {@link ThrottleController} held on the * wrapper instance. The builder constructs one wrapper instance per * route at build time, but the controller keys its buckets by Route, so * every exchange on a given Route shares one limiter while distinct * Routes (even built from the same definition) stay isolated. This is * the deliberate exception to the `WrapperStep` rule against storing * state on `this`: that rule bars PER-EXECUTION state; a rate limiter is * PER-ROUTE state and sharing it is the entire point. See * `.standards/resilience-wrappers.md` section 8. * * Emits scope-aware lifecycle events: * - `route:throttle:delayed` when an exchange must wait for a token. * - `route:throttle:passed` when an exchange is admitted (with `waited`). */ declare class ThrottleWrapperStep extends WrapperStep { #private; constructor(inner: Step, options: ThrottleOptions); protected describeOptions(): unknown; protected runInner(exchange: Exchange, ctx: StepContext): Promise; } /** * What a detached run reports back. `error` is present exactly when * `failed` is true and the failure reached the run's boundary, which is * what a resume needs to cache as the deferral's continuation result. * * @internal */ interface DetachedResult { failed: boolean; dropped: boolean; /** * The run deferred at a `.defer()`. Distinct from every other outcome: * the exchange is neither finished nor failed, and its terminal body is * the `Deferred` acknowledgment rather than the route's output. A caller * that treats it as a completion publishes both a false receipt and the * next deferral's resume token. */ deferred: boolean; exchange: Exchange; error?: unknown; } /** * Function that forwards a payload to another route via the direct adapter and returns its result. * * @param endpoint - The target route's direct endpoint * @param payload - The data to send * @returns The result of the target route's pipeline */ type ForwardFn = (endpoint: RegisteredDirectEndpoint, payload: unknown) => Promise; /** * Error handler invoked when a step in the route pipeline throws an unhandled error. * * The pipeline does not resume after this handler runs. The handler's return value * becomes the route's final exchange body. Use `forward` to delegate to another route. * * Instead of a recovery body the handler may return a branded `Recovery` * directive built with the `recovery` helpers (see `recovery.ts`): * `recovery.drop(reason?)` discards the exchange (emits * `route:exchange:dropped`, no `route:exchange:completed`), and * `recovery.rethrow()` propagates the original error exactly as if the * handler had thrown it. Plain (unbranded) return values are unaffected. * * @param error - The thrown error * @param exchange - The exchange at the point of failure * @param forward - Sends a payload to another route via the direct adapter * @returns Static fallback value, result of forward(), or a `Recovery` directive */ type ErrorHandler = (error: unknown, exchange: Exchange, forward: ForwardFn) => unknown | Promise; /** * Per-direction schema bundle for discoverable-capability routes. Mirrors the * Standard Schema shape used by adapters; the engine enforces `input` before * pipeline steps run and `output` before the primary destination fires. */ interface RouteSchemas { /** Standard Schema for the body. */ body?: StandardSchemaV1; /** Standard Schema for the headers. */ headers?: StandardSchemaV1; } /** * Well-known tag values surfaced as autocomplete suggestions while still * accepting any user-defined string. Use these consistently to enable * downstream filtering (e.g. an agent that only whitelists `"read-only"` * tools). */ type KnownTag = "read-only" | "destructive" | "idempotent" | "open-world"; /** * Tag value: one of the framework's well-known tags or any user string. * The `& {}` keeps autocomplete on `KnownTag` while accepting arbitrary * strings. */ type Tag = KnownTag | (string & {}); /** * Route-level discovery bundle. Adapters that maintain registries (direct, * mcp) mirror these fields into their registry entries; the engine uses * `input` / `output` for framework-enforced validation regardless of adapter. * * Set via the `.title()`, `.description()`, `.input()`, `.output()`, * and `.tag()` builder methods. All fields are optional. */ interface RouteDiscovery { /** Human-readable display title for discovery consumers (agents, docs). */ title?: string; /** Human-readable description of what this route does. */ description?: string; /** Input schemas runtime-enforced before pipeline steps run. */ input?: RouteSchemas; /** Output schemas runtime-enforced before the primary destination. */ output?: RouteSchemas; /** * Tags surfaced on `ToolsCatalog` entries for the builder form of * `tools((catalog) => ...)` in `@routecraft/ai`, and on resolved * tool entries for downstream inspection. Empty/missing means no * tags. */ tags?: Tag[]; } /** * Configuration for a route: source, steps, and consumer. * * Describes how data flows from a source through processing steps to destinations. * The builder preserves body type `T`; at runtime the runnable Route uses `Exchange` * and handlers/events receive `Exchange` unless you narrow or use `Route`. * * @template T - Body type produced by the source (flowing through the chain until type-erased at runtime) * * @example * ```typescript * const def: RouteDefinition = { * id: 'my-route', * sources: [simple('hello')], * steps: [...], * consumer: { type: SimpleConsumer, options: undefined } * }; * ``` */ type RouteDefinition = { /** Unique identifier for the route */ readonly id: string; /** * The sources that feed data into the route. A route may expose multiple * ingresses (e.g. `direct` for internal callers, `mcp` for agents, `http` * for integrations) that all drive the same downstream pipeline. The route * stays a single logical entity: one id, one set of lifecycle events, and * (where the registries derive a public name from the route id) one name * across ingresses. Every entry must be non-empty; the builder normalizes a * single `.from(x)` to `[x]`. */ readonly sources: readonly Source[]; /** Processing steps that transform, filter, or direct the data */ readonly steps: Step[]; /** Consumer configuration that determines how data is processed */ readonly consumer: { /** The type of consumer to use */ type: ConsumerType; /** Options for the consumer */ options: unknown; }; /** * Optional error handler invoked when a step throws an unhandled error. * If defined, the handler's return value becomes the final exchange body. * If not defined, the error is logged and emitted via the error event (current behavior). */ readonly errorHandler?: ErrorHandler; /** * Framework-managed filters that run BEFORE the source-attached * parse step (and therefore before everything else). Today: the * `.authorize()` ValidateSteps in declaration order. This is chain * position #2 in `.standards/pre-from-filter-chain.md`. * * @internal */ readonly preParseFilters: Step[]; /** * Framework-managed filters that run AFTER the source-attached parse * step but BEFORE the user pipeline. Today: the route-scope * `cache-check` filter (chain position #9). The future * `circuitBreaker` (#6) slots in once it lands. Route-scope * `throttle` (#5), `retry` (#7), and `timeout` (#8) instead sit * OUTSIDE this array (it is wrapped by the retry / timeout segments), * so they ride on their own definition fields below. * * @internal */ readonly postParseFilters: Step[]; /** * Framework-managed filters that run AFTER the user pipeline. * Today: the route-scope `cache-store` filter (chain position #10) * when `.cache()` is configured. Reached only on miss-success; the * cache-check filter pushes `steps: []` on a hit to short-circuit. * * @internal */ readonly postFromFilters: Step[]; /** * Optional route-level discovery bundle: title, description, and input / * output schemas. Populated via `.title()`, `.description()`, `.input()`, * and `.output()` on the route builder. The engine enforces `input` and * `output` schemas; discovery-aware adapters (direct, mcp) mirror the * metadata into their registries. */ readonly discovery?: RouteDiscovery; /** * The route's enablement predicate and refresh cadence, from `.enabled()`. * * Route metadata rather than a filter: it decides whether this route runs * at all, per route and per lifecycle, so it never enters the pre-from * filter chain and never sees an exchange. Absent means always enabled, * which is every route that does not declare one. */ readonly enablement?: RouteEnablement; /** * True when the route declares a route-entry `.authorize()`. Mirrored to * sources via {@link SourceMeta.requiresPrincipal} so identity-capable * transports enforce credential verification before dispatching into the * route, even on an unwalled mount. */ readonly requiresPrincipal?: boolean; /** * Route-scope `.retry()` config (pre-from filter chain position #7). * Unlike the cache filters, retry is not a flat step in * `postParseFilters`: it scopes over the whole chain tail (timeout, * cache-check, user pipeline, cache-store) and re-runs it on * failure, so the pipeline executor wraps the tail in a retry * segment step when this is set. See * `.standards/pre-from-filter-chain.md`. */ readonly retry?: ResolvedRetryOptions; /** * Route-scope `.timeout()` config (pre-from filter chain position * #8). Bounds each run of the chain tail below it with a deadline; * placed inside `retry` so every attempt gets its own deadline. Like * `retry`, realized as a segment step wrapped around the tail by the * pipeline executor rather than a flat `postParseFilters` entry. */ readonly timeout?: ResolvedTimeoutOptions; /** * Route-scope `.throttle()` admission gates (pre-from filter chain * position #5), in declaration order. Each is a one-shot gate (a flat * step, not a segment like retry / timeout); the exchange must be * admitted by ALL of them, so stacking `.throttle()` calls AND-combines * independent limits (e.g. a global ceiling plus a per-principal rate). * The pipeline executor places them OUTSIDE the retry (#7) / timeout * (#8) segments (throttle #5 is above them in the chain) and runs them * once per exchange; a retried attempt re-runs only the tail below and * never re-acquires a token. * * @internal */ readonly throttle?: Step[]; /** * Route-scope `.circuitBreaker()` controller (pre-from filter chain * position #6). Unlike retry / timeout (config objects re-built into a * segment per run), the breaker holds persistent per-Route state (the * failure window and the open/half-open machine), so the builder stores * the live {@link CircuitBreakerController} here once at `.from()` time * and the pipeline executor wraps the chain tail in a breaker segment * around it. Sits OUTSIDE the retry (#7) / timeout (#8) segments and * INSIDE the throttle (#5) gate: when open it fast-fails before retry / * timeout run, so one tripped breaker call is recorded per fully * exhausted attempt, not per retry. See * `.standards/pre-from-filter-chain.md`. * * @internal */ readonly circuitBreaker?: CircuitBreakerController; /** * Route-scope `.concurrency()` bulkhead controllers (one per * `.concurrency()` call; they nest). Like the circuit breaker they hold * persistent per-Route state (the slot pool / semaphores), so the builder * stores the live {@link ConcurrencyController}s here once at `.from()` * time and the pipeline executor wraps the chain tail in a bulkhead * segment per controller. Sits at the INNERMOST resilience position, * INSIDE the retry (#7) / timeout (#8) segments, so a slot is acquired * per attempt and released between retry backoffs (never held while a * retry sleeps). See `.standards/pre-from-filter-chain.md`. * * @internal */ readonly concurrency?: ConcurrencyController[]; /** * Every `.defer()` the route can reach, resolved once at build time and * in pre-order. Each step carries the {@link DeferSite} naming its * address and its continuation, which is what a resume addresses: the * continuation cannot be a closure captured at defer time, because * execution two happens in a different process. * * Absent (rather than empty) on a route that never defers, so the * common case costs nothing and `context.start()` can tell "no defers" * from "defers, needs a runtime" without walking anything. * * @internal */ deferSteps?: DeferrableStep[]; /** * Every defer-capable `.to()` / `.enrich()` step the route carries on * its primary flow, each holding the re-entrant {@link DeferSite} the * walk assigned it. Separate from {@link RouteDefinition.deferSteps} * deliberately: a capable step only MAY defer at runtime, so it does not * make the route require a deferral runtime at startup (`RC5052` stays * keyed to static sites; a runtime deferral without the runtime fails as an * ordinary step error naming the config line), and it does not trip the * route-scope cache refusal, whose "silently never caches" reasoning * assumes every run defers. * * @internal */ reentrantDeferSteps?: DeferCapableStep[]; /** * The route can reach a `.resume()`. Recorded alongside * {@link RouteDefinition.deferSteps} because a resume ingress needs the * deferral runtime too (it verifies tokens and reads the store), and a * resume-only route carries no defer sites to infer that from. * * @internal */ usesResume?: boolean; /** * The route has no `.id()` and a `.cache()` using the default key, which * is namespaced by the route id. The generated id changes on every start, * so the route warns once it is registered. Set by `RouteBuilder.build()`. * * @internal */ volatileCacheKey?: boolean; /** * Fingerprint of the user pipeline a route-scope `.cache()` skips on a * hit, folded into its default key (see `CacheKeyScope`). Set by * `RouteBuilder.build()` on a route with a route-scope cache. * * @internal */ cachePipeline?: string; }; /** * Represents a runnable route that processes data. * * Routes handle the flow of data from a source through processing steps * and can be started and stopped. Use Route when you know the route's * body type (e.g. from a typed definition); at runtime, handlers and * events receive Exchange (body: unknown) unless narrowed. * * @template T The body type of the route's exchange when known (default unknown) */ interface Route { /** The context this route belongs to */ readonly context: CraftContext; /** The route's configuration */ readonly definition: RouteDefinition; /** * Fires when in-flight work on this route is being abandoned. NOT fired * when the route merely stops accepting new work, which is what graceful * shutdown's first stage does. */ readonly signal: AbortSignal; /** * Fires when the route stops accepting new work, which is the moment * graceful shutdown begins. Work already in flight keeps running. * * A wrapper that is WAITING observes this: a `.delay()` wait, a * `.throttle()` pacing gap, a bulkhead queue slot, a `.retry()` backoff. * The wait is cut short once shutdown begins and the exchange still * reaches a terminal outcome, where sitting the timer out burns the * shutdown deadline and ends in an abandonment that reports nothing. * {@link Route.signal} is for abandonment itself, never for shortening a * wait. */ readonly intakeSignal: AbortSignal; /** * Abandon in-flight execution on this route. The forced stage of shutdown * and an explicit `stop()`; never the graceful drain. * @internal */ abortExecution(reason?: unknown): void; /** * How much work is still in flight. Read when a forced shutdown reports * what it is abandoning, which is the only forensic record that stage * leaves. * @internal */ readonly inFlightCount: number; /** Logger for this route (pino child logger) */ logger: ReturnType; /** * Start processing: subscribe to every source and begin delivering messages through the steps. * @returns Promise that resolves when all sources have been subscribed and the consumers are ready */ start(): Promise; /** * Stop the route: abort all source subscriptions and clear the internal queues. */ stop(): void; /** * Re-arm a stopped route so {@link Route.start} can run again, under the * fresh controller the context now holds for it. * * Only enablement calls this. Both abort controllers latch, and the queues * and consumers behind them belong to the run that just ended, so a route * brought back from `disabled` needs new ones or it would refuse to start * against its own aborted signal. Route IDENTITY is deliberately preserved: * ops tracks by route id, the capability registry keys by endpoint, and * listeners hold this instance, so re-enabling must not hand out a * different object. * * @param controller - The intake controller the context has registered * for this route, so the two never disagree about which one a shutdown * aborts. * @internal */ resetForRestart(controller: AbortController): void; /** * Wait until all in-flight message handlers and tracked tasks (e.g. tap) have completed. * Does not stop the route; use stop() to abort the sources. */ drain(): Promise; /** * Track a background task (e.g. tap) for this route. * @param promise The promise to track * @internal */ trackTask(promise: Promise): void; /** * Register a callback run at the START of every `drain()` (which shutdown * also calls), before the in-flight wait loop. Lets a step holding an * exchange outside the queue (debounce) flush it into in-flight work so * drain releases it promptly instead of waiting out a timer. Callbacks * must be idempotent: `drain()` can be called more than once. * * @internal */ onDrain(callback: () => void): void; /** * Run `steps` against a revived exchange as a first-class run of this * route: its own `route:exchange:started` / `:completed` pair, the route-scope * `.error()` handler, and `.output()` validation before completion. * * The entry point for execution two. The steps handed in are the deferred * exchange's continuation, so the route resumes partway down its pipeline * without re-running what already ran, and without re-running the * pre-from filter chain (authorize, parse, input, throttle, cache), all * of which belong to execution one. * * @param exchange - The rehydrated exchange, already bound to this route * @param steps - The continuation, in execution order * @internal */ runContinuation(exchange: Exchange, steps: ReadonlyArray>): Promise; /** * Push an error into this route's error channel for an exchange that is * not currently running in it. * * The resume path uses it so a revival failure (an expired deferral, a * continuation that changed under a deferred exchange) reaches the * DEFERRED route's `.error()` handler rather than only the ingress * route's. That is the difference between a route that can notify the * approver and re-ask, and an approver left at a dead link. * * @param exchange - The exchange the failure concerns * @param error - The failure to route * @param operation - Step label reported on the error events * @internal */ enterErrorChannel(exchange: Exchange, error: unknown, operation: string): Promise; /** * Build a forward function the route uses to delegate from an * error / fallback handler to another route via the direct adapter. * Exposed so step-scope `WrapperStep` subclasses can hand the same * callable to a user-supplied handler as the route-level pipeline * does. * * @param caller The exchange the forward is issued from. Its headers * travel with the forwarded call, so the target sees the same * principal and correlation id as the caller. Required rather than * optional so a new call site cannot silently forward anonymously. * @internal */ getForward(caller: Exchange): ForwardFn; } /** * Default implementation of the Route interface. * * Manages message flow from the source through the defined steps and the * internal processing queue to the consumer. Handles start, stop, drain, and * background task tracking (e.g. for tap). */ declare class DefaultRoute implements Route { readonly context: CraftContext; readonly definition: RouteDefinition; /** * Stops INTAKE: sources subscribe against this, and aborting it means * "produce nothing further". It does NOT cancel work already in flight, * which is what makes graceful shutdown's first stage a drain rather than * a cancellation. */ private abortController; /** * Abandons EXECUTION: surfaced as {@link DefaultRoute.signal} and threaded * into steps, wrappers and agent dispatch. Aborted only when work is * genuinely being given up on: a forced shutdown, an explicit `stop()`, or * a route that failed to start. * * Separate from intake because one controller carrying both meanings made * the documented two-stage shutdown a lie: the first signal, which * promises to stop accepting work and drain, cancelled every in-flight * exchange as well. */ private executionController; /** Logger for this route (pino child logger) */ readonly logger: ReturnType; /** Internal queues, one per source, for passing messages to the consumers */ private messageChannels; /** Processes messages from the message channels, one consumer per source */ private consumers; /** All in-flight work (handler and task promises) for drain */ private inFlight; /** Callbacks run at the start of drain() to flush deferred holds (debounce). */ private drainCallbacks; /** * Create a new route instance. * * @param context The context this route belongs to * @param definition The route's configuration * @param abortController Optional controller for aborting the route */ constructor(context: CraftContext, definition: RouteDefinition, abortController?: AbortController); /** * Emit `route:stopping` / `route:stopped` when the intake controller * aborts, however that abort arrives (shutdown, `stop()`, a finite source * completing). Re-armed on every restart because the listener belongs to * the controller, and a restart installs a new one. */ private watchIntakeAbort; /** @inheritDoc */ get intakeSignal(): AbortSignal; /** @inheritDoc */ get inFlightCount(): number; /** * The EXECUTION signal: fires when in-flight work is being abandoned, not * when the route merely stops accepting new work. Steps, resilience * wrappers and agent dispatch observe this. */ get signal(): AbortSignal; /** * Abandon in-flight execution on this route. * * Graceful shutdown never calls this: stage one aborts intake and waits. * It is the forced stage, an explicit `stop()`, and a route that failed to * start. * * @param reason - Recorded as the abort reason for anything observing it. * @internal */ abortExecution(reason?: unknown): void; /** * Create a new exchange object from a message and optional headers. * * Sources that authenticate at their boundary set the structured * `Principal` on `headers["routecraft.auth.principal"]` before calling * the consumer handler; that value flows through this method as a * normal header and surfaces on the exchange via the `ex.principal` * getter. * * This is the single route-ingress boundary: both the consumer handler * and `buildForward` funnel through it, and a `direct()` source hands the * target its caller's headers verbatim. Engine-owned identity is * therefore re-established here rather than inherited: * * - `routecraft.id` is minted fresh. Ingress is always a new exchange, * and an inherited id collides in every store keyed by it (telemetry * spans and rows, deferral ids). The correlation id is what links a * hop, not the exchange id. * - `routecraft.split_hierarchy` is dropped. A split group only joins * within the executor run that created it, so an inherited hierarchy is * unjoinable, and `.aggregate()` would resolve its trailing group id * against the context-wide split-parent store and delete the caller's * still-in-flight entry. * - `routecraft.route` and `routecraft.operation` are stamped for the * receiving route. * * Everything else, principal and correlation id included, is inherited by * reference. See `.standards/security.md` section 3. * * @param message The message data * @param headers Optional headers to include * @returns A new Exchange object * @private */ private buildExchange; private cachedExecutorDeps?; private cachedValidationDeps?; /** * Assemble the deps object for the pipeline executor. Memoized: every * field is stable for the lifetime of the route, and this is called on * the per-exchange hot path. */ private executorDeps; /** Assemble the deps object for the pipeline validation helpers (memoized, see {@link executorDeps}). */ private validationDeps; /** * Track a background task (e.g. tap) for this route. * @internal */ trackTask(promise: Promise): void; /** * Register a flush callback run at the start of drain(). See {@link Route.onDrain}. * @internal */ onDrain(callback: () => void): void; /** * Start processing data on this route. * * This method: * 1. Registers each per-source consumer to process messages * 2. Subscribes to every source to receive data * * @returns A promise that resolves when the route has started * @throws {RoutecraftError} If the route has been aborted */ start(): Promise; /** * Create an AbortController that aborts when the route's controller aborts, * but whose own abort does not propagate back to the route. Used to give * each ingress of a multi-source route an independent lifetime so a finite * source completing does not tear down its sibling ingresses. */ private linkedChildController; /** * Build the handler registered on every per-source consumer. The handler is * shared across all of a route's ingresses so they drive one pipeline; it * stashes the source-supplied parser and the route's `.input()` validator * on exchange internals so `runPipeline` runs both INSIDE the pre-from * filter chain (positions #3 / #4). A parse or validation failure is * therefore a normal step failure, routable through the route-scope * `.error()` handler (chain position #1) for every source shape; see * #187 (parse) and #447 (the input fold). */ private buildConsumerHandler; /** * Stop processing data on this route. * * This method: * 1. Unsubscribes from the internal processing queue * 2. Aborts the route's controller */ stop(): void; /** * One (channel, consumer) pair per source, so each ingress gets its own * delivery queue and, for batch routes, its own batch window. All * consumers drive the same shared step pipeline via the handler registered * in start(), so the route stays a single logical entity regardless of how * many ingresses it exposes. * * Shared by the constructor and the restart so a consumer that gains a * construction dependency cannot get it in one path and silently miss it * in the other. */ private buildChannelsAndConsumers; /** @inheritDoc */ resetForRestart(controller: AbortController): void; /** * Process an exchange through the route's steps. * Resolves with the result immediately; then waits for background tasks (e.g. tap) before cleanup. * * @param exchange The initial exchange to process * @returns A promise that resolves when processing is complete * @private */ private handler; /** * Run a revived exchange through its continuation. See * {@link Route.runContinuation}. * * Tracked as in-flight work so `drain()` (and therefore shutdown) waits * for execution two, which arrives out of band and would otherwise be * invisible to the route that owns it. * * @internal */ runContinuation(exchange: Exchange, steps: ReadonlyArray>): Promise; /** * Push an error into this route's error channel. See * {@link Route.enterErrorChannel}. * * Implemented as a one-step pipeline whose step throws, rather than by * calling the handler directly, so a revival failure produces exactly the * events a step failure produces (`route:step:error`, * `route:error-handler:invoked` / `:recovered` / `:failed`, or the * default `route:error` + `context:error` + `route:exchange:failed` * path). A second, hand-rolled error path would drift from that one. * * @internal */ enterErrorChannel(exchange: Exchange, error: unknown, operation: string): Promise; /** * Build a forward function that sends a payload to another route via the direct adapter. * * Exposed (`@internal`) so step-scope `WrapperStep` subclasses can hand * the same forward callable to a user-supplied error / fallback handler * as the route-level pipeline does. Resolve via * `getExchangeRoute(exchange).getForward(exchange)`. * * @returns A forward function */ getForward(caller: Exchange): ForwardFn; /** * Build a forward function that sends a payload to another route via the direct adapter. * * The forwarded exchange inherits the calling exchange's headers, which is * what carries the caller's principal and correlation id across the hop. A * `direct()` destination gets this for free because it hands the target its * live exchange; forward builds a fresh envelope, so it has to pass the * headers explicitly or the target sees an anonymous, separately-traced * call. * * @param caller The exchange the forward is issued from * @returns A forward function * @private */ private buildForward; /** * Wait for all in-flight work (handlers and tasks) to complete. * Loops until no new work is added (drains consumer queue). */ drain(): Promise; /** * Run the registered drain-flush callbacks (see {@link Route.onDrain}). * Callbacks are idempotent by contract, so calling this repeatedly (once * up front and once per drain settle round) is safe. */ private runDrainCallbacks; /** * Check if the route has been aborted, and throw an error if it has. * * @throws {RoutecraftError} If the route has been aborted * @private */ private assertNotAborted; } /** * A discoverable capability registered in a context: an endpoint plus the * route's discovery bundle (`.title()` / `.description()` / `.input()` / * `.output()` / `.tag()`). * * Returned by {@link CraftContext.capabilities}; dispatch into a * capability with `CraftClient.sendDirect(endpoint, body)`. */ interface Capability extends RouteDiscovery { /** Raw endpoint / route id, exactly as passed to `.id(...)` / `direct(...)`. */ endpoint: string; /** * The remote this capability was imported from (`defineConfig({ remotes })`), * absent for a capability a local route registered. Read by the ops * listing to name the origin and by the agent tool policy so a rule can * keep an agent local-only. */ remote?: string; } /** * Core-owned store key for the capability registry, keyed by RAW endpoint. * * Ownership is deliberately inverted from "core reads an adapter's * registry": adapters that expose discoverable endpoints WRITE into this * registry via {@link registerCapability}, and `context.capabilities()` * reads it without knowing which adapter populated it. That keeps the * core context free of adapter knowledge and lets future discoverable * adapters surface in `capabilities()` without core changes. * * `Symbol.for` so duplicate copies of the package (CLI vs user module) * share one registry. * * @internal The registry shape is internal; read via * `context.capabilities()` and write via {@link registerCapability}. */ declare const CAPABILITY_REGISTRY: unique symbol; /** * Register (or update) a discoverable capability on the context. Called * by adapters when a discoverable endpoint subscribes; the direct source * is the built-in writer, and ecosystem adapters exposing their own * discoverable endpoints use the same call. * * Keyed by the RAW endpoint id; any transport-level key encoding stays * inside the adapter that needs it. * * @returns A disposer that removes THIS registration when the endpoint is * still held by it, and does nothing once something else has taken the * name. An unconditional delete would be wrong on the one path that * matters: a local route stopping hands its endpoint to the remote route * it shadowed, `route:stopped` reaches the remotes plugin before the * direct source's own abort listener, and a blind delete would then * erase the replacement that already owns the name. */ declare function registerCapability(context: CraftContext, capability: Capability): () => void; /** * Core-owned store key for endpoints declared internal, keyed by RAW * endpoint. Same ownership inversion as {@link CAPABILITY_REGISTRY}: * adapters write, consumers read without knowing which adapter wrote. * * A `direct({ internal: true })` source registers here INSTEAD of the * capability registry: its in-process endpoint works unchanged, while the * two external doors (ops dispatch, agent `directTool` resolution) find * no capability. This map is what lets their refusals say "declared * internal" instead of the wrong advice "add `.from(direct())`" for a * route that has one. * * A map rather than a set of names, because a marker has to be removable * by the registration that wrote it and by nothing else. The value is an * identity token: a stale disposer from a route that has already stopped * compares its own token against what the map holds and finds a stranger * there, so it leaves the live marker alone. * * @internal The map shape is internal; write via * {@link registerInternalEndpoint} and read via {@link isInternalEndpoint}. */ declare const INTERNAL_ENDPOINT_REGISTRY: unique symbol; declare module "@routecraft/routecraft" { interface StoreRegistry { [CAPABILITY_REGISTRY]: Map; } } declare module "@routecraft/routecraft" { interface StoreRegistry { [INTERNAL_ENDPOINT_REGISTRY]: Map; } } /** * Record that an endpoint declared itself internal: composable in-process, * deliberately absent from the capability registry. Called by adapters at * subscribe, alongside where they would otherwise register a capability. * * @returns A disposer with the same ownership rule as * {@link registerCapability}'s: it clears the marker only while this * registration still holds it. */ declare function registerInternalEndpoint(context: CraftContext, endpoint: string): () => void; /** * Whether an endpoint declared itself internal. Read by the external doors * (ops dispatch, agent tool resolution) to refuse by name rather than with * advice that does not apply. */ declare function isInternalEndpoint(context: CraftContext, endpoint: string): boolean; /** * Store key under which test-time adapter overrides are registered. * * Intended for use by `@routecraft/testing`'s `testContext().override(...)` * API. Production code should not write to this key. * @internal */ declare const RC_ADAPTER_OVERRIDES: unique symbol; /** * Recorded call to an overridden destination adapter (send / enrich). * @internal */ interface AdapterSendCall { /** Args that were passed to the adapter's factory at route definition time. */ args: unknown[]; /** Snapshot of the exchange as seen by the adapter at the moment of the call. */ exchange: { id: string; body: unknown; headers: ExchangeHeaders; }; /** Result the mock returned (undefined for void-returning sends). */ result: unknown; } /** * Recorded call to an overridden source adapter (subscribe). * @internal */ interface AdapterSourceCall { /** Args that were passed to the factory at route definition time. */ args: unknown[]; /** Number of messages yielded by this subscription. */ yielded: number; } /** * Brand marking a source fixture that carries explicit headers alongside its * body. Lets a source-role mock reproduce the body/headers split that real * envelope-carrying sources (http, mail) perform, so a route that reads * `routecraft..*` headers can be exercised through `mockAdapter`. * * Use the `sourceMessage()` helper from `@routecraft/testing` to construct * these rather than building the branded object by hand. * @internal */ declare const SOURCE_FIXTURE: unique symbol; /** * A source fixture pairing a message body with the headers a real source would * have attached. Recognised by {@link wrapSourceWithOverride}. * @internal */ interface SourceFixture { readonly [SOURCE_FIXTURE]: true; /** The body the source would deliver on the exchange. */ readonly body: M; /** Headers the source would attach (e.g. `routecraft.mail.*`). */ readonly headers?: ExchangeHeaders; } /** * Type guard for {@link SourceFixture}. A plain message is delivered as the * body with no headers; a branded fixture is unwrapped into `(body, headers)`. * @internal */ declare function isSourceFixture(value: unknown): value is SourceFixture; /** * A source-role fixture: either a plain message (delivered as the body with no * headers) or a {@link SourceFixture} carrying body + headers. * @internal */ type SourceMessage = M | SourceFixture; /** * Handler shape for a source-role mock. May be a plain array of fixtures, * an async iterable, or a callable that returns either (receiving the * construction args so it can vary by call site). Each fixture may be a plain * message or a {@link SourceFixture} (via `sourceMessage()`) to also attach * headers. * @internal */ type SourceOverrideBehavior = readonly SourceMessage[] | AsyncIterable> | Iterable> | ((args: unknown[]) => readonly SourceMessage[] | Iterable> | AsyncIterable>); /** * Handler shape for a destination-role mock. Receives the exchange (as * seen by the adapter) and a meta object containing the factory args used * at the call site. Returning a value replaces `exchange.body` upstream. * @internal */ type SendOverrideHandler = (exchange: Exchange, meta: { args: unknown[]; }) => unknown | Promise; /** * An override registered on a test context. `target` may be either the * factory function that produced the adapter (matched via the factory-tag * set by `tagAdapter`) or the adapter's constructor class (matched by * `adapter.constructor === target`). Both routes coexist so any adapter * can be mocked without opt-in, while tagged factories keep nicer DX. * @internal */ interface AdapterOverride { /** Factory function or adapter class to match against the adapter instance. */ target: unknown; /** Optional source-role behaviour (used when adapter has `subscribe`). */ source?: SourceOverrideBehavior; /** Optional destination-role behaviour (used when adapter has `send`). */ send?: SendOverrideHandler; /** Recorded calls, populated at execution time. */ calls: { source: AdapterSourceCall[]; send: AdapterSendCall[]; }; } /** * The minimal synchronous SQLite surface the framework's storage * subsystems need, declared once for every consumer. * * Declared here rather than imported so neither `bun-types` nor * `@types/better-sqlite3` reaches the published type surface. `bun:sqlite` * and `better-sqlite3` both satisfy this shape, which is what lets one * store implementation sit on either driver. * * A consumer needing more than this extends {@link SqliteDatabase} with * the members it actually uses, rather than re-deriving what the two * drivers have in common. */ interface SqliteStatement { run(...params: unknown[]): unknown; all(...params: unknown[]): unknown[]; get(...params: unknown[]): unknown; } interface SqliteDatabase { exec(sql: string): void; prepare(sql: string): SqliteStatement; close(): void; } /** * Constructor shape shared by both drivers. The options argument is * optional because `bun:sqlite` and `better-sqlite3` accept one and the * deferral store passes none; typing it here keeps callers that do pass * options from re-declaring the whole constructor. */ type SqliteDatabaseConstructor = new (filename: string, options?: { readonly?: boolean; }) => SqliteDatabase; /** * Which driver backs a resolved connection. Surfaced so the store factory * can log what a deployment actually got, and so tests can assert the split * rather than infer it. */ type SqliteDriverName = "bun:sqlite" | "better-sqlite3"; interface ResolvedSqliteDriver { readonly name: SqliteDriverName; readonly Database: SqliteDatabaseConstructor; } /** * Resolve a synchronous SQLite driver for the current runtime. * * `bun:sqlite` under Bun, `better-sqlite3` as an optional peer under Node, * and an in-memory fallback with a loud warning when neither is available. * The default is durable wherever the runtime allows, because surviving a * restart is the feature's whole promise. * * ## Why not `node:sqlite` * * It is ruled out by the engines floor rather than by its API: * * | Runtime | `bun:sqlite` | `node:sqlite` | `better-sqlite3` | * | --- | --- | --- | --- | * | Bun >= 1.1 (our floor) | built in | n/a | installable | * | Node 22.0 - 22.4 (our floor) | n/a | does not exist | installable | * | Node 22.5+ | n/a | behind `--experimental-sqlite` | installable | * | Node 24+ | n/a | unflagged | installable | * * Core's floor is Node 22.0, so across the supported range `node:sqlite` * either does not exist or needs a CLI flag the framework cannot set on the * user's behalf. That inverts the naive preference order for the Node arm * and leaves `better-sqlite3` as the only Node option that works on the * whole range. * * ## Graduation condition for `node:sqlite` * * `node:sqlite` becomes the preferred Node driver, ahead of * `better-sqlite3`, once `@routecraft/routecraft`'s `engines.node` floor * moves past the first Node major where it runs unflagged (Node 24). At * that point it is a built-in with no install step and no native rebuild, * which beats an optional peer on every axis; `better-sqlite3` stays * supported as a fallback for anyone below the new floor. Revisit this * function and nothing else: the store talks to {@link SqliteDatabase}, * not to a driver. * * @param consumer - Names the subsystem asking, so an absent peer names * what wanted it ("deferral store (sqlite)", "telemetry (sqlite)") * rather than reporting a generic sqlite failure. Used ONLY to build the * default loaders: a caller that supplies its own `loaders` owns the * naming inside them, and this argument then has no effect. * @param loaders - Injection point for tests, which need to simulate a * runtime that lacks a driver without leaving the runtime they run on. * @returns The resolved driver for this runtime. * @throws RC5017 when running under Node and `better-sqlite3` is absent. * Whether that is fatal is the store factory's call, not this * function's: an unconfigured context falls back to memory with a * warning, while a context that named a store path fails to start, * because silently degrading a deployment that asked for durability is * worse than refusing to run. */ declare function resolveSqliteDriver(consumer: string, loaders?: SqliteDriverLoaders): Promise; /** * Driver loaders. Injectable so a store in another package, or a test * exercising the absent-peer arm, supplies its own resolution rather than * the runtime's. */ interface SqliteDriverLoaders { bun(): Promise; node(): Promise; } /** * Environment variable read for the resume-token signing secret when the * context does not configure one explicitly. */ declare const DEFERRAL_SECRET_ENV = "ROUTECRAFT_DEFERRAL_SECRET"; /** * A signed capability naming one deferral. * * The token proves that whoever holds it was handed it by this deployment. * It does NOT prove the holder may resume: authorizing the resuming * principal is the resume ingress route's job (`.resume({ authorize })`, * sender verification, a per-recipient link), which is where an * authenticated principal is available. Nor does the token enforce single use on its own; the store's * compare-and-swap does that, so a replayed token finds the deferral * already resumed and gets the cached continuation result instead of a second * execution. */ interface ResumeTokenPayload { /** Deferral this token resumes. */ readonly id: string; /** Mint time, epoch milliseconds. Carried for audit, not enforced. */ readonly iat: number; /** * The call this credential belongs to, when the deferring step can raise * more than one at a time. * * A deferral id names a RECORD; on the agent surface a single record is * reached through whichever tool call won a parallel batch, and every * handler in that batch is handed a credential before the winner is * known. Binding the credential to its own call is what stops the loser's * recipient from resuming the winner's deferral: revive compares this claim * against the record's own `stepState.deferredToolCallId` and refuses a * mismatch before touching the record. * * Absent for a static `.defer()`, which has exactly one logical call. * Both mismatched arms fail closed: a token without this claim cannot * resume a record that has a bound call, and a token carrying it cannot * resume a record that has none. */ readonly sub?: string; } /** * Mints and verifies resume tokens for one context. * * HMAC-SHA256 over a compact JSON payload, encoded base64url so a token * survives a URL, an email body, and a chat message without escaping. */ declare class ResumeTokenSigner { #private; /** * How the secret was obtained. Surfaced so the context can log it once at * startup: an operator seeing `ephemeral` in production has a * misconfiguration, and that is worth being able to spot. */ readonly source: SigningSecretSource; constructor(secret: string | Buffer, source: SigningSecretSource); /** * Mint a token for a deferral id. * * Callable BEFORE the deferring step runs, which is what lets a * notification step earlier in the pipeline put a working resume link in * the message it sends. Nothing about the deferred exchange is needed to * sign: the deferral id is derivable from the exchange (see * {@link deferralIdFor}), and the token carries no claims of its own. * * @param id - The deferral id this token will resume. * @param now - Mint timestamp, injectable for deterministic tests. * @param sub - The call this credential belongs to, when the deferring * step can raise several at once. See {@link ResumeTokenPayload.sub}. */ mint(id: string, now?: Date, sub?: string): string; /** * Verify a token and return what it names. * * @param token - The token as presented by the resume ingress. * @returns The verified payload. * @throws RC5041 when the token is malformed, carries an unknown format * version, or fails signature verification. The three are deliberately * one error: telling a caller which of them failed tells an attacker * how far a forgery got. */ verify(token: string): ResumeTokenPayload; } /** Where a context's signing secret came from. */ type SigningSecretSource = "config" | "env" | "ephemeral"; /** * Whether a deferred exchange is still waiting or has settled. * * Two values, because the five that came before were answers to three * different questions. What the work is waiting FOR is * {@link Deferral.waitingFor}; how it settled is {@link Deferral.outcome}; * whether a delivery claim is outstanding is {@link Deferral.claimedAt}. A * reader asks one of those and reads the field that answers it, instead of * inferring all three from which of five values a record landed on. * * A deferral is resumable while it is `waiting` AND unclaimed. The claim is * a second axis rather than a third state: whoever holds it owns telling the * route, and a claim whose holder died is released by * {@link DeferralStore.releaseClaims} once its lease elapses, so the next * sweep redelivers. Every transition goes through the store's * compare-and-swap methods so exactly one caller wins a race between a * resume, a sweep, and a cancellation. */ type DeferralState = "waiting" | "settled"; /** * What a deferral is waiting for. * * One value today: `.defer()` waits for somebody to call `.resume()`. It is * a stored field rather than an implied constant so a management surface can * group by it, and so the kinds #737 and #738 add (asking a person, handing * work outside the process) widen this union instead of introducing a second * vocabulary beside it. * * Kept on a settled record too, because it says what the work WAS waiting * for. That is what makes "expired while waiting for a resume" readable off * one record rather than reconstructed from two. */ type DeferralWaitingFor = "resume"; /** * Keyset cursor for {@link DeferralStore.findExpired}. * * Pages advance strictly past `(expiresAt, id)`, so a record the caller * visited and could not retire is never re-read by the same pass, whatever * its state. That is what makes an arbitrarily long unretirable prefix * unable to starve the records behind it. */ interface ExpiredScanCursor { readonly expiresAt: Date; readonly id: string; } /** * Keyset cursor for {@link DeferralStore.list}. * * A deferral id is `{exchangeId}#{sequence}` and an exchange id is a uuid, * so id order says nothing about time. The listing is ordered by * `(deferredAt, id)` instead, which is the order an operator reads it in: * what has been waiting longest is what they are looking for. */ interface DeferralListCursor { readonly deferredAt: Date; readonly id: string; } /** * What {@link DeferralStore.list} is being asked for. * * `state` defaults to `waiting` at the caller rather than here, because a * store implementation must not have to know which half of the collection * a management surface considers interesting. */ interface DeferralListQuery { /** Only deferrals in this state. Both states when absent. */ readonly state?: DeferralState; /** Only deferrals belonging to this route. */ readonly routeId?: string; /** Bound on one page. A non-positive or non-integer value throws. */ readonly limit: number; /** Resume strictly past this position in `(deferredAt, id)` order. */ readonly after?: DeferralListCursor; } /** * One deferral as a management surface presents it. * * A projection rather than the record, and deliberately so: the record * carries the serialized exchange, the step state and the resume-payload * schema, none of which a listing may show and none of which it should * pull into memory a page at a time to then drop. What a reader needs is * what it is waiting for, how long it has waited, and whether anything * already holds it. */ interface DeferralSummary { readonly id: string; readonly routeId: string; readonly state: DeferralState; /** What the work is waiting for, or was waiting for when it settled. */ readonly waitingFor: DeferralWaitingFor; /** * Whether a delivery claim is outstanding, rather than when it was * taken. A reader asks whether anything already owns telling the route, * and the timestamp is an implementation detail of the lease. */ readonly claimed: boolean; readonly deferredAt: Date; /** When the sweeper will expire it. Absent means no deadline. */ readonly expiresAt?: Date; /** How it settled. Absent while it is waiting. */ readonly outcome?: DeferralOutcome; } /** * Project a stored record onto what a management surface may see. * * Named once and shared by both backends so a field can never reach the * listing from one store and not the other, and so the exchange has one * place it is dropped rather than one per backend. */ declare function summariseDeferral(deferral: Deferral): DeferralSummary; /** * The persisted form of a deferred exchange: exactly the two stored slots of * `Exchange` (`body` and `headers`), per * `.standards/exchange-state-model.md`. Derivations (`id`, `principal`, * `logger`) are rebuilt by constructing a `DefaultExchange` around these * two on the resuming process, so there is nothing else to persist. * * Both slots are plain JSON data. `serializeExchange` refuses anything else * with `RC5042` rather than writing a value the store cannot round-trip. */ interface SerializedExchange { readonly body: unknown; readonly headers: Readonly>; } /** * How the resume-payload schema is carried on a stored deferral. * * A Standard Schema is a live object with a validate function, so it cannot * be persisted. What is persisted is a reference: the `(routeId, position)` * pair on the record identifies the deferring step, whose live `schema` is * read back off the route at resume time, and `hash` is folded into * `continuationHash` so a schema that changed under a deferred exchange fails * the compatibility check rather than validating against the wrong * contract. * * `jsonSchema` is populated opportunistically from the non-standard * `~standard.jsonSchema` extension that Zod, ArkType and the AI SDK bridge * expose. It is descriptive only: it tells a caller and an operator what a * a valid resume payload looks like. Validation always runs against the live schema. * * A site that declares NO schema still gets a descriptor, carrying * {@link DeferralSchema.absent}. That sentinel is deliberately distinct * from the degraded fallback below: without it, editing a site from a * declared-but-unrenderable schema to no schema at all would leave the * digest unmoved, so a recipient told there was a contract would have their * payload accepted unvalidated with no re-ask. */ interface DeferralSchema { /** Stable hash of the resume-payload schema, or the absent sentinel. */ readonly hash: string; /** Set when the site declared no schema at all. */ readonly absent?: boolean; /** JSON Schema rendering when the schema exposes one. Never used to validate. */ readonly jsonSchema?: unknown; /** * Set when the schema advertised a `~standard.jsonSchema` extension that * then produced nothing, so {@link DeferralSchema.hash} identifies only * the vendor rather than the contract. * * The consequence is worth stating where it is read: the step tail is * still hashed, but a widened or narrowed schema of that kind cannot * move the digest, so a resume will not catch it. Zod throws for * unrepresentable types (a `Date`, a `bigint`, any `.transform()`), which * makes this reachable with an ordinary approval schema. */ readonly degraded?: boolean; } /** * Audit record of who resumed a deferral. * * A subset of `Principal` rather than the principal itself: the store is a * persistence surface, and a full principal carries claims, scopes and a * delegation chain that would be resurrected as data with no verification * behind it. What is recorded is enough to answer "who authorized this", * which is what the receipt is for. */ interface PrincipalRef { readonly subject: string; readonly issuer?: string; readonly clientId?: string; /** The outermost actor's subject when a delegate resumed on someone's behalf. */ readonly actorSubject?: string; } /** * How a deferral settled. * * Present exactly when {@link Deferral.state} is `settled` and absent * exactly while it is `waiting`. Nothing in the type system enforces that * pairing; the store's transitions do, because every one of them writes * `state` and `outcome` in the same compare-and-swap. */ interface DeferralOutcome { /** Which way it ended. */ readonly kind: "resumed" | "expired" | "denied"; /** * When the record left the waiting state. This is the retention clock: * {@link DeferralStore.purgeSettled} measures from here, so a record that * waits for months and settles today is kept for the full retention * window from today. */ readonly at: Date; /** Why a `denied` deferral was denied (cancellation, operator action). */ readonly reason?: string; /** Who resumed it. Only ever set on a `resumed` outcome. */ readonly by?: PrincipalRef; } /** * The cached result of execution two, written once the resumed exchange * settles. A duplicate resume returns this instead of running the * continuation a second time. */ interface SerializedOutcome { /** * How execution two ended. `deferred` is the two-stage-approval case: * the continuation reached ANOTHER `.defer()` and deferred again, so the * work is neither finished nor failed. It is recorded distinctly because * calling it `completed` would tell a receipt, a dashboard, and every * duplicate resume that the work finished while it is still waiting on * the next resume. */ readonly status: "completed" | "failed" | "dropped" | "deferred"; /** Terminal body on a completed run. Plain JSON data, same rule as {@link SerializedExchange}. */ readonly body?: unknown; /** Error code and message on a failed run. The stack is deliberately not persisted. */ readonly error?: { readonly rc?: string; readonly message: string; }; /** Drop reason on a dropped run. */ readonly reason?: string; readonly at: Date; } /** * A deferred exchange plus everything needed to revive it. * * The record is deliberately free of anything agent-shaped. A deferring * step that needs to carry closure state of its own puts it in * {@link Deferral.stepState}, which the store never interprets; that one * opaque slot is what lets the agent tier (#258) share this store instead * of growing a second one. */ interface Deferral { /** Deferral identity. Distinct from the deferred exchange's own id. */ readonly id: string; /** Route the deferred exchange belongs to. Resume re-enters this route. */ readonly routeId: string; /** Index of the deferring step. Execution two resumes at `position + 1`. */ readonly position: number; /** * Hash over steps `position + 1` to the end of the pipeline, plus the * resume-payload schema descriptor. * * {@link Deferral.meta} is deliberately NOT folded in. It lives only on * the record, so it has no live copy to drift from: a defer site that * snapshots its policy there is protected by where the value lives rather * than by a tamper check. * * Covers step DEFINITIONS only: what a `direct()` route the tail forwards * to actually does, which version an adapter is on, and how an external * system behaves can all change without moving this hash. See * {@link continuationHash}. */ readonly continuationHash: string; readonly exchange: SerializedExchange; readonly schema: DeferralSchema; /** * Whatever the deferring step attached at deferral, persisted verbatim and * handed back to the resume route's `authorize` hook. * * The framework never reads it. Who may resume a deferred run is the * application's policy, and this is where the deferral carries whatever that * policy needs: a channel name, the roles the defer site required, an amount, * a snapshot of the policy in force at deferral time. Subject to the same * plain-JSON rule as the exchange (`RC5042`). * * On the agent surface it is supplied by a tool handler, which means the * MODEL influenced it. Treat it as data the defer site chose, not as a fact * the framework vouches for. */ readonly meta?: unknown; /** * Which call this record belongs to, when the deferring step mints one * credential per call. * * A batch of parallel tool calls shares one record (one deferral) while each * handler sends its own recipient a link. Only the call that actually won * the deferral may be resumed, so its identity is recorded here and every * credential carries the same value as its `sub` claim. A losing sibling's * recipient then takes `RC5055` rather than resuming a deferral that was never * theirs. * * Absent for an ordinary `.defer()`, where there is nothing to * disambiguate. */ readonly callBinding?: string; /** * Opaque state owned by the deferring step. Absent for an ordinary step; * an agent step puts its messages thread and outstanding tool-call id * here. The store persists it verbatim and never reads into it, so it is * subject to the same JSON-data rule as the exchange. */ readonly stepState?: unknown; /** * Binds an approval to the operation it authorized rather than to a * deferral id, so a receipt reads "this principal authorized this exact * operation". See {@link actionFingerprint}. */ readonly actionFingerprint: string; readonly deferredAt: Date; /** When the sweeper will expire this deferral. Absent means no TTL. */ readonly expiresAt?: Date; /** * When the outstanding delivery claim was taken, which is BEFORE the * notification: it is a claim timestamp, not proof of delivery. Set only * on a `waiting` record, cleared when a stale claim is released, and kept * on a settled one as the record of who got there first. * * Load-bearing rather than informational: a claimed record is not * resumable, because the claim holder owns the outcome. That is the * compare every transition out of `waiting` makes. */ readonly claimedAt?: Date; readonly state: DeferralState; readonly waitingFor: DeferralWaitingFor; /** How it settled. Absent while {@link Deferral.state} is `waiting`. */ readonly outcome?: DeferralOutcome; /** Cached result of execution two, for idempotent re-resume. */ readonly continuation?: SerializedOutcome; } /** * A deferral as it is handed to {@link DeferralStore.create}. * * A record is born `waiting`, so the fields only a transition can produce * are not part of the input. Taking a full {@link Deferral} would let a * caller insert a record that is already settled, and every compare-and-swap * in the contract then refuses to move it: the exchange is deferred * permanently, with no error to notice. */ type NewDeferral = Omit & { readonly [K in DeferralTransitionField]?: never; }; /** * The fields only a `mark*` transition may write. */ type DeferralTransitionField = "state" | "outcome" | "continuation" | "claimedAt"; /** * Details recorded when a resume wins the compare-and-swap. */ interface DeferralResumption { readonly at: Date; readonly by?: PrincipalRef; } /** * Result of a compare-and-swap on a deferral. * * `won` is the load-bearing field: it reports whether THIS caller performed * the transition. A resume arriving at the same moment the sweeper expires * the deferral produces exactly one `won: true`, and the loser reads * `deferral.outcome` to find out what happened instead. * * `deferral` is the record as it stands after the attempt, so a loser * does not need a second read to react. It is `undefined` only when the id * is unknown. */ interface DeferralCasResult { readonly won: boolean; readonly deferral: Deferral | undefined; } /** * What the startup scan reports at info level. */ interface PendingDeferralSummary { /** Deferrals still waiting. */ readonly count: number; /** `deferredAt` of the oldest of them, absent when there are none. */ readonly oldest?: Date; } /** * Whether a deferral can still be resumed: waiting, with no delivery claim * outstanding. * * The two-field condition every transition out of waiting compares against, * named once so the stores, the revival path and the sweeper cannot drift on * it. A claimed record is deliberately excluded even though it is still * waiting: the claim holder owns the outcome, and letting a resume in behind * it would produce two notifications for one event. */ declare function resumable(deferral: Deferral): boolean; /** * Whether a delivery claim is outstanding on a waiting deferral, which is * what {@link DeferralStore.markExpired} and * {@link DeferralStore.markDenied} settle. */ declare function claimed(deferral: Deferral): deferral is Deferral & { readonly claimedAt: Date; }; /** * Persistence contract for deferred exchanges. * * Two backends ship: {@link MemoryDeferralStore} for tests and ephemeral * use, and {@link SqliteDeferralStore} as the durable default wherever * the runtime allows. Postgres and redis are out of scope; the contract is * shaped so they can be added without touching call sites, which is why * every method is async and why the state transitions are compare-and-swap * rather than read-then-write. * * Implementations must treat the three `mark*` methods as atomic with * respect to each other. Single-node coordination is all that is required * today, but a read-then-write implementation cannot be made safe later * without a rewrite, and the atomic form costs nothing now. */ interface DeferralStore { /** * Persist a newly deferred exchange. Throws if `record.id` already exists: * a deferral id is minted per defer and a collision means a bug, not * a retry. The stored record is `waiting` and unclaimed; only the * transitions below move it. */ create(record: NewDeferral): Promise; /** Load a deferral by id. `undefined` when the id is unknown. */ get(id: string): Promise; /** * Settle an unclaimed waiting deferral as `resumed`, recording who resumed * it and when. Exactly one concurrent caller wins. */ markResumed(id: string, resumption: DeferralResumption): Promise; /** * Claim an unclaimed waiting deferral for delivery, recording when the * claim was taken. The record stays `waiting`, which is the point: a claim * is not an outcome. Winning it is the right to notify the route, and the * caller delivers the re-ask and then settles with * {@link DeferralStore.markExpired} or {@link DeferralStore.markDenied}. A * holder that dies mid-delivery is healed by * {@link DeferralStore.releaseClaims} rather than leaving the record * stuck. * * A released EXPIRY claim is overdue, so the next sweep redelivers it. A * released DENIAL claim is not, so its redelivery waits for the next * replay of the token, or for the deadline, whichever comes first; the * proactive nag is lost only for a record that has no deadline and is * never replayed, which is the pre-lease behaviour for every crash. */ claimExpiry(id: string, at: Date): Promise; /** * Settle a claimed deferral as `expired`, stamping * {@link DeferralOutcome.at} at the write. `expiresAt` still says when the * deferral came due and `claimedAt` when its delivery was claimed; the * outcome's own timestamp is when the record actually stopped waiting, * which is what retention measures from. */ markExpired(id: string): Promise; /** * Settle a claimed deferral as `denied`, stamping * {@link DeferralOutcome.at} at the write. * The claim-first shape applies to denial for the same reason as expiry: * a crash between the transition and the notification must heal by * redelivery, not strand the approver. Cancellation (#552) will claim * first too. */ markDenied(id: string, reason?: string): Promise; /** * Release every delivery claim taken at or before `before` by clearing * {@link Deferral.claimedAt}, and report how many were released. The * records were and remain `waiting`; what changes is that they are * resumable and sweepable again. * * This is the healing half of the claim: a released record is past its * deadline, so the next ordinary sweep pass redelivers it. A crash after * delivery but before finalize therefore costs one duplicate escalation * after the lease elapses, which is the accepted at-least-once trade; * a crash before delivery costs nothing but the lease's delay. */ releaseClaims(before: Date): Promise; /** * Compare-and-swap the opaque {@link Deferral.stepState} slot of a * record that is STILL waiting and unclaimed, leaving every other field * alone. * * The one write that edits a waiting record in place rather than settling * it. Compaction is the motivating caller: an agent's deferred thread grows * past what the model will accept, and shrinking it has to happen while * the exchange stays deferred, because a resume that lands on an * unshrinkable thread has nowhere to go. * * Two races are closed by the same compare. `expected` is the * `stepStateFingerprint` of the state the caller read and rewrote, so two * compactions of the same record produce one winner and one `won: false` * holding the state that landed, instead of the second silently * discarding the first. And the swap only matches an unclaimed waiting * row, so a resume or a sweep that got there first wins outright: the compaction is * refused rather than rewriting the thread of a run that is already * executing its continuation. * * The store still never reads INTO the slot. Whether the replacement is a * usable thread is the owning tier's question, and `@routecraft/ai` * answers it before calling this. * * @param id - Deferral whose step state is being replaced * @param expected - Fingerprint of the step state the caller based its * replacement on, from `stepStateFingerprint` * @param stepState - The replacement. Subject to the same plain-JSON rule * as every other free-form slot (`RC5042`). `undefined` clears the slot * on every backend. */ replaceStepState(id: string, expected: string, stepState: unknown): Promise; /** * Cache the result of execution two so a duplicate resume can reply * without re-running the continuation. Silently ignores an unknown id: the * cache is a convenience, and losing the race to a sweep must not turn * into a second failure on the way out. * * Unconditional by design: only the caller that won `markResumed` ever * writes a continuation, so there is no competing writer for this row and * a compare would defend nothing. */ recordContinuation(id: string, continuation: SerializedOutcome): Promise; /** * Deferrals still waiting and unclaimed whose `expiresAt` is at or * before `now`, ordered by `(expiresAt ASC, id ASC)` and starting * strictly after `after` when one is given. `limit` bounds one page so a * backlog accumulated while the process was down does not produce an * unbounded batch. * * The ordering is a strict total order within a backend, which is what * makes the cursor sound; it is not guaranteed byte-identical across * backends for non-ASCII ids, so a cursor must only ever be replayed * against the store that produced it. * * A non-positive or non-integer `limit`, or a malformed `after`, is a * caller error and throws rather than being interpreted. */ findExpired(now: Date, limit: number, after?: ExpiredScanCursor): Promise; /** Count and oldest `deferredAt` across deferrals still waiting. */ pending(): Promise; /** * One page of deferrals as a management surface presents them, ordered * `(deferredAt ASC, id ASC)` and starting strictly after * {@link DeferralListQuery.after} when one is given. * * Ordered by time rather than by id because a deferral id is * `{exchangeId}#{sequence}` over a uuid, so id order is arbitrary and a * listing in it answers no question anyone asks. Oldest first is the * question: what has been waiting longest. * * A projection rather than the records, so the serialized exchange, the * step state and the resume-payload schema never enter the read path at * all. The listing must not show them, and a store that returned whole * records would page a hundred thousand exchange bodies through memory * on the way to dropping them. * * The ordering is a strict total order within a backend, and as with * {@link DeferralStore.findExpired} it is not guaranteed byte-identical * across backends for non-ASCII ids, so a cursor is only ever replayed * against the store that produced it. * * A non-positive or non-integer `limit` is a caller error and throws * rather than being interpreted. */ list(query: DeferralListQuery): Promise; /** * Records settled as `resumed` whose continuation never landed, oldest * first. * * This is crash residue. A resume wins the compare-and-swap out of * `waiting` BEFORE running the continuation and caches the result after, * so a record in this state means the process died between the two. * The approval is spent, the work half ran, and nothing will ever revisit * it: it is invisible to {@link DeferralStore.findExpired}, which only * looks at waiting records. * * Re-running a * continuation whose side effects may have half happened needs a lease on * the resumed outcome and idempotent continuations, which is the * admission-and-idempotency work tracked separately. What this method is * for is the boot summary after an outage, which is the first moment * anyone could learn such a record exists. * * The asymmetry with the delivery claim is deliberate: expiry * NOTIFICATIONS heal by redelivery because re-sending a nag is safe, * while this residue is a half-run CONTINUATION and is only ever * reported. * * @param limit - Cap on records returned. Omit for all of them. */ resumedWithoutContinuation(limit?: number): Promise; /** * Delete settled deferrals whose {@link DeferralOutcome.at} is before * `before`, and report how many went. * * A settled record holds a full serialized exchange body plus its cached * continuation, and nothing else ever removes one. Without a * retention path a long-running process accumulates every exchange that * ever deferred: on disk under sqlite, and on the heap under the * in-memory backend, which is also the automatic fallback for a Node * install without a driver. Waiting records are never touched, whatever * their age; expiring them is the sweeper's job and goes through * {@link DeferralStore.markExpired}. * * The cutoff is the outcome's timestamp, not `deferredAt`: retention * promises how long a SETTLED record is kept, and measuring from the * deferral would purge a record that waited 89 days and settled on day 89 * one day after it settled. */ purgeSettled(before: Date): Promise; /** Release backend resources. Idempotent. */ close(): Promise; } /** * Environment variable naming where deferred exchanges are persisted. Either * a file path or the literal `memory`. Overridden by an explicit * `deferral: { store }`. */ declare const DEFERRAL_STORE_ENV = "ROUTECRAFT_DEFERRAL_STORE"; declare module "@routecraft/routecraft" { interface CraftConfig { /** * Durable defer and resume. Set it when any route can reach a * `.defer()`; leaving it unset is fine for a context that never * defers. */ deferral?: DeferralConfig; } } /** * Where deferred exchanges live. * * - A path (or `{ path }`) opens the sqlite backend at that location. This * is the value a container deployment sets to a mounted volume, which is * the whole point of it being configurable. * - `"memory"` opts into the in-process backend explicitly, accepting that * deferred exchanges die with the process. * - A {@link DeferralStore} instance plugs in a backend of your own * (postgres, redis) without waiting for core to ship one. */ type DeferralStoreConfig = string | { path: string; } | "memory" | DeferralStore; /** * Deferral configuration on `defineConfig`. */ interface DeferralConfig { /** * Where deferred exchanges are persisted. Defaults to the sqlite backend at * {@link DEFAULT_DEFERRAL_DB_PATH}, or to whatever * {@link DEFERRAL_STORE_ENV} names. */ store?: DeferralStoreConfig; /** * HMAC secret for signing resume tokens. * * Prefer the {@link DEFERRAL_SECRET_ENV} environment variable: a secret * in a config file is a secret in version control. This field exists for * deployments that pull secrets from a manager at boot and hand them to * `defineConfig` in code. */ secret?: string; /** * How long a deferral stays resumable when `.defer()` names no `ttl`. * * Defaults to {@link DEFAULT_DEFERRAL_TTL}. Set `"never"` to opt a * context out of default expiry entirely, which means an unresumed * deferral is kept until something else retires it. */ defaultTtl?: Duration | "never"; /** * How often the sweeper looks for overdue deferrals. * * Defaults to {@link DEFAULT_SWEEP_INTERVAL}. TTLs are measured in hours, * so sub-minute precision buys nothing; the knob exists for tests and for * deployments that want expiry noticed sooner. */ sweepInterval?: Duration; /** * How long an expiry-delivery claim is honoured before the sweeper * releases it for redelivery. * * Defaults to {@link DEFAULT_EXPIRY_LEASE}. Only a crash mid-delivery * ever spends this; keep it comfortably longer than the slowest `.error()` * handler, because a lease shorter than a slow handler would make one * healthy process double-deliver by itself. */ expiryLease?: Duration; /** * How long settled deferrals (resumed, expired, denied) are kept before * the sweeper purges them. * * Defaults to {@link DEFAULT_DEFERRAL_RETENTION}. Set `"never"` to keep * everything, which is the audit-trail configuration; the store then * grows with every exchange that ever deferred. */ retention?: Duration | "never"; } /** * The resolved per-context deferral runtime: one store, one signer. */ interface DeferralRuntime { readonly store: DeferralStore; readonly signer: ResumeTokenSigner; /** * What the store resolved to, for the startup log line. `custom` is a * store the caller supplied; reporting it as `sqlite` would mislead * exactly the operators who configured a backend deliberately, on the one * field that answers "is this deployment durable, and against what". */ readonly backend: "sqlite" | "memory" | "custom"; /** * False when the caller supplied the store, in which case they own its * lifecycle and the plugin must not close it on teardown. A user-supplied * backend typically wraps a pool shared with the rest of the application, * or is reused across two contexts in one process (which is how a * restart-durability test is written). */ readonly ownsStore: boolean; /** * Milliseconds a deferral stays resumable when `.defer()` names no * `ttl`. Undefined when the context opted out with `defaultTtl: "never"`, * which is the only way to defer something with no deadline at all. */ readonly defaultTtlMs?: number; /** * Milliseconds between sweeps. Resolved here rather than in the plugin's * `start()` hook so a malformed duration fails while the context is still * being built, which is the rule the rest of this config already follows. */ readonly sweepIntervalMs: number; /** Milliseconds an expiry-delivery claim is honoured before redelivery. */ readonly expiryLeaseMs: number; /** Milliseconds settled records are kept. Undefined means keep forever. */ readonly retentionMs?: number; } /** * Plugin form of {@link createDeferralRuntime}, wired to the `deferral` * config key. Resolves the runtime during `initPlugins()` so a missing * signing secret fails at startup, and closes the store during teardown, * but only a store it opened itself. */ declare function deferralPlugin(config?: DeferralConfig): CraftPlugin; /** * Context-store key holding the resolved deferral runtime. * * Exported so the executor, the resume path, and the sweeper reach one * resolved store and one signer per context rather than each building their * own. * * It lives in this leaf module rather than next to * {@link createDeferralRuntime} because `exchange.ts` reads the key to * build the `ex.deferral` affordance, and importing `config.ts` from * there would pull the store backends (and, through them, the context) into * a runtime cycle rooted at the exchange. The type-only import above is * erased, so this module has no runtime dependencies at all. */ declare const DEFERRAL_RUNTIME: "routecraft.deferral.runtime"; declare module "@routecraft/routecraft" { interface StoreRegistry { [DEFERRAL_RUNTIME]: DeferralRuntime; } } /** * Store key for runner-provided argv tokens. * * Set by `craft run` (or any runner) before `context.start()` so that * adapters can read the remaining CLI arguments without coupling to a * specific runner package. */ declare const RUNNER_ARGV: unique symbol; /** * Base store registry that can be extended by adapters * * @example * ```typescript * // Extend the store registry with channel adapter types * declare module "@routecraft/routecraft" { * interface StoreRegistry { * "routecraft.adapter.channel.store": Map; * "routecraft.adapter.channel.config": Partial; * } * } * ``` */ interface StoreRegistry { [key: `${string}.${string}.${string}`]: unknown; [RUNNER_ARGV]: string[]; [RC_ADAPTER_OVERRIDES]: AdapterOverride[]; } /** * Options with merged configuration support. * This type is used for adapters that support both direct options and * options that can be merged with context configuration. */ type MergedOptions = { /** Direct options for configuration */ options: Partial; /** * Function to merge options with context configuration * @param context The CraftContext instance * @returns Merged options */ mergedOptions(context: CraftContext): T; }; /** * A plugin configures the context and may own work for as long as it runs. * * Three phases, each answering a different question: * * - `apply(ctx)` wires the context, before routes are registered * (`initPlugins()`). Nothing is running yet. * - `start(ctx)` begins work, after every route has started. Optional. * - `teardown(ctx)` releases whatever the other two acquired, when the * context stops and routes have drained. Optional. * * The split matters for anything with a lifetime. A plugin that opens a * handle does it in `apply()`; a plugin that starts a timer or drives routes * does it in `start()`, because at `apply()` time there are no routes to * drive. Either way `teardown()` is what releases it. * * Plugins needing neither can omit both; use `ctx.registerTeardown()` from * `apply()` for one-off cleanup callbacks. */ interface CraftPlugin { /** * Stable identifier for the plugin, surfaced as `pluginId` on * `plugin:*` event payloads and in logs. Falls back to the plugin's * constructor name (or `plugin-`) when omitted. */ name?: string; /** * RESERVED. Names of plugins this plugin depends on. Not enforced yet: * declaring it today has no effect, but the key is reserved so a * future dependency-ordered initialisation can use it without a * breaking change. */ dependsOn?: string[]; /** Keep the context running after all routes complete until `stop()` is called. */ keepsAlive?: boolean; apply(ctx: CraftContext): void | Promise; /** * Called once per context start, after every route has been started, in * plugin registration order. Awaited. Optional. * * This is where a plugin owning a background task belongs, because * `apply()` runs at build time when no route is running yet. A plugin * that starts something here MUST stop it in {@link CraftPlugin.teardown}: * a live interval keeps the process alive with no visible cause. * * A throw fails `context.start()`. The context then shuts down (routes * aborted and drained, every plugin torn down in reverse order) and the * original error is rethrown unchanged, so a plugin that fails to start * cannot leave a half-running context behind. */ start?(ctx: CraftContext): void | Promise; /** * Called when the context stops, after routes have drained, and when a * build or a start failed partway. Optional. * * The second argument says which of those happened, so a plugin never has * to infer it from its own state. Ignore it and the hook behaves exactly * as it did when teardown only ran on a fully started context; read it * when releasing depends on how far the context got. */ teardown?(ctx: CraftContext, info: TeardownInfo): void | Promise; } /** * What the context managed to do before this teardown, handed to every * {@link CraftPlugin.teardown}. * * The distinction exists because teardown now runs on three different * shapes of context: one that started and is stopping, one whose build * failed with only some plugins applied, and one whose start failed with * only some plugins started. A plugin that closes what `apply()` opened * needs none of this; a plugin that stops what `start()` began must not be * told to stop something it never began. */ interface TeardownInfo { /** * This is not a fully started context. Either it never started (a build * that failed partway, or an embedder that built and then stopped without * ever calling `start()`) or a `start()` hook threw. Routes may not be * registered and later plugins may never have applied, so state a plugin * would expect a running context to hold may be missing. */ partial: boolean; /** * THIS plugin's own `start()` hook ran to completion. Always false for a * plugin with no `start()` hook, and false during a build-failure unwind, * where nothing started. */ started: boolean; } /** * Configuration options for creating a CraftContext. * * Declared as an `interface` so ecosystem packages can extend it via * declaration merging. Pair an augmentation with `registerConfigApplier` * to promote an ecosystem capability to a first-class config key. * * @example * ```typescript * declare module "@routecraft/routecraft" { * interface CraftConfig { * myCapability?: MyCapabilityOptions; * } * } * ``` */ interface CraftConfig { /** * Service / application name for this context. Emitted on every log line as * `service.name` (the OpenTelemetry semantic convention), so log aggregators * that map OTel resource attributes (e.g. BetterStack `resources.service.name`) * can identify the originating app. When omitted, no `service.name` field is * added to logs. */ name?: string; /** Initial values for the context store */ store?: Map; /** Event handlers to register on context creation */ on?: Partial | EventHandler[]>>; /** One-time event handlers to register on context creation (fire once, then auto-unsubscribe) */ once?: Partial | EventHandler[]>>; /** Plugins to run before routes are registered (call initPlugins() then registerRoutes) */ plugins?: CraftPlugin[]; /** How long a graceful shutdown may drain before it is forced. */ shutdown?: ShutdownConfig; } /** * Bounds on {@link CraftContext.stop}. * * Shutdown behaviour is a property of the app, not of one invocation, which * is why this is config rather than a CLI flag: the same bound applies to a * signal, to `craft start --once`, and to an embedder calling `stop()`. */ interface ShutdownConfig { /** * How long stage one may drain in-flight exchanges before stage two is * forced. Defaults to {@link DEFAULT_SHUTDOWN_TIMEOUT_MS}. * * Set it BELOW the platform's own kill timer (Kubernetes * `terminationGracePeriodSeconds` and friends) so the process's policy * decides the outcome rather than the platform's: past that timer the * platform sends SIGKILL and whatever stage two was meant to do is lost. */ timeout?: Duration; } /** * How long a graceful shutdown drains before it is forced. Thirty seconds * because it sits under the common platform defaults (Kubernetes ships 30s * itself, and operators who raise it raise this with it) while being long * enough that ordinary in-flight work finishes inside it. */ declare const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30000; /** * What a completed {@link CraftContext.stop} reports. * * Returned because it is an answer the caller acts on: `shutdownHandler` * maps it to an exit code, and `craft start --once` reports a forced stop as * a failure. The same pair also rides on `context:stopped`, because counting * forced shutdowns is observability and an exit code is not something an * in-process subscriber can read. */ interface ShutdownOutcome { /** * Stage one did not finish inside the deadline, so in-flight execution was * abandoned. The process should exit non-zero. */ forced: boolean; /** * Route ids that still had work in flight when the deadline hit. Empty on * a clean stop, so the shape does not change between the two arms. */ pending: string[]; } /** * The main context for running and managing routes. * * CraftContext is the central runtime environment that: * - Manages the lifecycle of routes * - Provides a storage system for adapters * - Handles startup and shutdown of the application * * @example * ```typescript * // Create a context with routes and event handlers * const context = new CraftContext({ * on: { * 'context:starting': async () => { * console.log('Starting application'); * }, * 'context:stopping': async () => { * console.log('Shutting down application'); * } * } * }); * * // Register routes * context.registerRoutes(myRoute1, myRoute2); * * // Start processing routes * await context.start(); * * // Later, stop all routes * await context.stop(); * ``` */ declare class CraftContext { /** Unique identifier for this context instance */ readonly contextId: string; /** Service / application name, surfaced on logs as `service.name`. */ readonly name?: string; /** Routes registered with this context */ private routes; /** Abort controllers for each route */ private controllers; /** * Owns every route's `.enabled()` state, its refresh cadence, and the * transitions between them. Built eagerly (it is a few maps) so the * capability filter and ops can read it whether or not any route * declares a predicate. */ private readonly enablement; /** Storage for adapter configuration and state */ private store; /** Logger for this context (pino child logger) */ readonly logger: ReturnType; /** Event bus backing on/once/emit (see event-bus.ts) */ private readonly events; /** Plugins from config, run by initPlugins() before routes are registered */ private readonly plugins; /** Guards initPlugins() so start() can call it idempotently */ private pluginsInitialized; /** * Indices of plugins whose `apply()` returned. Teardown walks this rather * than the whole plugin list: a build that failed at plugin 3 must not * tear down plugin 4, which never ran. */ private readonly appliedPlugins; /** Indices of plugins whose `start()` hook returned. */ private readonly startedPlugins; /** Latched once `start()` has fully completed, for {@link TeardownInfo.partial}. */ private startCompleted; /** How long stage one of shutdown may drain; see {@link ShutdownConfig}. */ private readonly shutdownTimeoutMs; /** Teardown callbacks registered by plugins; run during stop() before context:stopped */ private readonly teardownCallbacks; /** Cached shutdown promise so concurrent stop() callers all await the same teardown */ private shutdownPromise; /** Backing gate for {@link CraftContext.whenStarted}. */ private startedGate; /** The in-flight start, so concurrent `start()` calls join one boot. */ private startInFlight; /** * The plugin lifecycle hook currently awaiting, so a `stop()` that lands * mid-boot can let it finish before teardown runs. * * Scoped to `apply()` and `start()` alone, never `run()`: for an * indefinite route `run()` resolves only once the context stops, so * waiting on it would deadlock the shutdown this ordering exists to serve. */ private pluginHookInFlight; /** Latched by `stop()`. A stopped context refuses to start again. */ private hasStopped; /** * Create a new CraftContext instance. * * @param config Optional configuration for the context */ constructor(config?: CraftConfig); /** * Generate a plugin identifier from the plugin's constructor name or index. * @param plugin The plugin instance * @param index The plugin's index in the plugins array * @returns A string identifier for the plugin */ private getPluginId; /** * Run plugins from config. Called by the builder's `build()` (and by * `start()` as an idempotent fallback) before routes are registered so * plugins can set up state or dynamically add routes. * * Fails fast: on first plugin error, logs, emits `error`, and rethrows. * * @throws Rethrows if any plugin's `apply(ctx)` throws * @internal Public for the builder and tests; not part of the supported * embedding surface. The context initialises plugins itself. */ initPlugins(): Promise; /** * Resolves once the context is fully started: every route has emitted * `route:started` AND every plugin `start()` hook has resolved. * * `context.start()` cannot answer this, because it does not resolve until * the routes complete, and an http or cron route completes at shutdown. * Waiting on `route:started` alone answers only two thirds of the * lifecycle, which is why anything needing a started context (a test * harness, a readiness probe) waits here instead. * * A hook that performs bounded startup work is awaited by this, on * purpose: the deferral sweeper's downtime scan has RUN by the time * this resolves, so overdue expiries reach their routes before new * traffic does. Rejects with the error that refused the start, which is a * plugin `start()` hook or the context's own config. A single route that * fails to come up is NOT observable here: `start()` keeps the remaining * routes running by design, so a probe that must know a specific route is * serving watches `route:started` for it. * * A context starts once (`start()` after `stop()` refuses with RC1004), * so this settles exactly once and keeps reporting that outcome. */ whenStarted(): Promise; /** * The gate behind {@link CraftContext.whenStarted}, created on first * use by either side. * * Lazy because most contexts never ask, and shared because `start()` has * to settle the same promise a caller may already be holding. * * @internal */ private ensureStartedGate; /** * Resolve once every route has signalled readiness, or stopped trying. * * `Promise.allSettled` over the route starts cannot answer this: each * mapped function runs only as far as its first `await`, so it returns * when every route has ENTERED `start()`, not when its sources are * listening. A plugin whose `start()` hook talks to a route would race * the source's subscribe, and a readiness probe would report a context * ready before its HTTP port was bound. * * A route that fails or completes counts as settled rather than ready. * One failing route does not fail the context (`start()` keeps the others * running by design), so readiness cannot report it either: what this * waits for is that no route is still coming up, not that all of them did. * * The wait is bounded because readiness is not something every source * signals. Everything `.from()` normalizes signals it (the iterable path * from its loop, a bare callable on invocation), but a hand-written * `Source` adapter that never calls `ready()` and never emits would * otherwise hold the whole context down forever. The bound is a guard * against that, not a deadline anything healthy approaches. * * @internal */ private awaitRoutesStarted; /** * Log one line saying whether the boot came up, after the readiness gate * settles. * * `whenStarted()` deliberately cannot report a single route failing to * bind, because partial availability is the design: one route failing * does not fail the context. That leaves an operator watching a boot with * no single answer to "did everything come up", visible only to whoever * subscribed to `route:started` at the right moment. This is that answer, * and nothing more: no new events, no semantics change. */ private logBootSummary; /** * Run every plugin's optional `start()`, in registration order. * * Separate from {@link CraftContext.initPlugins} because the two phases * answer different questions. `apply()` wires the context and runs at * build time; `start()` begins work and needs the routes running. The * deferral sweeper is the first consumer: it re-enters a route's error * channel when a deferred exchange expires, which is not something that can * be done against a route that has not started. * * A throw propagates to `context.start()`, which shuts the context down * before rethrowing it unchanged. Unwinding a partially started context * is deliberately the existing shutdown path rather than a second * mechanism, so a plugin's stop logic has one home; a failed build * unwinds through the same walk via {@link CraftContext.unwindFailedBuild}, * so which phase failed does not change where cleanup lives. */ private startPlugins; /** * Run one plugin lifecycle hook, publishing it as the in-flight hook for * as long as it runs. * * The published promise covers the hook and the bookkeeping that records * it, so a shutdown waiting on it sees a plugin either fully applied or * fully absent, never half-recorded. * * @param hook The hook call together with the state it updates on success */ private runLifecycleHook; /** * Wait for a plugin lifecycle hook still in flight before teardown runs. * * Waiting rather than interrupting, and unbounded, for the reason plugin * teardown is unbounded: a hook cut off partway keeps whatever it acquired * after its last await point, and no teardown can release what the plugin * had not yet handed over. Interrupting instead would oblige every plugin * author to write `start()` so it tolerates teardown-before-completion, a * contract paid for by every correct plugin to contain a defective one. * A hook that never settles is that defective plugin, not a shutdown * policy question. * * The hook's own rejection belongs to whoever invoked it, which is why * only its settling is observed here. */ private settlePluginHook; /** * Refuse to start when a route can reach a `.defer()` and nothing * configured where deferred exchanges go. * * Deliberately not auto-provisioned. The deferral runtime decides * whether this deployment survives a restart and whether resume tokens * outlive the process, and defaulting it silently would hand a route that * promises durability an in-memory store nobody chose. Failing at startup * costs one config line; failing on the first large payout costs the * payout. * * Routes that never touch deferral carry neither marker, so a context * without the feature pays nothing here. * * @throws RC5052 when a deferrable route has no deferral runtime */ private assertDeferralConfigured; /** * Register a teardown callback to run when the context stops. Plugins use this * to release resources (e.g. caches, native handles) after routes have drained. * Callbacks run in REVERSE registration order (LIFO, mirroring plugin * teardown) before `context:stopped` is emitted, so resources unwind in * the opposite order they were acquired. * * @param fn - Callback (sync or async) to run during stop() */ registerTeardown(fn: () => void | Promise): void; /** * Subscribe to lifecycle and system events. * * Takes an exact event name, or `"*"` for every event. Patterns such as * `route:*` are refused with RC2001: event names are a fixed set and the * route id lives in the payload, so scope to one route with * `forRoute(routeId, handler)`. The `event()` source still accepts patterns. * * @param event - Exact event name (e.g. `route:started`) or `"*"` * @param handler - Callback receiving `{ ts, contextId, details }` * @returns Unsubscribe function (call to remove the handler) * * @example * ```typescript * // Subscribe to specific event * const unsubscribe = ctx.on('route:started', ({ details }) => { * console.log('Route started:', details.route.definition.id); * }); * * // One route's failed exchanges * ctx.on('route:exchange:failed', forRoute('orders', ({ details }) => { * console.log('Order failed:', details.error); * })); * * // Every event * ctx.on('*', ({ details }) => { * console.log('Event:', details); * }); * * // later: unsubscribe(); * ``` */ on(event: K, handler: EventHandler): () => void; on(event: "*", handler: EventHandler): () => void; /** * Subscribe to an event for a single occurrence. The handler is automatically * removed after the first time the event is emitted. * * Accepts the same names as `on()`: an exact event name, or `"*"`. * * @param event - Exact event name or `"*"` * @param handler - Callback receiving `{ ts, contextId, details }` * @returns Unsubscribe function (call to remove the handler before it fires) * * @example * ```typescript * // Wait for context to start * ctx.once('context:started', () => { * console.log('Context started!'); * }); * * // Wait for the first route to start * ctx.once('route:started', ({ details }) => { * console.log('First route started:', details.routeId); * }); * ``` */ once(event: K, handler: EventHandler): () => void; once(event: "*", handler: EventHandler): () => void; /** * Emit an event to registered handlers. * * @param event - Event name * @param details - Event-specific payload (merged into `EventPayload.details`) * @internal Public for use by routes/adapters; prefer subscribing via on() */ emit(event: K, details: EventPayload["details"]): void; /** * Register routes with this context. * * @param definitions Route definitions to register * @throws {RoutecraftError} If there are duplicate route IDs or invalid route definitions * * @example * ```typescript * // Register a single route * context.registerRoutes(myRoute); * * // Register multiple routes * context.registerRoutes(route1, route2, route3); * ``` */ registerRoutes(...definitions: RouteDefinition[]): void; /** * Get all routes registered with this context. * * @returns A copy of the route list. Mutating the returned array does * not affect the context; routes are managed via `registerRoutes()`. */ getRoutes(): Route[]; /** * List the discoverable capabilities registered in this context: every * direct endpoint together with its route's discovery metadata. Agents * and embedding code use this instead of reaching into the (internal) * direct registry; dispatch into a capability with * `CraftClient.sendDirect(capability.endpoint, body)`. * * @returns A fresh array of capability snapshots. Endpoints are the raw * ids as passed to `.id(...)`; mutating the result does not affect * the registry. */ capabilities(): Capability[]; /** * Get a value from the context store. * * @template K Store key type * @param key The store key to retrieve * @returns The stored value or undefined if not found * * @example * ```typescript * // Get channel store * const channelStore = context.getStore('routecraft.adapter.channel.store'); * ``` */ getStore(key: K): StoreRegistry[K] | undefined; /** * Set a value in the context store. * * @template K Store key type * @param key The store key * @param value The value to store * * @example * ```typescript * // Set channel store * context.setStore('routecraft.adapter.channel.store', new Map()); * ``` */ setStore(key: K, value: StoreRegistry[K]): void; /** * Find a route by its ID. * * @param id The route ID to find * @returns The matching route or undefined if not found */ getRouteById(id: string): Route | undefined; /** * Start all routes registered with this context. * * Emits `context:starting` and `context:started`, then starts all routes in parallel. * If all routes complete (e.g. finite sources), the context automatically stops. * If any route fails to start, the error is logged, emitted as `error`, and rethrown. * * **Context Lifecycle Events:** * - `context:starting` - Context initialization begins * - `context:started` - Context initialized (routes may not be started yet) * - `context:stopping` - Context shutdown begins * - `context:stopped` - Context shutdown complete * * **Note:** `context:started` fires after context initialization but BEFORE * individual routes start. To track route readiness, subscribe to * `route:started` or `route:stopping` events instead. * To filter by specific route, inspect details.route.definition.id in the handler. * * @returns A promise that resolves when all routes have started (or when context stops) * @throws If any route fails to start * * @example * ```typescript * try { * await context.start(); * console.log('All routes started successfully'); * } catch (error) { * console.error('Failed to start routes:', error); * } * ``` */ start(): Promise; private run; /** * Wait for all in-flight route handlers (and their background tasks) to complete. * Does not stop sources; use stop() for full shutdown. * * @returns A promise that resolves when all routes have drained */ drain(): Promise; /** * Stop all routes and shut down the context. * * This will: * 1. Abort all route controllers (stops sources) * 2. Drain all routes (wait for in-flight handlers and their background tasks) * * @returns A promise that resolves when all shutdown operations complete * * @example * ```typescript * // Handle shutdown signals * process.on('SIGINT', async () => { * console.log('Shutting down...'); * await context.stop(); * process.exit(0); * }); * ``` */ stop(): Promise; /** * Tear down every plugin that applied, in reverse application order, then * the registered teardown callbacks. * * One walk serves all three exits: an ordinary shutdown, a start that * failed partway, and a build that failed partway. They differ only in * what {@link TeardownInfo} reports, which is why they are not three * mechanisms. * * Only APPLIED plugins are torn down. A build that failed at plugin 3 * leaves plugin 4 never having run, and calling its teardown would ask it * to release something it never acquired. * * Failure-tolerant throughout: a throwing teardown is logged and the * remaining teardowns still run, because the caller's original error is * what the operator needs and one plugin's cleanup must not strand * another's. * * @param partial - The context never finished starting. */ private teardownPlugins; /** * Release everything a failed `build()` acquired, then let the caller * rethrow the original error. * * `build()` never returns a context when it fails, so the caller has no * handle to run teardown against: whatever an `apply()` opened (a database * handle, a socket, an interval) is unreachable and stays open. Under a * supervisor that retries boot, one handle leaks per attempt, and with * SQLite the held handle also keeps the file locked, so a transient boot * failure becomes a permanent one whose error names lock contention rather * than the real cause. * * @internal Called by `ContextBuilder.build()` on the failure path. */ unwindFailedBuild(): Promise; private performShutdown; /** * Wait for ONE route to drain, bounded by the route's own grace or, when * it declared none, by the same `shutdown.timeout` the whole-context * shutdown uses. * * Enablement's disable transition is the caller: taking a route out of * service is a per-route shutdown, so it defaults to the same * operator-facing knob rather than a second timeout nobody knows to look * for. A route that declared `.enabled({ drainGrace })` overrides it, * because the author is the one who knows what this pipeline's in-flight * work costs. * * @param graceMs - Milliseconds to allow; `undefined` for the context * default; `null` to wait indefinitely and never force. * @returns True when the route drained; false when the deadline elapsed * first and the caller must force it. */ private drainRouteWithin; /** * Re-arm a disabled route under a fresh controller and start it. * * The controller is minted HERE rather than inside the route so this map * and the route never disagree about which controller a shutdown aborts: * a route holding one controller while `controllers` held the previous, * latched one would make `stop()` a silent no-op for that route. */ private restartRoute; /** * Re-evaluate enablement predicates on demand and apply any transitions. * * The internal control surface behind the operator loop the deferred * `/ops` re-evaluate endpoint will expose: set the secret, ask for a * re-check, and a route whose predicate now passes starts without a * process restart. The endpoint is wiring on top of this, not a redesign * of it. * * @param routeId - Re-evaluate only this route. Omitted re-evaluates * every route that declared a predicate. * @returns Each re-evaluated route's resulting state, by route id. */ reevaluateEnablement(routeId?: string): Promise>; /** * Whether a route is currently serving, as far as `.enabled()` is * concerned. True for a route that never declared a predicate. */ isRouteEnabled(routeId: string): boolean; /** * Every route currently disabled by its predicate, mapped to the reason. * * Read by the ops surface, which reports a disabled route distinctly from * a failed one: a deliberate configuration state is not an open circuit * and must not degrade the aggregate. */ disabledRoutes(): ReadonlyMap; /** * Resolve when `drain` settles, or reject with {@link SHUTDOWN_DEADLINE} * once the configured bound elapses. */ private raceShutdownDeadline; /** * Abandon in-flight execution on every route, and report what was still * running. * * The log line names route ids and their pending counts rather than a * total, because it is the only forensic record a forced shutdown leaves: * an operator reading it afterwards needs to know WHICH capability was * still working, not merely that something was. * * What this accepts losing: in-flight exchanges are abandoned mid-step and * emit no terminal event. What it does NOT do is settle or deny anything on * the way down, so a deferred exchange survives a forced shutdown exactly * as it survives a graceful one. * * It does NOT reach the deferral sweeper. The sweeper stops cooperatively * from `context:stopping`, which fires at the start of EVERY shutdown, and * plugin teardown then awaits its in-flight sweep unbounded, outside this * deadline. Records it had claimed heal via their lease either way. */ private forceStageTwo; } /** * Deferral state carried on the exchange. * * Per `.standards/exchange-state-model.md`, everything that must survive a * deferral lives in `headers`; `ex.deferral` is a derivation over these keys * plus the context's signer, in the same shape as `ex.principal` and * `ex.logger`. Nothing here is a second per-exchange bag. */ declare const DeferralHeaders: { /** * How many times this exchange has already deferred. Distinguishes * successive deferrals of one exchange (a route that defers, resumes, * and defers again for a second approval), which is what keeps their * deferral ids distinct. */ readonly SEQUENCE: "routecraft.deferral.sequence"; /** * Id of the exchange a snapshot was taken of, set when the framework * clones an exchange (`.tap()`, `.multicast()`). A clone gets a fresh * `routecraft.id` by design, so without this a token minted inside a * `.tap()` notification would name a deferral that never exists. The * canonical "notify before deferring" step is exactly such a tap, so the affordance follows this key back to the exchange that will * actually defers. */ readonly OWNER: "routecraft.deferral.owner"; /** The validated resume payload, written by the resume path before the continuation runs. */ readonly RESULT: "routecraft.deferral.result"; /** Who resumed it, recorded for audit. */ readonly RESUMED_BY: "routecraft.deferral.resumedBy"; /** When the resume was accepted. */ readonly RESUMED_AT: "routecraft.deferral.resumedAt"; }; declare module "@routecraft/routecraft" { interface RoutecraftHeaders { "routecraft.deferral.sequence"?: number; "routecraft.deferral.owner"?: string; "routecraft.deferral.result"?: unknown; "routecraft.deferral.resumedBy"?: PrincipalRef; "routecraft.deferral.resumedAt"?: Date; } } /** * The `ex.deferral` affordance. * * Readable at any point in a pipeline, which is the whole point: `id` and * `token` are derived from the exchange rather than minted by the defer * step, so a notification step that runs BEFORE the `.defer()` can put a * working resume link in the message it sends (n8n's `$execution.resumeUrl` * has the same property). * * `result`, `resumedBy` and `resumedAt` are the other half: they are absent * on execution one and populated by the resume path before the continuation * runs. `result` is typed `unknown` here and narrowed by the builder, which * threads the `schema` option's output type into every step after the * `.defer()`, the way `.input()` types the body. * * @template R - The resume payload type, threaded in by `.defer({ schema })` */ interface DeferralAffordance { /** Id of the deferral this exchange would defer as (or deferred as). */ readonly id: string; /** How many times this exchange has already deferred. Zero before the first defer. */ readonly sequence: number; /** * Signed, single-use resume token for {@link DeferralAffordance.id}. * * @throws RC5052 when the context has no deferral runtime configured. */ readonly token: string; /** * A resume token bound to one specific call on this record. * * Only a step that can raise several calls against one deferral needs this: * the agent tier's parallel tool batch is the shipped case, where every * handler sees the same deferral id (it names the deferral, not the call) * and each sends its own recipient a link. Binding the credential to the * call means the handler that then LOSES the deferral cannot have its * recipient resume the winner's deferral; they take `RC5055` instead. * * The binding is checked against what the record actually deferred with, so * minting one here without the deferral recording the same value refuses * every resume. Plain `.defer()` sites use {@link * DeferralAffordance.token}. * * @throws RC5052 when the context has no deferral runtime configured. */ readonly tokenFor: (callBinding: string) => string; /** The validated resume payload. Present only after a resume. */ readonly result: R; /** Who resumed it. Present only after a resume, and only when the ingress route had a principal. */ readonly resumedBy: PrincipalRef | undefined; /** When the resume was accepted. Present only after a resume. */ readonly resumedAt: Date | undefined; } /** * Types of operations that can be performed on an exchange. * These values are used in exchange headers to track the current operation. */ declare enum OperationType { /** The exchange was created from a source */ FROM = "from", /** * Synthetic step inserted by the runtime when a source adapter attaches a * `parse` function to the queued message. Builds a derived exchange via * `DefaultExchange.rewrap(exchange, { body: await parse(exchange.body) })` * before any user steps so parse failures flow through the route's normal * error handling instead of aborting the source. See #187. */ PARSE = "parse", /** The exchange was processed by a processor */ PROCESS = "process", /** The exchange was sent to a destination */ TO = "to", /** The exchange was split into multiple exchanges */ SPLIT = "split", /** The exchange was aggregated from multiple exchanges */ AGGREGATE = "aggregate", /** Modify the body of the exchange */ TRANSFORM = "transform", /** Tap an exchange without modifying it */ TAP = "tap", /** Filter an exchange based on a condition and reject the message if the condition is not met */ FILTER = "filter", /** Validate the exchange against a schema and reject the message if the schema is not met */ VALIDATE = "validate", /** Enrich the exchange with data from another exchange */ ENRICH = "enrich", /** Set or override a header on the exchange */ HEADER = "header", /** Conditionally route the exchange through one of several branches */ CHOICE = "choice", /** Fan the exchange out to multiple independent paths in parallel */ MULTICAST = "multicast", /** Run exactly one of several targets, chosen by load-balancing strategy */ DISPATCH = "dispatch", /** Rate limit an operation, pacing exchanges that exceed the rate */ THROTTLE = "throttle", /** Fast-fail an operation while a downstream is known to be failing */ CIRCUIT_BREAKER = "circuit-breaker", /** Bound how many exchanges run an operation simultaneously (bulkhead) */ CONCURRENCY = "concurrency", /** Pass every Nth exchange (or the first per time window), dropping the rest */ SAMPLE = "sample", /** Drop exchanges whose derived key has already been seen */ DEDUPE = "dedupe", /** Hold a burst of exchanges and release only the last after a quiet period */ DEBOUNCE = "debounce", /** Short-circuit the pipeline: drop the exchange without further steps */ HALT = "halt", /** Defer the exchange durably and exit the pipeline, to be resumed later at the next step */ DEFER = "defer", /** Revive a deferred exchange addressed by a signed resume token */ RESUME = "resume" } /** * Framework-owned header keys present on every exchange. * * The whole `routecraft.*` header namespace is RESERVED: the framework and * its adapters own every key under it. Adapter-specific keys live in * per-adapter key objects next to their adapters (e.g. `MailHeaders.UID`, * `CronHeaders.EXPRESSION`); each adapter merges its keys into * {@link RoutecraftHeaders} via declaration merging so they stay typed on * `exchange.headers`. User-defined headers must use their own namespace * (e.g. `x-...` or `myapp.*`). */ declare const HeadersKeys: { /** Unique identifier for this exchange. Stored in headers so it survives halt/continue alongside body. */ readonly ID: "routecraft.id"; /** The operation type (from, process, to) */ readonly OPERATION: "routecraft.operation"; /** The route id */ readonly ROUTE_ID: "routecraft.route"; /** The correlation id */ readonly CORRELATION_ID: "routecraft.correlation_id"; /** The hierarchy of split groups this exchange belongs to */ readonly SPLIT_HIERARCHY: "routecraft.split_hierarchy"; /** * Authenticated principal resolved from the request, when available. * Carries the structured `Principal` object; the `ex.principal` getter * is sugar over `ex.headers[HeadersKeys.AUTH_PRINCIPAL]`. */ readonly AUTH_PRINCIPAL: "routecraft.auth.principal"; }; /** * Typed map of well-known headers on an exchange. The core interface * declares only the framework-owned keys; adapters and ecosystem packages * merge their own `routecraft..*` keys in via declaration merging, * next to the adapter that owns them: * * ```ts * declare module "@routecraft/routecraft" { * interface RoutecraftHeaders { * "routecraft.myadapter.thing"?: string; * } * } * ``` * * The `routecraft.*` namespace is reserved for the framework and adapters; * see {@link HeadersKeys}. */ interface RoutecraftHeaders { /** * Unique identifier for this exchange. * * Stored in headers (not as a separate field) so it travels with the * exchange's serializable state. This is the single source of truth for * exchange identity; the `ex.id` getter reads from this key. */ "routecraft.id": string; /** The current operation being performed (OperationType or DSL label) */ "routecraft.operation": OperationType | string; /** The ID of the route processing this exchange */ "routecraft.route": string; /** Unique identifier for correlating related exchanges */ "routecraft.correlation_id": string; /** Hierarchy path for split operations */ "routecraft.split_hierarchy"?: readonly string[]; } /** * Allowed types for a single header value at the **bag** level. * * `unknown` lets cross-cutting concerns (auth principal, future tracing * spans, tenancy contexts) live in `headers` as a single typed slot rather * than being spread across many flat keys. Per-key types are narrowed by * {@link RoutecraftHeaders} (which adapters extend via declaration * merging); this bag-level type is just the catch-all for unregistered * keys. * * For API surfaces that accept a static header value (e.g. `.header("k", * v)`), prefer {@link HeaderLiteral} which excludes function types so user * lambdas get correct inference in the value-or-function overload. * * Array values declared on registered keys are typed `readonly string[]` so * they cannot be mutated via `push` / `splice` / index assignment through * the (already `Readonly<>`) `ExchangeHeaders` map. The contract is * type-level; the constructor freezes array values defensively so a caller * who casts away the readonly cannot mutate a shared reference. */ type HeaderValue = unknown; /** * Allowed types for a header value when supplied as a literal at an API * boundary that also accepts a callback (e.g. `.header("k", v)` / * `.header("k", ex => ...)`). Excludes function types so the callback arm * of the union infers parameters correctly. * * Includes the original primitive types for source-compat plus * `Readonly>` so structured values (Principal, * future Span, etc.) can be assigned directly when they aren't mediated by * a callback. */ type HeaderLiteral = string | number | boolean | null | undefined | readonly string[] | Readonly>; /** * Complete set of headers for an exchange. Read-only by contract: produce * derived headers via spread (`{ ...exchange.headers, key: value }`) and the * framework re-wraps the resulting exchange when the operation hands it back. * * Includes standard Routecraft headers, adapter-declared headers (merged * into {@link RoutecraftHeaders}), and custom headers. */ type ExchangeHeaders = Readonly & Record>; /** * Represents a message being processed through a route. * * An exchange has two kinds of state: * * 1. **Stored fields** (`body`, `headers`) carry the data and metadata that * must survive halt/continue. Persistence serializes exactly these two * slots; rehydration constructs a new instance around them. * 2. **Derived accessors** (`id`, `principal`, `logger`) read from * `headers` (or runtime services) and look like properties at the call * site. They are not stored separately. `id` reads * `headers["routecraft.id"]`; `principal` reads * `headers["routecraft.auth.principal"]`; `logger` builds a child logger * lazily from the framework's logger and the exchange's id. * * Cross-cutting concerns (auth, tracing, tenancy) all live as keys in * `headers` rather than as top-level fields. This keeps the serialization * surface small and avoids a "special field" precedent for every new * concern. * * Exchanges are immutable. Operations that change body or headers must * produce a new exchange (typically via spread) and return it; the * framework re-wraps the result via {@link DefaultExchange.rewrap} so it * preserves internal bindings (context, route). Body is not deep-frozen so * adapter authors can attach arbitrary user payloads, but the framework * will not mutate it and authors should treat it the same way. * * @template T The type of data in the body */ type Exchange = { /** * Unique identifier for this exchange. * * Reads from `headers["routecraft.id"]`. Stable across `rewrap`s so * pipeline steps see the same id throughout a route. */ readonly id: string; /** Headers containing metadata, including cross-cutting concerns like the authenticated principal. */ readonly headers: ExchangeHeaders; /** The data being processed */ readonly body: T; /** * Authenticated principal for this exchange, when one has been resolved. * * Sugar over `headers["routecraft.auth.principal"]`. Set automatically by * source adapters that perform authentication (e.g. the MCP server when * `auth:` is configured) by writing the principal into headers. Routes * may also assign a custom principal in `.process()` by spreading new * headers with this key. * * Propagates naturally because it lives in `headers`: any operation that * spreads `prev.headers` keeps the principal sticky-set automatically, * with no special-case plumbing. */ readonly principal?: Principal | undefined; /** * Logger for this exchange (pino child logger). * * Built lazily from the framework's base logger and the exchange's id / * route / correlation. Not stored as serializable state; rehydrated * exchanges build a fresh child logger on first access. */ readonly logger: ReturnType; /** * Durable-deferral view of this exchange: the id and signed token it * would defer as, and (after a resume) the payload that revived it. * * Sugar over the `routecraft.deferral.*` headers plus the context's * token signer, in the same shape as `principal` and `logger`. Readable * before the `.defer()` runs, which is what lets a notification step * earlier in the pipeline send a working resume link. * * `result` is `unknown` here; `.defer({ schema })` narrows it to the * schema's output type for every step after the defer. */ readonly deferral: DeferralAffordance; }; /** * Get the CraftContext for an exchange, if it has internals. * Reads from symbol-keyed storage first (cross-instance safe), then WeakMap. * * @param exchange The exchange * @returns The context or undefined * @internal */ declare function getExchangeContext(exchange: Exchange): CraftContext | undefined; /** * Get the route for an exchange, if it has internals. * Reads from symbol-keyed storage first (cross-instance safe), then WeakMap. * * @param exchange The exchange * @returns The route or undefined * @internal */ declare function getExchangeRoute(exchange: Exchange): Route | undefined; /** * The step state a resume attached for the re-entrant step about to re-run, * or `undefined` when this execution is not resuming that step. * * A defer-capable adapter (see {@link markDeferCapable}) calls this at * the top of its execution to decide between a fresh run and a resumed one. * Reading does NOT consume the state: the executor clears it when the step * settles, so a step-scope or route-scope `.retry()` re-running a failed * resume attempt still sees it, while a later defer-capable step in the * same continuation (or a fresh deferral by the same step) starts clean. */ declare function peekResumeStepState(exchange: Exchange): unknown; /** * Returns true if the route's `.output({ body })` validation accepted this * exchange's body against `schema` specifically. * * A request/reply source adapter that enforces the same schema at its own * protocol boundary reads this to avoid validating twice. The second run is * not merely redundant: validation replaces the body with the schema's * output value, so a schema that transforms (`z.string().transform(...)`, * `.pipe()`) rejects the very value it just produced. * * The schema is compared by identity, so an exchange validated against some * other contract does not count as validated against this one. * * @param exchange - The exchange to test, or any rewrap of it * @param schema - The schema the caller is about to enforce * @returns Whether the route already validated this body against that schema */ declare function wasOutputValidated(exchange: Exchange, schema: StandardSchemaV1): boolean; /** * Returns true if the exchange (or any rewrap of it sharing the same * internals) has been marked as dropped. * * A request/reply source adapter reads this to tell "the route declined * this request" apart from "the route produced this result": a dropped * exchange resolves with the body it came in with, so an adapter that does * not check hands the caller its own request back as if it were an answer. * `CraftClient.sendDirect` and the route-scope `forward` both raise RC5031 * on it; an adapter outside core translates it into whatever its protocol * uses to decline. * * @param exchange - The exchange to test, or any rewrap of it * @returns Whether the exchange was dropped rather than completed */ declare function isDropped(exchange: Exchange): boolean; /** * Internal options accepted by {@link DefaultExchange}'s constructor. * The constructor freezes whatever it produces; callers can supply either a * `Readonly<...>` headers object (e.g. from another exchange) or a plain * literal. * * `id` and `principal` are NOT options here: they live inside `headers` * (`headers["routecraft.id"]` and `headers["routecraft.auth.principal"]`). * Callers that want to control them set the corresponding header keys when * building `headers`. * * @internal */ type DefaultExchangeOptions = { body?: T; headers?: ExchangeHeaders; }; /** * Default implementation of the Exchange interface. * * The implementation stores exactly two fields, `body` and `headers`. * Everything else surfaced on the public `Exchange` API (`id`, * `principal`, `logger`) is exposed through getters that derive from * `headers` plus runtime services. This keeps the serialization surface * for halt/continue narrow (just `{ body, headers }`) and removes the * "special field" precedent for cross-cutting concerns. * * Instances are immutable: the constructor freezes the wrapper and headers * (and any non-primitive header values, defensively). Body is left * unfrozen so user payloads of any shape can flow through; the framework * does not mutate it. * * @template T The type of data in the body * * @example * ```typescript * // Create a simple exchange with a string body * const exchange = new DefaultExchange(context, { * body: "Hello, World!" * }); * * // Access exchange properties * console.log(exchange.id); // Unique UUID (read from headers["routecraft.id"]) * console.log(exchange.body); // "Hello, World!" * console.log(exchange.headers); // Headers object with standard fields * ``` */ declare class DefaultExchange implements Exchange { #private; /** Headers containing metadata, including the exchange id and (when set) the authenticated principal. */ readonly headers: ExchangeHeaders; /** The data being processed */ readonly body: T; /** * Create a new exchange. * * @param context The CraftContext this exchange belongs to * @param options Optional configuration for the exchange. Set the exchange * id by including `headers["routecraft.id"]`; set the principal by * including `headers["routecraft.auth.principal"]`. Both default to * sensible runtime values when omitted (id: `randomUUID()`). */ constructor(context: CraftContext, options?: DefaultExchangeOptions); /** * Unique identifier for this exchange. Reads from * `headers["routecraft.id"]` so the id travels with the serializable * state and survives halt/continue. */ get id(): string; /** * Authenticated principal, when one has been resolved. Sugar over * `headers["routecraft.auth.principal"]`. */ get principal(): Principal | undefined; /** * Pino child logger scoped to this exchange. Built lazily from the * framework's base logger; rebuilt on a rehydrated exchange because the * logger is not part of the serializable state. */ get logger(): ReturnType; /** * Durable-deferral view of this exchange. See {@link Exchange.deferral}. * * Built per access rather than cached: the view derives from `headers`, * which a resume rewrites, so a cached one would go stale exactly when it * matters. It is a small object of getters, and the expensive part * (minting a token) only runs if the caller reads `token`. */ get deferral(): DeferralAffordance; /** * Construct a new {@link DefaultExchange} that combines internals from a * previous exchange (context, route binding, parse hooks) with field * overrides from a partial. Used by the engine to normalise plain * objects user code returns from `.process()` (or any `with*`-style * spread) back into proper instances without losing the framework's * back-channel state. * * - `headers` defaults to `prev.headers`. Identity (`routecraft.id`) is * forced to `prev.id` so it survives a caller-supplied headers object * that came from a different exchange. Cross-cutting concerns * (`routecraft.auth.principal`, future tracing/tenancy keys) flow * through naturally because they live in the same headers bag. * - `body` defaults to `prev.body`. * * Identity-changing operations (split, aggregate restoring a parent) * construct a new `DefaultExchange` directly with fresh headers rather * than calling `rewrap`. * * @internal */ static rewrap(prev: Exchange, partial?: { readonly body?: T; readonly headers?: ExchangeHeaders; }): DefaultExchange; } /** * Compile a path pattern into a matcher closure. * * Supports literal segments and `:param` segments. Pattern and pathname are * tokenised on `/`; an empty leading token (from the leading `/`) and any * empty trailing token (from a trailing `/`) are dropped so `"/orders"`, * `"/orders/"`, and the pathname `"/orders/"` all behave the same way. * * URL-decoding happens once per matched segment so handlers receive the * decoded value (`"hello world"`, not `"hello%20world"`). * * URLPattern would do all of this, but it is not a global in Node 22. Doing * it by hand keeps the http source dependency-free; richer patterns * (constraints, wildcards) can move to URLPattern when the minimum Node * version catches up. */ interface PathMatcher { /** Try to match a concrete pathname. Returns the resolved params on success, null on miss. */ match(pathname: string): Readonly> | null; /** Original pattern, e.g. `/orders/:id`. */ readonly pattern: string; /** Names of `:param` segments in the pattern, in declaration order. */ readonly paramNames: readonly string[]; } declare const SCHEMES: readonly ["hmac-sha256-hex", "hmac-sha1-hex", "stripe-timestamped", "standard-webhooks"]; /** * Signature schemes supported by the built-in webhook verifier. * * - `"hmac-sha256-hex"`: hex-encoded HMAC-SHA256 of the raw body. GitHub's * `X-Hub-Signature-256` uses this with `prefix: "sha256="`. * - `"hmac-sha1-hex"`: hex-encoded HMAC-SHA1 of the raw body. Legacy GitHub * `X-Hub-Signature` uses this with `prefix: "sha1="`. Prefer sha256 when * the provider offers both. * - `"stripe-timestamped"`: Stripe's `Stripe-Signature` format * (`t=,v1=`). The signed payload is `.` and the * timestamp must be within `toleranceSec` of the server clock, which * bounds replay of captured deliveries. * - `"standard-webhooks"`: the [Standard Webhooks](https://www.standardwebhooks.com/) * format, sent by Resend, Bird and Svix among others. Reads the three * headers the specification fixes (`webhook-id`, `webhook-timestamp`, * `webhook-signature`), signs `..` with the * base64-decoded secret, and admits when any space-separated `v1,` entry * matches. Covers the symmetric half of the specification only: * asymmetric `v1a` (ed25519) entries are skipped like any other * non-`v1` version. */ type HttpWebhookSignatureScheme = (typeof SCHEMES)[number]; /** Fields every scheme takes. */ interface HttpWebhookSignatureOptionsBase { /** Shared secret the provider signs with. */ secret: string; /** * Literal prefix stripped from the header value before comparison, e.g. * `"sha256="` for GitHub. A header value that does not start with the * prefix is an invalid signature. Ignored by `"stripe-timestamped"` and * `"standard-webhooks"`, which have their own field formats. */ prefix?: string; /** * Maximum allowed clock skew, in seconds, between the signature's * embedded timestamp and the server clock. Used by * `"stripe-timestamped"` and `"standard-webhooks"`. Defaults to 300. */ toleranceSec?: number; } /** * The three schemes that read one configured header. `header` is required: * nothing in these formats fixes a header name, so there is no default that * would be right more often than it is wrong. */ interface HttpWebhookSignatureHeaderOptions extends HttpWebhookSignatureOptionsBase { /** Request header carrying the signature (e.g. `"x-hub-signature-256"`). Case-insensitive. */ header: string; /** Signature scheme. See {@link HttpWebhookSignatureScheme}. */ scheme: Exclude; } /** * Standard Webhooks. The specification fixes all three header names, so a * route configures a secret and nothing else. There is deliberately no * per-header override: the id and timestamp names are read from the * specification, so renaming only the signature header would build a route * that constructs cleanly and then rejects every live delivery. */ interface HttpStandardWebhooksSignatureOptions extends HttpWebhookSignatureOptionsBase { scheme: "standard-webhooks"; /** * Never set. Declared so the refusal survives a config object that is not * an object literal, where excess-property checking does not apply. */ header?: never; } /** * Declarative webhook-signature verification for `http({...})` sources. * When configured, the plugin verifies the raw request bytes against the * signature header and rejects 401 before any route step runs, so HMAC * comparison never has to be hand-rolled in a `.filter()`. * * For providers whose scheme is not covered here, opt in to * `http({ rawBody: true })` instead and verify in a route step against * `routecraft.http.rawBody`. */ type HttpWebhookSignatureOptions = HttpWebhookSignatureHeaderOptions | HttpStandardWebhooksSignatureOptions; /** * One entry in an http mount's route registry. Created when a * `from(http({...}))` source subscribes and removed on abort. The mount's * dispatcher matches incoming requests against this registry. * * The route's `handler` is the runtime-provided callback that turns a message * into a fully-processed {@link Exchange}. The source's job is just to call * it (and to convert the result back into the wire format). */ interface HttpRouteEntry { readonly routeId: string; readonly method: HttpMethod; /** Matcher over the FULL request path (mount prefix already joined in). */ readonly matcher: PathMatcher; /** * The route declares a route-entry `.authorize()`. On a walled mount this * is redundant (the wall already ran); on a public mount it forces the * dispatcher to pull credential verification for this route. */ readonly requiresPrincipal: boolean; /** Whether the route opted in to `routecraft.http.rawBody` on the exchange. */ readonly rawBody: boolean; /** Webhook-signature gate; requests failing verification 401 before the route runs. */ readonly signature: HttpWebhookSignatureOptions | undefined; /** * Decides when the caller is answered and with what. Undefined leaves the * dispatcher's own path, which awaits the pipeline and serialises it. */ readonly respond: HttpResponder | undefined; /** Route-level discovery bundle, used by /openapi.json. */ readonly discovery: RouteDiscovery | undefined; /** Provided by the source on subscribe; the dispatcher calls it once it has a parsed body. */ readonly handler: (body: unknown, headers: ExchangeHeaders) => Promise; } /** * Per-mount route registry. The http plugin instantiates one per configured * mount; source adapters resolve their mount by name and push/pop entries. */ type HttpRouteRegistry = Map; /** * What the http source needs to know about one configured mount: where its * routes register and which path prefix route patterns are joined onto. */ interface HttpMountRuntime { /** Mount path prefix; `"/"` for the catch-all. */ readonly path: string; readonly registry: HttpRouteRegistry; } /** * Symbol key sharing the mount table between the http plugin (which creates * and owns it) and the http source (which resolves a mount by name and * inserts / removes route entries). `Symbol.for` so the key is shared across * any duplicate package copies in a workspace (matches the convention used * by every other plugin in `.standards/adapter-architecture.md`). */ declare const HTTP_MOUNTS: unique symbol; /** * Symbol key used by the http source to assert the plugin has been * registered. Set to `true` in `httpPlugin.apply(ctx)`. The source throws * `RC5003` when it is missing so misconfiguration fails at subscribe time. */ declare const HTTP_PLUGIN_REGISTERED: unique symbol; declare module "@routecraft/routecraft" { interface StoreRegistry { [HTTP_MOUNTS]: ReadonlyMap; [HTTP_PLUGIN_REGISTERED]: boolean; } } /** * `info` block of an OpenAPI 3.1 document. Mirrors the OpenAPI Info Object * shape (`title`, `version`, optional `description`, `contact`, `license`). * * Defaults are auto-detected from the host project's `package.json` at * plugin apply time: * * - `title` -> `package.json` `name` (literal, including any `@scope/`). * - `version` -> `package.json` `version`. * * Description / contact / license are **not** auto-pulled even when * present in `package.json`: those fields commonly carry internal * context (TODO notes, author emails, license boilerplate) that should * not leak into a publicly served document by default. Set them * explicitly to publish them. * * Auto-detection deliberately yields nothing when the nearest * `package.json` is a workspace container (a `workspaces` field, or a * `pnpm-workspace.yaml` beside it): a monorepo root's private, often * stale identity must not be advertised on a publicly served document. * See `findPackageInfo` for the full resolution rules. * * Any explicit field passed by the caller wins over the auto-detected * default; no usable `package.json` falls back to the hardcoded * "Routecraft HTTP API" / "0.0.0". */ interface HttpOpenApiInfo { title?: string; version?: string; description?: string; contact?: { name?: string; url?: string; email?: string; }; license?: { name: string; identifier?: string; url?: string; }; } /** HTTP request methods supported by both the destination and the source. */ type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; /** Query string values accepted by the destination's `query` option. */ type QueryParams = Record; /** * What the client does when the server answers with a 3xx. Mirrors the * platform `RequestInit.redirect` values, and nothing more: the option * reports what happened and hands control back to the route. * * - `"follow"` (the default): the runtime follows the chain and the result * describes the final response. * - `"manual"`: the 3xx itself is returned, `Location` readable in * {@link HttpResult.headers}, so a route that validated the URL it asked * for can re-run that rule on the next hop instead of having the adapter * walk somewhere the route never approved. Such a 3xx does not trip * `throwOnHttpError`, because it is the outcome the route asked for; * every other non-2xx still does, `304` included, since a cache answer is * not a hop. * - `"error"`: the request fails rather than following. */ type HttpRedirectMode = "follow" | "manual" | "error"; /** * What the client accepts as a request body. * * Deliberately not `unknown`: a union containing `unknown` collapses to * `unknown`, which took the contextual parameter type off the callback form of * {@link HttpClientOptions.body} and made every call site annotate its own * exchange by hand. Anything a `fetch` body can be is here, plus the plain * values the client serialises to JSON for you. * * `object` is deliberately broad. Narrowing it to `Record` * would make a callback's return type checked, but it also refuses an * interface-typed body, which is the ordinary case: an `interface` has no * implicit index signature where an otherwise identical `type` alias does, so * the two spellings of the same shape would stop behaving alike. It refuses * arrays, class instances and Maps too, all of which the client serialises * correctly, and * `object & { call?: never }` refuses a plain object literal because the * intersection triggers excess-property checking. So the value arm admits * functions and a callback's return goes unchecked; the contextual parameter * type it exists for still works. * * `undefined` is a member rather than only the property being optional, * because the callback form has to be able to decide per exchange that there * is no body to send (`body: (ex) => ex.body.attachment ?? undefined`). The * client sends none for it, as it always has. */ type HttpRequestPayload = string | number | boolean | null | undefined | object | Uint8Array | ArrayBuffer | URLSearchParams | FormData; /** * How the client hands the response body to the route. * * - `"text"` (the default): decoded as UTF-8, then parsed as JSON when the * response says it is JSON. What every route got before this option existed. * - `"bytes"`: the body is a `Uint8Array` of exactly what arrived, with no * decoding and no JSON parsing. Required for anything that is not text, * because UTF-8 decoding is lossy in one direction: an invalid sequence * becomes U+FFFD and the original bytes cannot be recovered. * * Chosen explicitly rather than sniffed from the content type, which would * silently change the body type of an existing route that happens to receive * `application/octet-stream` and reads it as a string today. */ type HttpResponseBodyMode = "text" | "bytes"; interface HttpClientOptions { method?: HttpMethod; url: string | ((exchange: Exchange) => string); headers?: Record | ((exchange: Exchange) => Record); query?: QueryParams | ((exchange: Exchange) => QueryParams); body?: HttpRequestPayload | ((exchange: Exchange) => HttpRequestPayload); /** Abandon the request after this long. No deadline when unset. */ timeout?: Duration; throwOnHttpError?: boolean; /** * Maximum response body size in bytes. Defaults to 10 MB, the same name * and the same number as the http plugin's inbound cap, so one concept * means one thing on both sides of the framework. * * A declared `Content-Length` above the cap is refused before a byte is * read; otherwise the body streams and is abandoned the moment the count * crosses the ceiling, which bounds what an oversized response can cost * the process rather than only what it hands the route. Exceeding it * fails the exchange with `RC5061`: a truncated body would hand the * route half a document and let it parse as though it were whole. * * `Infinity` is the named way to opt out entirely. Zero and negatives are * refused rather than read as "no limit", so an options object built * programmatically cannot arrive at unbounded by accident. */ maxBodySize?: number; /** * What to do with a 3xx response. Defaults to `"follow"`, the platform * default and the behaviour of every existing route. See * {@link HttpRedirectMode}. */ redirect?: HttpRedirectMode; /** * How to hand the response body to the route. Defaults to `"text"`, which * is what every route did before this option existed. * * Use `"bytes"` for anything that is not text. The default decodes the * response as UTF-8, and that is lossy in one direction: a JPEG's leading * `ff d8 ff e0` is not valid UTF-8, so each byte becomes U+FFFD and * re-encodes as three bytes. The corruption is silent and asymmetric, * which is what makes it dangerous: an Ogg page header is ASCII, so a * voice note survives while an image beside it does not. * * `maxBodySize` and `timeout` apply unchanged; the cap counts bytes as * they arrive on both modes. * * @see {@link HttpResponseBodyMode} */ responseBody?: HttpResponseBodyMode; } type HttpResult = { status: number; headers: Record; body: T; url: string; }; /** * API-key auth options accepted by the http plugin. Mirrors the validator * shape used by `jwt()` / `jwks()` so the plugin's `auth` slot remains a * single uniform surface. * * `keys` is the static-allowlist shortcut: any caller presenting one of the * listed keys is admitted with a synthetic `Principal { kind: "custom", * scheme: "apiKey", subject: "" }`. Use `verify` instead * when a key needs to resolve to a per-user identity (database lookup). * * @experimental */ interface ApiKeyAuthOptions { /** Discriminator: this is API-key auth, not bearer. */ readonly kind: "apiKey"; /** Where to look for the key. Defaults to `"header"`. */ in?: "header" | "query"; /** * Header (or query) parameter name. Defaults to `"x-api-key"` for header * lookups and `"api_key"` for query lookups. */ name?: string; /** Static allowlist of accepted keys. Mutually exclusive with `verify`. */ keys?: readonly string[]; /** * Scopes granted to a caller admitted by the static `keys` allowlist. * * Every listed key carries the same scopes, because a flat allowlist has * no place to hang per-key authority; when different callers need * different scopes, `verify` returns a principal per key and this field * is not the tool. Without it a static key mints a principal carrying no * scopes at all, which any scope check refuses. * * Refused alongside `verify` with `RC5003` rather than ignored: a verifier * returns the principal itself, so the scopes belong on what it returns and * a second place to declare them could only disagree with it. */ scopes?: readonly string[]; /** * Custom verifier. Receives the raw key and returns a {@link Principal} * (admit) or `null` (reject). Throwing is also a rejection. */ verify?: (key: string) => Principal | null | Promise; } /** * Reserved future shape so adding OAuth 2.1 as a follow-up is non-breaking. * Today it serves only as a sentinel in the discriminated union. * * @internal */ interface OAuthAuthOptionsReserved { readonly kind: "oauth"; } /** * Auth strategy accepted at the plugin level. Three shapes: * * - {@link ValidatorAuthOptions} / {@link OAuthValidatorAuthOptions}: * anything that exposes a `validator(token) -> Principal` -- typically the * result of `jwt(...)` or `jwks(...)`. Activated by the presence of * `Authorization: Bearer `. * - {@link ApiKeyAuthOptions}: header or query API key. Discriminated by * `kind: "apiKey"`. * - {@link OAuthAuthOptionsReserved}: placeholder for the upcoming OAuth * 2.1 server flow (`kind: "oauth"`). Not implementable in v1. */ type HttpAuth = ValidatorAuthOptions | OAuthValidatorAuthOptions | ApiKeyAuthOptions | OAuthAuthOptionsReserved; /** Event toggles for the http plugin. */ interface HttpPluginEventOptions { /** * Emit `plugin:http:request:completed` after every response. Built-in * endpoints (`/health`, `/ready`, `/openapi.json`) never produce this * event regardless of the flag. Defaults to `true`. */ perRequest?: boolean; } /** * Configuration for `defineConfig({ http: {...} })`. Materialised into a * plugin via the registered config applier; users rarely import this type * directly. * * @experimental */ interface HttpPluginOptions { /** * Single-mount shorthand: named entry from `CraftConfig.servers` for the * implicit `default` mount at `"/"`. Defaults to `"default"`. Mutually * exclusive with `mounts`, where each mount names its own server; the top * level of this options object is the mount definition when there is only * one, exactly as mcp and ops options are theirs. */ server?: string; /** * Single-mount shorthand: auth for the implicit `default` mount at `"/"`. * Mutually exclusive with `mounts`. The mount is the wall: with a * validator in scope every route on it requires a valid credential; the * only route-level escalation is `.authorize()`. Omit (with a * server-level `servers..auth`) to inherit the server validator, * or pass `false` for a surface with no wall. */ auth?: HttpAuth | false; /** * Named path-scoped surfaces, each a complete self-description: path, * server, auth. Mutually exclusive with the top-level `server` and `auth` * shorthand. * * ```ts * http: { * mounts: { * api: { path: "/api" }, // servers.default, inherits its wall * admin: { path: "/admin", server: "internal" }, * default: { path: "/", auth: false }, // public catch-all * }, * } * ``` * * Routes select a mount with `http({ mount: "api", path: "/orders" })`; * an omitted `mount` resolves only to a mount literally named `default`. */ mounts?: Record; /** * Maximum request body size in bytes. Requests exceeding this cap return * 413 Payload Too Large. Defaults to 10 MB, the same number as the * `http()` client's response cap. * * Must be a positive integer. Unlike the client option of the same name, * `Infinity` is refused: an inbound request comes from a stranger and is * buffered whole before it can be measured, so an unbounded cap here * means one request can exhaust the process, which is what this option * exists to prevent. */ maxBodySize?: number; /** Event emission toggles. */ events?: HttpPluginEventOptions; /** * Built-in endpoint configuration. See {@link HttpBuiltinsOptions}. * Built-ins serve from the mount named `default` when its path is `"/"`; * with no such mount they are disabled. */ builtins?: HttpBuiltinsOptions; } /** * One path-scoped surface under {@link HttpPluginOptions.mounts}. Each mount * describes itself completely: which listener it sits on and which validator * applies. There is no plugin-level default to override. * * The mount decides authentication for every route on it: * - `auth` unset inherits the server validator (`servers..auth`) as * a wall when one is configured, else the mount is open. * - `auth` set is the mount's own wall, replacing the server's validator. * - `auth: false` removes the wall: requests are served without credentials * being demanded, and the inherited validator stays reachable, so a route * that declares `.authorize()` still pulls verification through it. * Identity demands can only tighten a public mount, never the reverse. */ interface HttpMountDefinition { /** Path prefix this mount owns. `"/"` is the catch-all fallback. */ path: string; /** Named entry from `CraftConfig.servers`. Defaults to `"default"`. */ server?: string; auth?: HttpAuth | false; } /** * Configuration for the built-in endpoints (`/health`, `/ready`, * `/openapi.json`). Each entry takes the {@link HttpBuiltinOptions} * shape; openapi additionally accepts an `info` block to populate the * OpenAPI document's `info` object. The meaning of `requireAuth` varies * per endpoint as documented on {@link HttpBuiltinOptions}. * * @experimental */ interface HttpBuiltinsOptions { health?: HttpBuiltinOptions; ready?: HttpBuiltinOptions; openapi?: HttpOpenApiBuiltinOptions; } /** * Configuration for `/openapi.json` specifically. Extends the uniform * {@link HttpBuiltinOptions} shape with `info`, the OpenAPI `info` block * (`title`, `version`, `description`, `contact`, `license`). When omitted, * `title` and `version` auto-detect from the host project's `package.json`; * see {@link HttpOpenApiInfo} for the security rationale on which fields * are auto-pulled and which are opt-in. * * @experimental */ interface HttpOpenApiBuiltinOptions extends HttpBuiltinOptions { info?: HttpOpenApiInfo; } /** * Uniform config shape for every built-in endpoint. Inspired by Spring * Boot Actuator's `management.endpoint..enabled` plus * `management.endpoint.health.show-details`, but compressed to a single * boolean for the auth gate. * * What `requireAuth` controls, per endpoint: * * | Endpoint | `requireAuth: false` | `requireAuth: true` | * | --- | --- | --- | * | `/health` | n/a (response is `{ status: "ok" }`, nothing to gate) | n/a | * | `/ready` | always `{ status: "ready", routes }` | anon: `{ status: "ready" }`; authed: `{ status: "ready", routes }`. Always 200 (k8s probes keep working). | * | `/openapi.json` | doc to anyone | 401 to anon; doc to authed | * * Defaults differ per endpoint based on security best practice: * * - `health`: `{ enabled: true }` (k8s liveness must be open; `requireAuth` is a no-op) * - `ready`: `{ enabled: true, requireAuth: true }` (gates the `routes` count) * - `openapi`: `{ enabled: true, requireAuth: false }` (matches the * Stripe / GitHub / Twilio / OpenAI convention of publishing the * schema publicly) * * `requireAuth` has no effect when no global `auth` is configured: there * is nothing to authenticate against, so the response collapses to the * `requireAuth: false` shape. * * @experimental */ interface HttpBuiltinOptions { /** Whether the endpoint is reachable. Default: `true`. When `false` the path returns 404. */ enabled?: boolean; /** Whether seeing the endpoint's full response requires authentication. See the table above for per-endpoint behaviour. */ requireAuth?: boolean; } /** * Back-compat alias. Older code wrote `CraftConfig["http"]: HttpConfig` * where `HttpConfig` was a placeholder `Record`. The * placeholder is gone; `HttpConfig` now resolves to the real plugin * options shape so the slot continues to type-check. */ type HttpConfig = HttpPluginOptions; /** * What a {@link HttpResponder} returns to send an answer of its own, described * rather than constructed. The dispatcher serialises it exactly as it serialises a * pipeline result, so one place owns the wire format. * * A `body` of `undefined` sends no body at all, which is what a bare * acknowledgement wants: `{ status: 202 }`. */ interface HttpResponseDescriptor { /** Status code to send. Required: a responder exists to choose it. */ status: number; /** Extra response headers, lower-cased before they are applied. */ headers?: Readonly>; /** * Response body. Serialised by the same rules as a pipeline result * (string, `Uint8Array`, `ArrayBuffer`, else JSON). Omit for an empty body. * Streaming is not available here; a route that streams has no responder. */ body?: unknown; } /** * What the request looked like by the time every gate had passed. The same * values the route receives, never the `Request` itself: its body has already * been read to parse and to verify the signature, so it cannot be read again. */ interface HttpRespondRequest { /** Parsed request body, by the same content-type rules the route sees. */ body: HttpRequestBody; /** * Raw request headers, lower-cased and unfiltered, so `authorization` * arrives verbatim wherever the caller sent one. A responder that logs or * echoes this map leaks the bearer; read `principal` for identity instead. */ headers: Readonly>; /** Resolved path parameters. */ params: Readonly>; /** Request method. */ method: HttpMethod; /** Matched route pattern. */ path: string; /** The admitted principal, where the mount or the route demanded one. */ principal?: Principal; } /** Everything a {@link HttpResponder} is given. */ interface HttpRespondContext { /** The request, as the route sees it. */ request: HttpRespondRequest; /** * The pipeline, already running. Awaiting it answers with the finished * exchange in hand, which is what the framework does when no responder is * configured. Returning without touching it answers now and leaves the * pipeline running detached. * * Rejections are already claimed by the framework, so ignoring this * promise is safe: a failure reaches the route's `.error()` handler and the * error events either way. Await it only to read the result. */ finished: Promise; } /** * Decides when the caller is answered, and with what. * * Called once per request, after every gate has passed (method and path * match, body limit, body parse, signature verification, and the mount's * credential check where it has one) and after the pipeline has been * started. Rejections are unaffected: a bad signature is still a 401 and the * responder never runs. * * The response is sent when the function returns, so the function decides * whether the caller waits: * * A descriptor is sent as soon as it is returned. Returning `undefined` * defers to the pipeline instead: the dispatcher waits for `finished` and * answers with its result, exactly as when the option is absent. So one * responder can decide per request. * * ```ts * // Acknowledge a webhook, then process. `finished` is never touched. * respond: () => ({ status: 202 }) * * // Answer with something derived from the finished pipeline. * respond: async ({ finished }) => ({ status: 200, body: (await finished).body }) * * // Answer a status ping at once; let a real delivery take the normal path. * respond: ({ request }) => * isPing(request.body) ? { status: 202 } : undefined * ``` * * Omitting the option entirely keeps the framework's own behaviour, which is * to await the pipeline and serialise its result. That path also carries * streaming and the deferral acknowledgement; a responder carries neither, * so a route that streams does not configure one. */ type HttpResponder = (ctx: HttpRespondContext) => HttpResponseDescriptor | undefined | Promise; /** Server-side options accepted by `http({...})` when used with `.from(...)`. */ interface HttpServerOptions { /** * Path pattern with `:param` segments. Examples: `"/orders"`, * `"/orders/:id"`, `"/tenants/:tenant/users/:user"`. Trailing slashes * are normalised away. */ path: string; /** HTTP method to accept. Defaults to `"GET"`. */ method?: HttpMethod; /** * Named mount under `http.mounts` this route belongs to. The mount, not * the route, decides authentication: a mount with an effective validator * is a wall (every route 401s without a valid token), a mount with * `auth: false` is public. The one route-level escalation is * `.authorize()`, which forces credential verification even on a public * mount; there is no route-level way to weaken a mount's posture. * * `path` is relative to the mount's prefix (a route `"/orders/:id"` on a * mount at `"/api"` serves `/api/orders/:id`). * * When omitted, the route resolves only to a mount literally named * `"default"`; if the plugin declares mounts and none is named * `default`, omitting `mount` fails loudly at subscribe rather than * silently landing a route on the wrong surface. */ mount?: string; /** * Attach the exact wire bytes of the request body to the exchange as * `routecraft.http.rawBody` (a `Uint8Array`). Needed to verify webhook * signatures manually: providers sign the raw bytes, and re-serialising * the parsed body is not byte-faithful. Defaults to `false`. * * Opt-in because of retention and exposure, not cost: the bytes already * exist in memory during parsing, but attaching them pins a buffer of up * to `maxBodySize` for the exchange lifetime and surfaces raw payload * bytes to anything that logs or serialises the exchange headers. */ rawBody?: boolean; /** * Declarative webhook-signature verification. When set, the plugin * verifies the raw request bytes against the configured header before * any route step runs; a missing, invalid, or expired signature returns * 401 and emits `auth:rejected` with `scheme: "signature"`. Independent * of the mount's bearer wall; place webhook routes whose only credential * is the signature itself on a public mount (`auth: false`). * * Only valid on body-bearing methods; configuring it on `GET`, `HEAD`, * `DELETE`, or `OPTIONS` throws RC5003 at construction. For providers * whose scheme is not built in, use `rawBody: true` and verify in a * route step instead. See {@link HttpWebhookSignatureOptions}. */ signature?: HttpWebhookSignatureOptions; /** * Decide when the caller is answered, and with what. Omit it and the * framework awaits the pipeline and serialises its result, which is what * every route did before this option existed. * * The webhook form is the reason it exists: * * ```ts * respond: () => ({ status: 202 }) * ``` * * That answers `202` the moment the gates pass and leaves the pipeline * running detached. Bird, Stripe and Svix all treat a slow response as a * failed delivery and redeliver, and the Standard Webhooks specification * says to acknowledge before processing, so a webhook whose work takes tens * of seconds cannot answer with its result. * * What a responder costs, all of it load-bearing: * * - **A refusal only reaches the sender if it happens before the answer.** * `.authorize()` runs inside the pipeline, so on a responder that answers * early a denial stops the work without the caller being able to tell it * from acceptance. A route that must be able to refuse its caller does * not answer early. * - **A detached failure has nowhere to go but the error channel.** It * reaches the route's `.error()` handler and the ordinary error events * (`route:error`, `context:error`, `route:exchange:failed`), and nothing * else, because the response has already gone. Give such a route an * `.error()` handler. * - **Answering early removes the backpressure the caller's wait provided.** * `.throttle()` and `.concurrency()` before `.from()` still apply, but * their defaults (`mode: "queue"` with no `maxQueue`, `mode: "delay"`) * cap how many run at once while the wait line grows without limit, and * every queued exchange pins its parsed body. Under the redelivery burst * this exists for, that is unbounded heap. Bound admission instead, with * `.concurrency({ maxQueue })` or `mode: "reject"`. * - **A responder cannot stream and cannot carry a deferral.** Both live * on the framework's own path, so a route that streams omits the option. * An exchange body that turns out to be a stream is cancelled once the * pipeline finishes, since nothing will read it and it may hold a socket * or a file descriptor. * - **`/openapi.json` advertises only the rejection codes** for a route with * a responder, because the document cannot know what a function returns. * A route wanting documented success codes omits the option. * - **Two combinations are refused outright** (`RC5003`, at subscribe): a * responder alongside `.batch()`, because a buffered message is not * in-flight work and an answer would outlive the delivery; and, at * request time, a responder once shutdown has begun, which answers `503` * with `retry-after` so the sender redelivers to the next instance. * Both refuse a responder that would have awaited the pipeline too, since * nothing can tell the two apart before calling it. * * A detached run is still the route's in-flight work, so a graceful * shutdown waits for it within the context's drain deadline. * * @see {@link HttpResponder} */ respond?: HttpResponder; } /** * Inbound HTTP message produced by the http source. Reaches the route * handler as the `body`. The parsed shape depends on `Content-Type`: * * - `application/json` -> parsed object. * - `text/*` -> string. * - `application/x-www-form-urlencoded` -> object built from URLSearchParams. * - `multipart/form-data` -> `FormData` (with `File` entries for uploads). * - other / no body -> `Uint8Array` (possibly empty). * * The route is free to swap the body in a `.transform(...)` step; the * dispatcher only reads the post-pipeline body for the response. * * Typed as `unknown` on purpose: the concrete runtime shape is only known * from the request's `Content-Type` (parsed object for JSON, `string` for * text, plain object for url-encoded form, `FormData` for multipart, or * `Uint8Array` for anything else). Route steps narrow it, typically via an * `.input()` schema. A union including `unknown` would collapse to `unknown` * anyway, so the alias states the honest contract and keeps the per-type * mapping in this doc comment. */ type HttpRequestBody = unknown; /** * Hint shape for influencing the response. Populated by writing the matching * headers on the exchange somewhere in the pipeline; the dispatcher reads * them when building the response object. */ interface HttpResponseHint { status?: number; contentType?: string; headers?: Readonly>; } declare module "@routecraft/routecraft" { interface RoutecraftHeaders { /** Request method as received from the client. */ "routecraft.http.method"?: HttpMethod; /** Matched route pattern (e.g. `/orders/:id`). */ "routecraft.http.path"?: string; /** Raw URL the client sent (path + query). */ "routecraft.http.url"?: string; /** Resolved path parameters keyed by name. */ "routecraft.http.params"?: Readonly>; /** Query string parameters as a flat object. Repeated keys keep the last value. */ "routecraft.http.query"?: Readonly>; /** * Raw request headers as a flat lower-cased map. This is the open-ended, * pass-through wire-header remainder (mirrors `routecraft.mail.rawHeaders`); * the parsed request envelope (method, path, query, params) is promoted to * its own keys above. */ "routecraft.http.rawHeaders"?: Readonly>; /** * Exact wire bytes of the request body. Only present when the route * opted in via `http({ rawBody: true })`; empty-body requests carry an * empty array. Use for manual webhook-signature verification or any * consumer that needs byte-faithful input. */ "routecraft.http.rawBody"?: Uint8Array; /** Override the response status code. */ "routecraft.http.response.status"?: number; /** Override the response Content-Type. */ "routecraft.http.response.contentType"?: string; /** Extra response headers merged into the final Response. */ "routecraft.http.response.headers"?: Readonly>; } } /** * What execution one returns when a route defers. * * A durable defer cannot hold a caller: the resume payload arrives in * hours or days and the process will be restarted first. So the run that * reaches a `.defer()` terminates there and replies immediately with this value * instead of the route's declared output. The real output flows to the * route's destinations on execution two. * * Every source renders it in its own terms (`202` plus this body on * `http()`, the value itself on `direct()`, a log line on `cron()` / * `simple()` / file sources, an ack on queue sources), which is why a route * with a reachable durable defer has output type `Output | Deferred`. * * The value is deliberately transport-shaped and JSON-safe: it crosses the * wire to whoever called the route. */ interface Deferred { readonly status: "deferred"; /** The deferred exchange's deferral id. */ readonly deferralId: string; /** Signed, single-use token that resumes it. */ readonly token: string; /** * JSON Schema rendering of what a valid resume payload looks like, when the * deferring step declared a schema and it exposes one (Zod, ArkType and * the AI SDK bridge do through the non-standard `~standard.jsonSchema` * extension). Absent otherwise, and absent whenever the site declared no * schema at all; validation always runs against the live schema at * resume, so nothing depends on this being present. */ readonly schema?: unknown; /** When the deferral expires, ISO-8601. Absent when `.defer()` declared no `ttl`. */ readonly expiresAt?: string; } /** * Whether a value is the framework's own {@link Deferred} acknowledgment. * * Transports call this on a route's terminal body to decide how to render * it. It is a brand check rather than a shape check on purpose: a route * whose real output happens to have `status: "deferred"` must not be * mistaken for a deferred exchange. */ declare function isDeferred(value: unknown): value is Deferred; /** * JSON Schema for the {@link Deferred} acknowledgment, draft 2020-12. * * The shape a transport publishes when it advertises that a tool or route * may defer: the MCP server derives `oneOf: [Output, Deferred]` for a * deferrable tool's `outputSchema` from this rendering. Closed for * additional properties so the advertised contract is exactly the value * {@link createDeferred} mints. */ declare const DEFERRED_JSON_SCHEMA: { readonly type: "object"; readonly description: "The run deferred at a durable deferral. The resume payload is delivered out of band with the resume token, and the real result is produced when the run continues."; readonly properties: { readonly status: { readonly const: "deferred"; }; readonly deferralId: { readonly type: "string"; }; readonly token: { readonly type: "string"; }; readonly schema: { readonly description: "JSON Schema of what a valid resume payload looks like, when the deferring step declared a schema that renders one."; }; readonly expiresAt: { readonly type: "string"; readonly format: "date-time"; }; }; readonly required: readonly ["status", "deferralId", "token"]; readonly additionalProperties: false; }; /** * Standard Schema for the {@link Deferred} acknowledgment. * * Exists so surfaces that reason in schema arms (the MCP server's * advertised-output arms are the shipped consumer) can carry the * acknowledgment as an ordinary `StandardSchemaV1` next to a route's own * `.output()` schema. Validation accepts the framework's own acknowledgment * by brand first, so a genuine `Deferred` value always passes whatever a * structural check would say, and falls back to the structural check for * values that crossed a process boundary and lost the brand. */ declare const deferredSchema: StandardSchemaV1; /** * Public types for the ops plugin's health surface. * * The status vocabulary and the report shape are the external contract an * uptime monitor and an orchestrator match against, so both are add-only: * fields may be added, never removed or repurposed. */ /** * The four-member health vocabulary. * * - `up`: serving normally. * - `degraded`: serving with reduced capability. A circuit breaker is holding * calls off, or the route was deliberately taken offline. Never pages. * - `down`: cannot serve at all. For a route this means its source gave up * producing; nothing it exists to serve is being served. * - `inactive`: deliberately out of the picture. A finished one-shot route, a * route stopped cleanly, or an indicator muted for maintenance. Excluded * from aggregation entirely. * * New signals map into these rather than extending them; their richer state * belongs in the per-component `details` map. */ type HealthStatus = "up" | "degraded" | "down" | "inactive"; /** * How widely a failure is felt, which decides whether readiness may carry it. * * - `instance`: this process or replica specifically. Peers may be fine, so * taking this instance out of the load balancer's pool can genuinely help. * - `deployment`: every replica alike. Derotating one sends traffic to a peer * that fails identically, so readiness must ignore it and only the * operational signal may carry it. */ type FailureDomain = "instance" | "deployment"; /** * Which components a report covers. * * - `readiness`: instance-domain components only, because the only thing * acting on this view does is move traffic between replicas, which helps * only when the peers are in a better position. * - `all`: every component, whatever its domain. The operational view, read by * humans and monitors, which never influences routing. */ type HealthView = "readiness" | "all"; /** * Where the app is in its own serving life, independent of whether any * dependency is healthy. This is the substance of readiness: during a deploy * a new container must not receive traffic until its routes are subscribed, * and during shutdown it must stop receiving before the process exits. */ type ContextState = "starting" | "started" | "stopping" | "stopped"; /** * Where a route is in its life, independent of whether that is healthy. * * Routecraft routes are not all long-running: a one-shot source runs, does its * work, and closes. Without this distinction such a route would either look * permanently `up` (a lie) or trip readiness when it finished (a false page). * * - `running`: started, still live. * - `completed`: stopped after doing work successfully. A finished one-shot. * - `stopped`: stopped cleanly without ever succeeding (shutdown, or a source * that had nothing to do). * - `offline`: taken out of service by an operator. Reports `degraded`, * never pages. * - `disabled`: held back by its own `.enabled()` predicate, with the reason * in `details.reason`. Reports `inactive`, so it is listed and excluded * from aggregation: a capability whose credentials were never supplied is * a configuration state the operator chose, and paging on it would make * every deliberately-dormant route an incident. Distinct from `offline`, * which is a running deployment having capability taken away from it, and * from `failed`, which is a route that should be running and is not. * - `failed`: not running when it should be. Its source gave up producing. */ type RouteLifecycle = "running" | "completed" | "stopped" | "offline" | "disabled" | "failed"; /** A circuit breaker's position, mirroring routecraft's breaker events. */ type CircuitState = "open" | "half-open"; /** * A component's status transition, as carried by `plugin:ops:health:changed`. * * Emitted as a component changes rather than derived by re-reading the whole * report, so an operator alerts on the transition instead of polling for it. */ interface HealthChange { component: "context" | "route" | "indicator"; /** The component's name; the reserved id `context` for the serving lifecycle. */ name: string; from: HealthStatus; to: HealthStatus; } /** * A value allowed in a component's `details` map. * * Structural facts only: enums, numbers, booleans. Never an error message. * Health answers whether a component can serve; what went wrong and why is * carried by logs and telemetry, which are not readable by everyone who can * reach this endpoint. */ type HealthDetailValue = string | number | boolean; /** Component-owned diagnostic context. Structural facts only. */ type HealthDetails = Record; /** What a component reports. */ interface Health { status: HealthStatus; details?: HealthDetails; } /** A component as it appears in a report. */ interface HealthComponent extends Health { /** * How widely this component's failure is felt. Only `instance` components * appear in the readiness view. */ domain: FailureDomain; /** Milliseconds since this component last reported. Indicators only. */ ageMs?: number; } /** * A report body. This shape is the external contract, so it is add-only: * fields may be added, never removed or repurposed. * * There is deliberately no separate `ready` flag. It would be exactly * `status !== "down"` on whichever view produced the report, and a derivable * field named `ready` on the operational aggregate invites the one mistake * this design exists to prevent: routing traffic on the deployment-wide * signal. Read `status`, and read it from `/health/ready` when the answer * decides where traffic goes. */ interface HealthReport { /** * Which components this report covers. Carried on the body so a consumer * holding a report can tell the operational aggregate from the routing * signal, rather than having to remember which path produced it. */ view: HealthView; status: HealthStatus; /** The app's own serving lifecycle. Always present, always instance-domain. */ context: HealthComponent; routes: Record; indicators: Record; } /** The liveness body. Deliberately carries no component state. */ interface LivenessReport { status: "up"; uptime: number; } /** * Exposure of the per-component `details` maps. * * - `always`: serve details to every caller. * - `when-authenticated`: serve details only to a caller the mount's * effective validator admits (the ops mount's own `auth`, else the * server's). Written explicitly with no validator in scope it fails the * boot: the operator asked for a gate and there is nothing to gate with. * As the unwritten default with no validator it collapses to `never`, * with a startup warning naming the ways out: a missing diagnostic is * visible and self-correcting, a leak is silent, and indicator details are * arbitrary app-supplied objects. * - `never`: never serve details. * * The gate withholds per-component diagnostics and nothing else. It does not * hide topology: component names are the keys of the always-served status * maps, and the per-component paths answer 404 for an unknown id in every * mode. An app that must not disclose its route inventory puts the ops * listener somewhere the public cannot reach it. */ type HealthDetailsExposure = "always" | "when-authenticated" | "never"; /** Health endpoint configuration. */ interface OpsHealthOptions { /** Exposure of per-component `details`. Defaults to `when-authenticated`. */ details?: HealthDetailsExposure; } /** Options for {@link opsPlugin}. */ interface OpsPluginOptions { /** * Named server (`defineConfig({ servers })`) to mount the health paths on. * Defaults to `"default"`. * * A dedicated ops port is a second named server the surface points at, which * is how an internal-only surface is expressed. Bind that server somewhere * the probes can reach: a Kubernetes `httpGet` probe comes from the kubelet * against the pod IP and a reverse-proxy check comes from the proxy, so * neither reaches `127.0.0.1`. Only a Docker `HEALTHCHECK`, which runs * inside the container, does. Keep the port private at the network layer * rather than by binding loopback. */ server?: string; /** * Validator for the details gate: `health.details: "when-authenticated"` * admits callers through this, falling back to the server's validator when * unset. It is not a wall. The health surface answers every probe without * a credential whatever this says, because an orchestrator's probe carries * none and a health endpoint that answers it 401 is a health endpoint that * restarts the pod. `health.details: "always"` is the way to serve * details to every caller. * * The management tiers under `/ops` identify their caller through this * same validator, and each tier's own {@link OpsTier} value decides what * that identity must carry. * * `false` follows the server plugin's meaning unchanged: the mount * declares no wall, while the inherited validator stays reachable for * anything that pulls identity rather than demanding it. So the details * gate still admits an authenticated caller, and a scope-gated tier, * which does demand it, is refused at boot as a contradiction rather than * quietly enforced through a validator the mount opted out of. */ auth?: HttpAuth | false; /** Health endpoint configuration. */ health?: OpsHealthOptions; /** * The management API's exposure, one field per tier. Every tier is * disabled unless named here, so an app that configures nothing serves * health and answers 404 on every `/ops` path. * * See {@link OpsTier} for what each value means. A scope string needs a * wall to enforce it, so one written where this mount declares none * (no `auth` here and none on its server, or an explicit `auth: false`) * fails the boot rather than admitting everyone. */ tiers?: OpsTiers; /** Indicators to register. See `defineIndicator`. */ indicators?: readonly Indicator[]; } /** The `ops` config key. */ type OpsConfig = OpsPluginOptions; /** How an indicator is declared. */ interface IndicatorDefinition { /** Unique name. Becomes the key in the report and the path segment. */ name: string; /** * No report within this window makes the indicator `down` (stale). * * Optional. With it, the indicator is expected on a cadence, which is right * for a probe route. Without it the indicator is only as fresh as its last * push, which is the correct reading for one fed from a business route that * may legitimately be idle for hours. */ maxAge?: Duration; /** * How widely this dependency's failure is felt. Defaults to `deployment`, * which is right for anything reached over the network with shared * credentials: every replica would fail the same way, so it must page * without ever moving traffic. */ domain?: FailureDomain; /** * Bind this indicator to a route's exchange outcomes: a completed exchange * reports up, a failed exchange reports down, and no exchange within * `maxAge` goes stale. The route needs no health code at all. * * For probe routes only, where the exchange is the health check by * construction. Binding a business route would make every expected refusal * (a caller without the scope, a validation error, a business rule saying * no) report the dependency down, which is the escalation this design * exists to prevent. */ route?: string; } /** * A registered indicator: both the declaration and the push handle. * * Inert until an ops plugin binds it to a context. Ledgers are keyed by * context rather than held in a closure slot, so one module-scope handle * serves any number of live contexts. */ interface Indicator { readonly name: string; readonly definition: Readonly; /** * Report this dependency healthy. * * Anything passed in `details` is published on the health endpoint, which * may admit unauthenticated callers. Pass structural facts, never an error * message: `err.message` from a connection or auth failure routinely carries * hosts, usernames, and provider text. Log the error where it happened. */ up(details?: HealthDetails): void; /** * Report this dependency unhealthy. * * The same rule as {@link Indicator.up}: `details` reaches the endpoint, so * it carries structural facts and never an error message. */ down(details?: HealthDetails): void; /** Mark the indicator inactive: it reports `inactive` and never pages until it reports again. */ inactive(): void; } /** * One management tier's admission rule. * * - `false` (and unset): the tier is disabled and its paths answer 404. * - `true`: the tier is open and needs no credential of its own. * - a scope string: the caller's principal must carry that scope. * * The mount's `auth` (its own, or the named server's, per the server * plugin's inheritance) decides WHO the caller is; this value decides what * that identity must carry. The two are deliberately separate: the same * validator serves every tier, and only the requirement differs. * * Disabled answers 404 rather than 403 so an unconfigured instance * discloses nothing about what it could expose, and so the rule is the one * an ingress proxy would enforce in front of it. */ type OpsTier = boolean | string; /** * The management tiers, individually exposed. Unset is `false`, so an app * that configures nothing answers 404 on every management path. * * `operations` (taking a route offline, resetting a breaker) is named in * the design and deliberately not delivered yet; it slots in here without * reshaping anything. */ interface OpsTiers { /** `GET /ops/routes` and `GET /ops/routes/{id}`. */ introspection?: OpsTier; /** `POST /ops/routes/{id}/exchanges`. */ dispatch?: OpsTier; /** * `GET /ops/events`, the context event bus tailed as Server-Sent Events. * * Its own tier rather than a corner of introspection: the route listing * describes an app's shape, while the tail carries what it is doing right * now, exchange failures and auth decisions included. An operator who * wants one is not thereby asking for the other. */ events?: OpsTier; } /** * The scope name this project documents for the introspection tier. * * A convention, not a default: every tier defaults to `false`, and the value * an operator writes is any string their tokens carry. */ declare const OPS_SCOPE_INTROSPECTION = "ops:introspection"; /** The scope name this project documents for the dispatch tier. */ declare const OPS_SCOPE_DISPATCH = "ops:dispatch"; /** The scope name this project documents for the events tier. */ declare const OPS_SCOPE_EVENTS = "ops:events"; /** * A read-only resource another package contributes to the management API, * served under the introspection tier at `GET /ops/{name}` and * `GET /ops/{name}/{segment...}`. * * The ops plugin lives in core and knows routes, exchanges and events. * What an ecosystem package keeps alongside them (an agent's sessions) is * listable through the same door and the same tier without core learning * what it is: the contributor answers the two reads, and the mount owns * admission, method handling and the wire. * * Read-only by contract. A resource that needs to act on something is the * operations tier's business and slots in there when it ships. * * A throw from either read is answered as a 500 carrying the error's code * and never its message; `RC5059` (a malformed limit or cursor) is the one * the caller owns and is answered as a 400 with the message. * * @template TItem - What the collection lists and the item view describes; * plain JSON, since it goes on the wire as the mount serialises it */ interface OpsResource { /** * The path segment under `/ops`. `routes` and `events` are the mount's * own and refused. */ readonly name: string; /** * The collection, filtered by the request's query parameters. The * contributor pages, with `parsePageQuery()`, `takePage()` and * `decodeCursor()` from core: a present `nextCursor` means there is * more, exactly as for the route collection, and a resource whose whole * collection fits one page returns it without one. */ list(query: Readonly>): Promise>; /** * One item, addressed by the decoded path segments after the resource * name. `undefined` answers 404. */ describe(segments: readonly string[]): Promise; } /** * A collection response. Never a bare array, from the first release: a * present `nextCursor` means there is more, and a correct client follows * it even against a collection that does not produce one today. */ interface OpsPage { items: T[]; /** Opaque keyset cursor. Absent on the last page. */ nextCursor?: string; } /** * One deferral as `GET /ops/deferrals` renders it. * * Distinct from the store's own `DeferralSummary`, and only in its * timestamps: a store answers in `Date`, and the wire has no such thing. * Left as `Date` on this type, `JSON.stringify` would still send strings * and every consumer of the client would be typed for a value it never * receives. Every closed vocabulary keeps its union, so a consumer can * switch exhaustively and a renamed state fails their build rather than * silently taking the other branch. * * What is absent is as deliberate as what is here. Never the resume * token, which is the credential; never the stored exchange, which is the * caller's data and can be anything; never the step state or the * resume-payload schema. A listing answers what is waiting, on what, and * for how long. */ interface OpsDeferralSummary { /** Deferral id, distinct from the deferred exchange's own id. */ id: string; /** The route the deferred exchange belongs to, and re-enters on resume. */ routeId: string; state: DeferralState; /** What the work is waiting for, or was waiting for when it settled. */ waitingFor: DeferralWaitingFor; /** * Whether a delivery claim is outstanding. A claimed deferral is still * waiting, and is not resumable while the claim holds: whoever took it * owns telling the route. */ claimed: boolean; /** ISO 8601. */ deferredAt: string; /** ISO 8601. Absent means no deadline. */ expiresAt?: string; /** How it settled. Absent while it is waiting. */ outcome?: { kind: DeferralOutcome["kind"]; /** ISO 8601. When it stopped waiting, which is what retention measures from. */ at: string; reason?: string; /** Who resumed it. Only ever set on a `resumed` outcome. */ by?: { subject: string; issuer?: string; clientId?: string; }; }; } /** * JSON Schema renderings of a route's declared schemas, when the schema * library exposes the non-standard `~standard.jsonSchema` extension. A * library without it yields nothing here; the live schema is what * validation runs against either way, so nothing depends on these. */ interface OpsRouteSchemas { body?: unknown; headers?: unknown; } /** * A route as the management API presents it. * * `dispatchable` is observed rather than inferred: a `direct()` ingress * registers the route in the capability registry when it subscribes, and * that registration is the door `POST .../exchanges` goes through. A cron-, * mail- or http-sourced route has no such door and says so here rather than * failing at dispatch time. * * `enabled` is reported separately because `dispatchable` alone cannot carry * both meanings. A route held back by its `.enabled()` predicate never * subscribes, so it is not in the capability registry either, and without * this field it would be indistinguishable from a cron-sourced route that * simply has no dispatch door. One is a configuration state an operator can * change; the other is the shape of the route. */ interface OpsRouteSummary { id: string; dispatchable: boolean; /** * False only while the route's `.enabled()` predicate is holding it back. * True for every route that declares no predicate, so an existing consumer * reading this field sees the state it already assumed. */ enabled: boolean; /** Source kinds, in declaration order (`direct`, `cron`, `mail`, ...). */ sources: string[]; /** The route declares a route-entry `.authorize()`. */ requiresPrincipal: boolean; title?: string; description?: string; tags?: string[]; /** * The remote this route was imported from, for a route another instance * exposes and `defineConfig({ remotes })` made dispatchable here. Absent * for a route this instance defines. Additive: an existing consumer that * never reads it sees the listing it always saw. */ remote?: string; } /** One route in full. Adds the schema renderings to the summary. */ interface OpsRouteDetail extends OpsRouteSummary { input?: OpsRouteSchemas; output?: OpsRouteSchemas; } /** Documented filters on `GET /ops/routes`. */ interface OpsRouteFilter { /** Only routes that do (or do not) have a dispatch door. */ dispatchable?: boolean; /** Exact route id match, not a prefix. */ id?: string; /** Routes carrying a source of this kind. */ source?: string; } /** A page request against the route collection. */ interface OpsRouteQuery extends OpsRouteFilter { limit?: number; after?: string; } /** * One item from the event tail. * * `dropped` and `heartbeat` are the tail's own signals rather than bus * events: a slow reader must learn that it missed something instead of * silently seeing a gap, and an idle app must still put a byte on the wire * often enough that an intermediary does not reap the connection. */ type OpsEventTailItem = { kind: "event"; /** The bus event's name, which rides the SSE `event` field. */ name: string; /** * The frame's payload, already rendered to JSON. * * Rendered when the event is emitted rather than when the reader takes * it, for two reasons. A bus payload may carry `exchange.body`, which * the framework deliberately leaves mutable, so a reference held until * the reader catches up could report a value that never existed at the * moment it was emitted. And holding a few hundred payloads by * reference pins whatever they reach, which on a route moving large * bodies is all of it. Rendering unpins that; the tail's byte budget * is what bounds what remains. */ data: string; } | { kind: "dropped"; count: number; } | { kind: "heartbeat"; }; /** * What a dispatch produced. * * A deferral is an outcome, not an error: a route that reaches a durable * `.defer()` replies with the acknowledgment every other surface returns, * and the operator at the terminal is often exactly who the deferral waits for. * A drop is separate from a failure because they need different answers: a * drop means a filter said no, a failure means something broke. */ type OpsDispatchOutcome = { outcome: "completed"; body: unknown; } | { outcome: "deferred"; deferral: Deferred; } | { outcome: "dropped"; message: string; }; /** * Base interface for all adapters (sources, destinations, transformers, filters, etc.). * Adapters can expose an optional `adapterId` string for logging (e.g. "routecraft.adapter.log"). */ interface Adapter { /** * Dotted identifier used for log and trace labels, by convention * ".adapter." (e.g. "routecraft.adapter.mail"). The last * segment is shown in step events and logs. Optional so inline adapter * objects (e.g. `{ aggregate: fn }`) stay ergonomic; framework-shipped * adapters always set it. */ adapterId?: string; /** * Observability hook for the FETCH slot: given the value the fetch * produced, return details to attach to the step's completion event * (`details.metadata`). Fires for `.enrich()` and for a fetch-only adapter * in `.to()`. * * Derive the result from the arguments, never from state written during the * call: one adapter instance serves every exchange on a route, so a field * set by one call is routinely read back by another. * * @param result - Value the fetch resolved to * @param exchange - Exchange the call ran against */ getMetadata?(result: unknown, exchange?: Exchange): StepOutcomeMetadata; /** * Observability hook for the SEND slot: given the receipt headers the send * recorded through {@link SendContext} (or `undefined` when it recorded * none), return details to attach to the step's completion event. Fires for * a send-resolved `.to()`. * * Named separately from {@link Adapter.getMetadata} so an adapter filling * both slots never has to sniff which contract it was handed. The same * no-instance-state rule applies. * * @param receipts - Receipt headers collected during the send * @param exchange - Exchange the call ran against */ getSendMetadata?(receipts: unknown, exchange?: Exchange): StepOutcomeMetadata; } interface Step { operation: OperationType; adapter: T; /** * Display name shown in traces, logs, and step events instead of the * raw OperationType. Set automatically by registerDsl for sugar methods * (e.g., "log" instead of "tap", "schema" instead of "validate"). * When absent, the operation field is used. */ label?: string; /** * When true, runPipeline will not emit generic route:step:started/route:step:completed * events for this step. The step is responsible for emitting its own * lifecycle events with the correct exchange identity. */ skipStepEvents?: boolean; /** * Execute this step and report what happened. The executor owns all * scheduling: steps no longer see the work queue or the remaining * pipeline, they describe an outcome and the engine routes it. * * The exchange is typed as Exchange at runtime (body is unknown); * the builder chain preserves body types for the next step, but custom * steps receive an untyped exchange. Narrow or assert body type if needed. */ execute(exchange: Exchange, ctx: StepContext): Promise; } /** * Observability metadata a step may attach to its outcome (e.g. LLM token * usage, HTTP status codes). The executor copies it into the * `route:step:completed` event payload. Guidelines: small values only * (IDs, names, counts, codes), no large bodies. Outcome-scoped on purpose: * `Step` instances are shared across exchanges, so per-execution data must * never live on the step itself. */ type StepOutcomeMetadata = Record; /** * What a step did with its exchange. Returned from {@link Step.execute}; * the pipeline executor translates outcomes into scheduling: * * - `continue`: run the remaining steps against `exchange` (the common case; * `exchange` is usually a rewrapped derivation of the input). * - `complete`: skip the remaining steps and complete the exchange * successfully (route-scope cache hit). * - `drop`: halt the exchange. The step has already called `markDropped` * and emitted its drop events (filter reject, choice unmatched, * parse-drop); the executor schedules nothing. * - `branch`: run `steps` and then the remaining steps against `exchange` * (choice routes into the matched branch). * - `fanOut`: schedule each child exchange independently through the * remaining steps (split). * - `defer`: deferral `exchange` durably and exit the pipeline, to be resumed * later at the next step. Produced by `.defer()`. The executor * serializes the exchange, writes the deferral, emits * `route:exchange:deferred`, and schedules nothing further for it; * `request` carries what it needs to do that (the `expect` schema, the * TTL, and where the defer sits in the route). Only the framework's own * defer step can produce a coherent `request`, so a custom step * returning this kind without one is rejected with `RC5032` rather than * silently dropping the exchange. * * The union is OPEN across minor releases: new kinds may be added as the * engine grows (the executor exhaustively handles every kind it ships * with). Code that consumes outcomes (custom wrappers, tests) should pass * unrecognised kinds through rather than throwing on them. */ type StepOutcome = { kind: "continue"; exchange: Exchange; metadata?: StepOutcomeMetadata; } | { kind: "complete"; exchange: Exchange; metadata?: StepOutcomeMetadata; } | { kind: "drop"; metadata?: StepOutcomeMetadata; } | { kind: "branch"; exchange: Exchange; steps: Step[]; } | { kind: "fanOut"; exchanges: Exchange[]; } | { kind: "defer"; exchange: Exchange; request: DeferRequest; }; /** * The abort surface of a step execution, handed to function-form steps * (`.process()`, `.transform()`, `.to()`, `.enrich()`) as their trailing * argument. {@link StepContext} extends it, so adapter authors * implementing {@link Step.execute} read the same field from `ctx`. * * `signal` fires when an enclosing `.timeout()` deadline expires (step * scope or route scope): promises cannot be cancelled, so the framework * discards the losing run's outcome, and this signal is how the run * itself finds out. Forward it into cancellation-aware IO (`fetch`, DB * drivers) so abandoned work actually stops instead of running to * completion in the background: * * ```ts * .timeout(3000) * .process(async (ex, { signal }) => { * const res = await fetch(url, { signal }); * return { ...ex, body: await res.json() }; * }) * ``` * * The signal's abort reason is the `RC5011` timeout error. It is absent * when no timeout wraps the step. Route shutdown does NOT fire it: * graceful drain lets in-flight exchanges complete (sources observe * shutdown via their own `Subscription.signal`). */ interface StepSignalContext { /** Fires when an enclosing `.timeout()` deadline expires. */ readonly signal?: AbortSignal; } /** * Narrow executor capability handed to {@link Step.execute}. * * `takePending` atomically removes and returns pending sibling exchanges * matching the predicate; it exists for join-style steps (aggregate) that * consume their split siblings. The queue itself is never exposed, so * steps cannot reorder, duplicate, or corrupt scheduling. * * Extends {@link StepSignalContext}: `signal` fires when an enclosing * `.timeout()` deadline expires, so steps doing cancellation-aware IO * can abort abandoned work. */ interface StepContext extends StepSignalContext { takePending(predicate: (exchange: Exchange) => boolean): Exchange[]; /** * Run each provided sub-pipeline against its own exchange, all in * parallel, resolving once every path has settled (`Promise.allSettled` * semantics: one path throwing does not reject the call). Each path runs * as an isolated nested pipeline whose failure fires that exchange's * default error events (the caller's route-scope `.error()` handler does * NOT run for a path) but does not affect the caller or sibling paths. * The queue itself stays private, so a fan-out step (e.g. `multicast`) * cannot reorder or corrupt the parent's scheduling. */ runPaths(runs: ReadonlyArray<{ steps: Step[]; exchange: Exchange; }>): Promise; /** * Run ONE sub-pipeline against its own exchange as an isolated nested run, * resolving with the run's outcome. Like a single {@link runPaths} entry, * but the result is reported back rather than swallowed, so a caller (e.g. * `dispatch`'s `failover` strategy) can decide what to do next based on * whether the run failed or was deliberately dropped. A failing path still * fires that exchange's default error events (the caller's route-scope * `.error()` handler does NOT run for it) and never rejects this call. */ runPath(run: { steps: Step[]; exchange: Exchange; }): Promise<{ failed: boolean; dropped: boolean; error?: unknown; /** * True when an outer abort signal (a route-scope timeout abandoning the * attempt) truncated the run before or while it was scheduling steps. * An aborted run is neither a success nor a target failure; callers * (dispatch failover) must not treat it as a handled exchange. */ aborted?: boolean; }>; /** * Capture the downstream continuation for the currently-executing step: * returns a runner that, when later invoked with an exchange, runs it * through the steps that FOLLOW this one as a detached, route-tracked * pipeline (with its own `route:exchange:started` / `:completed` lifecycle). * * Snapshot it synchronously inside `execute`; the runner stays valid after * `execute` resolves. Used by `debounce` to release a held exchange after * its quiet window closes without re-running the steps before it. Because * the released exchange is the route's PRIMARY flow (not a side-effect * clone), the detached run honors the route-scope `.error()` handler and * enforces the route's `.output()` schemas before completing, and it * inherits no abort signal from the capturing attempt. */ captureDownstream(): (exchange: Exchange) => Promise<{ failed: boolean; dropped: boolean; }>; } /** * Construction dependencies handed to a {@link Consumer} by the route * runtime, one bag per (source, consumer) pair. * * `options` is deliberately `unknown` here: route definitions store * heterogeneous consumer configurations, so the typed options surface * lives on the builder method that stages the consumer (e.g. * `.batch(...)`). Each consumer narrows and defaults its own options in * its constructor; it owns the interpretation of that value. */ type ConsumerDeps = { context: CraftContext; definition: RouteDefinition; channel: ProcessingQueue; options: unknown; }; /** * Constructable consumer class. The single deps-bag parameter (rather * than positional arguments) keeps every consumer class assignable to * `ConsumerType` without casts, so route definitions can store * any consumer implementation uniformly. */ type ConsumerType = (new (deps: ConsumerDeps) => T) & { /** * The consumer may hold a message before the pipeline runs, the way * `.batch()` fills a buffer. A held message is not the route's in-flight * work, so a graceful shutdown can drain past it, and a transport that * answers its caller before the pipeline finishes cannot promise the * delivery will run. Declared by the consumer rather than inferred from * its class, so a new consumer states its own answer instead of an * unrelated file guessing. */ readonly buffers?: boolean; }; /** * Internal envelope flowing from a source adapter to its consumer through the * route's processing queue. * * @property message - Raw payload as the adapter handed it to `handler(...)`. * When `parse` is set this is typically the unparsed bytes/string; when * `parse` is unset this is the already-parsed value used directly as the * exchange body. * @property headers - Optional exchange headers attached by the adapter. * @property parse - Optional parser the runtime invokes as a synthetic first * step before any user-defined steps run. When provided, the runtime * builds a derived exchange via * `DefaultExchange.rewrap(exchange, { body: await parse(exchange.body) })` * inside the same try/catch that handles step errors, so a parse * failure flows through the route's `errorHandler` and * `route:exchange:failed` event path. See `adapters/shared/parse.ts` for * the `OnParseError` semantics. If `parse` resolves to `undefined` * the body is explicitly set to `undefined` (the rewrap respects an * explicit `body: undefined`), not left as the previous value. * @property parseFailureMode - Decides how the synthetic parse step handles * a thrown parse error. `"fail"` (default) and `"abort"` throw `RC5016` * so `route:exchange:failed` fires; `"drop"` instead emits `route:exchange:dropped` * with `reason: "parse-failed"`. Adapters set this from their * `onParseError` option; the source loop additionally rethrows for * `"abort"` so the source dies. See #187. * * parse-error-handling work in #187. Their shape may evolve. */ type Message = { /** * The payload. When `parse` is set this is the RAW pre-parse value * (e.g. a JSON line string), typed as `T` only after the synthetic * parse step runs; adapters narrow at the call site. */ message: T; headers?: ExchangeHeaders; parse?: (raw: unknown) => unknown | Promise; parseFailureMode?: OnParseError; }; interface Consumer { context: CraftContext; channel: ProcessingQueue; definition: RouteDefinition; options: O; /** * Register the route handler. The handler receives the same * {@link Message} envelope that sources enqueue, so a pass-through * consumer forwards it untouched while merging consumers (e.g. batch) * synthesize new envelopes. At runtime the envelope's `message` and the * returned exchange's body are untyped (unknown); the builder chain is * typed, so narrow or assert in the handler if you need body fields. * * When the envelope carries a `parse` function the route captures it on * the exchange internals so `runPipeline` can apply it as a synthetic * first pipeline step. Consumers that merge multiple messages parse * items eagerly during enqueue and hand over a `parse`-less envelope. */ register(handler: (envelope: Message) => Promise): void; } /** * Internal queue API for route source→consumer flow. Sources enqueue messages; the consumer handler is set by the route and receives messages. Used by DefaultRoute. * * @template T - Message type (typically Message with message + headers) */ interface ProcessingQueue { enqueue(message: T): Promise; setHandler(handler: (message: T) => Promise): Promise | void; clear(): Promise | void; } /** Exchange snapshot attached to terminal exchange events. */ type ExchangeSnapshot = { id: string; headers: Record; body: unknown; }; /** Shared identity fields on per-exchange events. */ type ExchangeScoped = { routeId: string; exchangeId: string; correlationId: string; }; /** * What every tool-call event carries. * * `session` names the conversation the call was made in, when the agent * was dispatched with one. A session's boundary turn runs on the exchange * that deferred, which may be an earlier caller's, so the exchange identity * alone cannot say which conversation a call belongs to; the session can. */ type ToolCallScoped = ExchangeScoped & { toolCallId: string; toolName: string; session?: string; }; /** * Every event the framework emits, mapped to its detail payload. * * Event names are a FIXED, finite set: identity (route id, plugin id, * step label) lives in the payload, not the name. Subscribe with exact * names and filter on payload fields (see {@link forRoute}), or use the * single catch-all `"*"` to observe everything (telemetry-style taps). * * Declared as an interface so ecosystem packages can add their own events * via declaration merging, mirroring `ErrorCodeRegistry` / `StoreRegistry`: * * ```typescript * declare module "@routecraft/routecraft" { * interface EventDetailsMap { * "myext:thing:happened": { routeId: string; thing: string }; * } * } * ``` */ interface EventDetailsMap { "context:starting": Record; "context:started": Record; "context:stopping": { reason?: unknown; }; /** * Shutdown finished. `forced` is true when stage one did not drain inside * `shutdown.timeout` and in-flight execution was abandoned, with * `pending` naming the routes that still had work. A clean stop carries * `forced: false` and an empty `pending`, so the shape is stable and a * subscriber can count forced shutdowns without reading exit codes. */ "context:stopped": { forced: boolean; pending: string[]; }; "context:error": { error: unknown; route?: Route; exchange?: Exchange; }; "auth:success": { subject: string; scheme: string; source: string; }; "auth:rejected": { reason: string; scheme: string; source: string; }; "route:registered": { routeId: string; route: Route; }; /** * A route's enablement predicate was evaluated and the verdict CHANGED * (or was reached for the first time). Not emitted when a refresh * re-confirms what the route was already doing, so a five-minute cadence * on a stable predicate is silent rather than a heartbeat. * * `reason` is present only when `enabled` is false, and is what ops * reports next to the route. */ "route:enablement:changed": { routeId: string; route: Route; enabled: boolean; reason?: string; }; "route:starting": { routeId: string; route: Route; }; "route:started": { routeId: string; route: Route; }; "route:stopping": { routeId: string; route: Route; reason?: unknown; exchange?: Exchange; }; "route:stopped": { routeId: string; route: Route; exchange?: Exchange; }; /** * A source subscription rejected: the source gave up producing (e.g. a * connection-backed source exhausted its reconnect attempts) and the route * is about to stop. Distinct from `route:stopping`, which also fires for * orderly shutdowns; subscribe to this one to alarm on a dead channel. */ "route:source:failed": { routeId: string; route: Route; /** `adapterId` of the failed source, when the adapter declares one. */ adapter?: string; error: unknown; }; "route:error": { routeId: string; error: unknown; route?: Route; exchange?: Exchange; }; "route:error:caught": { routeId: string; error: unknown; route?: Route; exchange?: Exchange; }; "route:exchange:started": ExchangeScoped; "route:exchange:completed": ExchangeScoped & { duration: number; exchange?: ExchangeSnapshot; }; "route:exchange:failed": ExchangeScoped & { duration: number; error: unknown; exchange?: ExchangeSnapshot; }; "route:exchange:dropped": ExchangeScoped & { reason: string; exchange?: ExchangeSnapshot; }; "route:exchange:restored": ExchangeScoped & { source: string; }; /** * The exchange deferred at a `.defer()` and execution one ended. This is * that run's terminal event, in place of `:completed`: the body the * source receives is the `Deferred` acknowledgment, and the route's real * output flows on execution two. */ "route:exchange:deferred": ExchangeScoped & { deferralId: string; /** Address of the deferring step; the continuation is what follows it. */ position: number; /** When the deferral stops being resumable, when a `ttl` was declared. */ expiresAt?: Date; }; /** * A deferred exchange was revived and its continuation is about to run. * Execution two's `:started` follows immediately, and that run gets its * own `:completed` / `:failed` / `:dropped`. */ "route:exchange:resumed": ExchangeScoped & { deferralId: string; position: number; /** Who resumed it, when the resume ingress had an authenticated principal. */ resumedBy?: PrincipalRef; }; /** * A deferral stopped being resumable because its `ttl` elapsed. Fires * when a late resume discovers it, and (once the sweeper lands) when the * sweeper reaches it first. The deferred route's error channel receives * `RC5047` alongside, which is where a re-ask belongs. */ "route:exchange:expired": ExchangeScoped & { deferralId: string; expiresAt: Date; }; "route:step:started": ExchangeScoped & { operation: OperationType | string; adapter?: string; }; "route:step:completed": ExchangeScoped & { operation: OperationType | string; adapter?: string; duration: number; metadata?: Record; }; "route:step:failed": ExchangeScoped & { operation: OperationType | string; adapter?: string; duration: number; error: string; }; /** A step threw; `operation` is the step label (was `route::step: