Menu
On this page

Changelog

All notable changes to this project are documented here. The format follows Keep a Changelog, and the project follows Semantic Versioning.

Unreleased

[0.11.0] — 2026-10-30

Added

  • Start a project from an example (#528, @setu-ts/cli). setu new <name> --example <app> (also npm create @setu-ts <name> -- --example <app>) downloads an offered example from the CLI’s GitHub release tag and rewrites it into a standalone Node project. Dry-run fetches nothing; failed downloads write nothing. See Examples.

  • node-redis as a supported Redis client (M117, #525, 0.11.0, @setu-ts/common, the Redis-backed plugins, @setu-ts/telemetry-plugin, @setu-ts/cli). fromNodeRedis(client, options?) wraps a node-redis (redis v5/v6) client so every Redis-backed plugin’s client option takes it: cache, queue, Redis Streams messaging, the realtime backplane, the scheduler lock, idempotency and RedisRateLimitStore. The adapter (NodeRedisAdapter, typed over NodeRedisClientLike, options NodeRedisAdapterOptions) bounds each command with its own commandTimeoutMs (default 15 s), because node-redis does not bound a written command; connects lazily; keeps an 'error' listener attached; and turns RESP3 map replies back into RESP2 shapes. No package depends on redis; ioredis stays the default. isConnectionFault recognizes node-redis’s outage errors, so they answer 503 as ioredis outages do. instrumentations.redis traces node-redis through @opentelemetry/instrumentation-redis (^0.69.0, optional peer) on Node; fromNodeRedis also takes a NodeRedisClientFactory, so redis can load on the first connect, after the instrumentation is registered. setu new/setu add take --redis-client ioredis|node-redis; a node-redis project wires each plugin through a generated src/node-redis.ts, and a workspace records redisClient. See Using node-redis and upgrading.

  • Workspace build, test and deploy (M122, #522, 0.11.0, @setu-ts/cli, @setu-ts/common). setu workspace build builds one image per member, tagged with the commit. setu workspace deploy builds, loads into a kind-* cluster or pushes to --registry, applies one Secret per member from k8s/secrets/<member>/.env (names from .env.example; server-side, base64 data:), applies k8s/members.yaml with the namespace and images filled in, and waits for every rollout; it refuses first, naming every missing variable, and --dry-run shows the plan. New roots get build and deploy scripts, and test on npm and Bun (Deno: deno task --recursive test, since a recursive root task would call itself). Generated Deployments read the Secret through envFrom, and Compose services read each member’s .env — a full-stack member’s SESSION_SECRET was set by neither. The optional env_file needs Docker Compose 2.24.0 or later. parseEnv moves from config-plugin into @setu-ts/common. See deployment and upgrading.

  • Local diagnostics on Node (M126, #521, @setu-ts/runtime). The explicitly activated diagnostics connector can bind on Node as well as Deno, using a private IPv4-loopback node:http server. Request-target spellings are preserved, duplicate singleton lines are refused, parser errors answer a fixed empty 400, bind conflicts reject startup, and shutdown destroys open connections; the server evicts oldest-idle sockets at 32 and routes Expect through the ordinary request path. Bun and Workers continue to refuse this transport.

  • Session and account lifecycle (M124, #520, 0.11.0, @setu-ts/auth-plugin). signIn: { providers: [] } is accepted without mfa or passkeys, so a password-only application gets refreshPrincipal’s per-request re-check (V10-65). requireAuth({ signInPage }) (RequireAuthOptions) answers an unauthenticated browser navigation with 302 to the sign-in page carrying returnTo, and every other request with the existing 401; both answers append Accept, Sec-Fetch-Mode and X-Requested-With to Vary (V10-66). signIn.sessionStamp (SessionStamp) reads a stamp from the application’s user record: its SHA-256 digest is recorded at sign-in and compared on every request in constant time, so changing the stamp ends every session of that user on both session strategies; passkey registration admits a session only through the same re-checks, so an ended session cannot enrol a credential and mint a new one (its mayRegister policy now receives that re-checked principal, carrying claims.amr). IAuthSessionService.current() stays the un-re-checked snapshot — authorize from ctx.request.user. safeReturnTo is exported for an application’s own login handler. See ending every session of a user and upgrading.

  • Full-stack broker configuration (M121, #519, 0.11.0, @setu-ts/cli). Full-stack scaffolds and workspace members render messaging and queue transport options with config-backed connection reads and the selected clients. setu add messaging / queue reads workspace transports and accepts standalone --broker / --queue, preserving existing values and printing exact manual guidance. setu add react-router refuses non-full-stack projects with the command to create one. See upgrading.

  • Styled app shells and views (M123, #517, 0.11.0, @setu-ts/cli). Full-stack scaffolds include Tailwind 4, neutral shadcn theme tokens, copied MIT Button/Card, a light/dark/system toggle and a fixed header/sidebar with scrolling content. components.json supports adding more components; class names combine with shadcn’s cn package, and app/lib/utils.ts (classNames) is no longer generated. setu add view adds hono, JSX options, Tailwind CLI build/watch scripts, stylesheet, layout, /ui example and static serving, preserving and reporting existing choices. Workers receives the dependency/JSX/example pieces only. See upgrading and server-rendered view styling.

  • npm configuration as a build secret (M120b, #516, 0.11.0, @setu-ts/cli). Every generated install step (Node, Bun and Deno; workspace and standalone) mounts an optional BuildKit secret npmrc at /root/.npmrc, and the generated Compose file passes it from NPMRC, so a private registry token or npm settings reach the install without entering an image layer. Without the secret, builds are unchanged. Generated Dockerfiles now need BuildKit; the deprecated legacy builder refuses RUN --mount. A one-member workspace’s generated docker/compose.yaml no longer renders an empty environment: key, which Compose refused for the whole file. See deployment and upgrading.

  • Production builds (M120, #515, 0.11.0, @setu-ts/cli). setu build checks wiring and bundles one Node service, including workspace libraries and migration runners. Runtime npm packages remain external; runtime imports of devDependencies fail. Generated Node starts now require a build; use start:dev for source development and start:debug for debugging. Node and Bun full-stack starts stop rebuilding. Standalone and workspace images install production dependencies and build frontend artifacts in the image. Bun workspaces select the hoisted linker so members resolve shared libraries; library generation safely plans the setting for older workspaces. Deno images build a full-stack frontend on a glibc stage, and a Deno workspace with a full-stack member and a library no longer fails to start read-only (the image switches the root’s nodeModulesDir to "manual"). Generated .dockerignore files exclude .npmrc. Every CLI command now refuses a manifest that is itself a symbolic link (naming the link) instead of reading through it. See upgrading.

  • Custom logger transport (#512, 0.11.0, @setu-ts/logger-plugin). LoggerPlugin({ transport: 'custom', logger }) wraps an application ILogger with level filtering, metadata redaction, request logging and trace correlation. Logger/transport mismatches throw a named LoggerConfigurationError at construction; child adapters keep the controls.

  • Scaffold logger choice (#512, 0.11.0, @setu-ts/cli). setu new --logger console|pino and setu generate app --logger console|pino configure templates with logging. The prompt, help and dry-run report the choice; Pino on Cloudflare Workers is refused by name.

  • setu check and setu sync (M118, #510, 0.11.0, @setu-ts/cli). setu check [--dir] [--strict] is a read-only wiring gate: it exits 1 when a seam artifact is skipped, a barrel does not list an artifact in its directory or lists one that is gone, setu.config.ts does not reference a barrel on disk, an artifact is registered both by its barrel and by hand, a feature module is skipped or not activated, or the old src/routes/ layout is still in use. It never loads the application. Barrels are compared as sets of names, so a reformatted barrel passes. Every finding carries a stable id (setu.E001…setu.E013, setu.W001…setu.W004); warnings exit 0 unless --strict. A setu.config.ts or seam artifact that exists but cannot be read is an error, and sync leaves an artifact’s barrel unchanged rather than drop an import it cannot verify. .setu-ts/check.json silences an id everywhere or for one path, and an invalid file silences nothing (setu.E011). From a workspace root both commands run over every member, and refuse a member that is a symbolic link. check executes no project code and parses in linear time, so it is safe on an untrusted pull request; sync and generate load the project’s Prettier configuration. setu sync [--dir] [--dry-run] rewrites every CLI-managed barrel from its directory with the renderer setu generate uses, writes nothing else, and then reports what it could not fix.

  • Realtime subscriber reporting (#511, 0.11.0, @setu-ts/common, @setu-ts/realtime-backplane-plugin, @setu-ts/cloudflare-plugin). dispatchRealtimeFrame and createRealtimeHandlerErrorReporter share subscriber isolation and per-handler warning, repeat and recovery diagnostics, with IRealtimeHandlerErrorReporter and RealtimeHandlerErrorReporterOptions as their contracts. onHandlerError(error, frame) replaces default logging on built-in backplane plugin options and DurableObjectBackplaneOptions.

  • Bounded LaunchDarkly snapshots (#511, 0.11.0, @setu-ts/feature-flags-plugin). LaunchDarklyProviderOptions.maxCachedContexts caps the least recently used context snapshots; default 10,000, validated as a positive integer at provider construction.

  • A compiled DI container with a request scope (M125, 0.11.0, @setu-ts/common, @setu-ts/di-plugin, @setu-ts/kernel, @setu-ts/decorator-plugin, @setu-ts/testing). The container compiles its graph into direct factories and validates it once at boot. createToken<T>(name) (@setu-ts/common) returns an InjectionToken<T>, a frozen object matched by identity; with a class, it is accepted wherever a ContainerToken<T> is, and resolve infers T from either. InjectEntry is one inject element, a token or { token, optional: true }. IContainer.forRequest(ctx) returns the request’s scope, created on first call and disposed when the request finishes; IContainer.dispose() and ProviderOptions.dispose release what the container created, each asynchronous disposal bounded at 5000 ms (per disposal, not in total); ContainerConfig.timing supplies the timers, and ContainerConfig.reportDisposeError — set by DiPlugin to a logger warn — reports a request scope’s disposal failures whenever they settle. IRequestContext.defer?(cleanup) is the kernel’s per-request cleanup primitive: cleanups run before a buffered response is returned, or when a streamed body ends, each asynchronous one bounded at 5000 ms, and a request that never calls it stays on the synchronous path. IRequestContext.slot?(key, create) is owner-keyed per-request storage under a key only its owner holds; forRequest keeps the scope there. @Params(Service(token)) (@setu-ts/decorator-plugin) resolves from the request scope on every call. DiContainer.close() ends registration; DiPlugin calls it after the last bootstrap hook. New errors in @setu-ts/di-plugin: ContainerValidationError (with findings), ScopeRequiredError, ScopeDisposedError, ContainerClosedError and RequestScopeUnavailableError.

  • Portable database errors (M119e, 0.11.0, @setu-ts/common, @setu-ts/database-plugin). UnknownPayloadFieldError moves to common, preserving the database-plugin re-export’s identity; InvalidFieldValueError provides the fixed 400 field-type refusal. MissingKeyColumnError and TenantColumnUnresolvedError name server configuration faults and their remedies.

  • PublishTimeoutError (M119a, 0.11.0, @setu-ts/messaging-plugin). A new exported class for a publish that outlived its acknowledgement bound and may still be delivered, so a caller can distinguish it from a broker refusal without parsing the message: name is 'PublishTimeoutError', and the error carries the published topic and the configured bound as readonly topic: string and timeoutMs: number. The Kafka, Pub/Sub, RabbitMQ, Redis Streams and NATS publish paths throw it when publishTimeoutMs expires; its message names the broker, topic and bound and ends ”…; it may still be delivered”, with the Kafka, Pub/Sub and RabbitMQ texts unchanged from before, so a caller matching on the text keeps working.

  • publishTimeoutMs on the Redis Streams and NATS arms (M119a, 0.11.0, @setu-ts/messaging-plugin). The option the Kafka, Pub/Sub and RabbitMQ arms already accept is now accepted by RedisStreamsMessagingOptions (and RedisStreamsOptions) and NatsMessagingOptions (and NatsOptions): default 15000 ms, 0 unbounded, a value outside 0–2147483647 (including NaN) refused with RangeError at MessagingPlugin(...). On Redis Streams the bound covers the publish’s XADD only — consumer reads are unaffected. On NATS it is passed to the client as its own publish timeout and held by the framework’s deadline as well.

  • describeError(value) (M119g, #500, 0.11.0, @setu-ts/common). A new exported function that renders any thrown value as one bounded diagnostic line — Name: message (code=…, …) [inner; inner] <- cause — from the same serializeError record the structured sink uses. A non-Error value yields Error: <string>, an AggregateError lists its members, whitespace and control characters collapse to one space, and the line caps at 8192 Unicode code points with … [truncated]. Pure and total. It is the one renderer behind createConnectionErrorReporter, the idempotency plugin’s claim/release/settle lines, and the messaging plugin’s broker error lines (moved from there unchanged).

  • HealthIndicatorOptions and per-indicator readiness membership (M119b, 0.11.0, @setu-ts/common). A new exported interface whose only member is readiness?: boolean; absent means the indicator counts toward readiness, which is the previous behaviour. It is carried by an optional parameter on both registration surfaces — IHealthApi.register(name, indicator, options?) and IHealthService.registerIndicator(name, indicator, options?) — and by an optional IHealthIndicator.readiness? member. checkReady() runs only the indicators that are not self and that count toward readiness; an indicator outside readiness still runs in check() (/health) and is absent from the /ready report. checkLive() is unchanged.

  • HealthPluginOptions.readiness (M119b, 0.11.0, @setu-ts/health-plugin). { include?: readonly string[], exclude?: readonly string[] }, which overrides a contributor’s declared membership by exact indicator name: include forces a named indicator into readiness, exclude forces it out. HealthPlugin(...) refuses a non-array, an empty or non-string name, the name self, or a name in both lists with TypeError; the message names the option and never echoes a name. Names that match no registered indicator produce one count-only warning at startup.

  • setu agents --check --strict (V10-24, M119h, #506, 0.11.0, @setu-ts/cli). --strict is accepted only with --check (alone: usage error, exit 2, --strict requires --check.). With it, each agent-card warning becomes <AGENTS.md path>: <warning> and the command exits 1, so a CI step run after install fails when a package’s AGENT_CARD.md was not delivered. Plain --check still answers without an install.

  • Generated Workers projects carry test and test:e2e (V10-23, M119h, #506, 0.11.0, @setu-ts/cli). A cloudflare-workers scaffold now gets both scripts, run through tsx --test against the real Worker on workerd via wrangler’s createTestHarness, plus test/support/server.ts (which locates wrangler.toml from its own path, never the working directory), a smoke test, an e2e test and fetch-based module tests. package.json records tsx and @types/node, tsconfig.json adds node to types, the deno.json test task that permitted an empty suite is dropped, and the generated README’s Testing section names both commands. The upgrade guide shows how to add them to a project scaffolded before this release.

  • CliDependencies.logicalCwd (V10-25, M119h, #506, 0.11.0, @setu-ts/cli). One additive optional member on the exported type. The executable supplies the shell’s PWD only when it is absolute and resolves to the physical working directory; setu agents uses it solely to refuse a write through a symlinked workspace member, never to authorize one.

Changed

  • Agent brief development guidance (M119i, #528, @setu-ts/cli). Briefs list and gloss development commands, distinguish Workers from socket full-stack development, and include messaging guidance for starter compositions. Workspace roots name the member development entry and production start. Existing projects re-run setu agents; setu agents --check reports their old briefs stale.

  • IRedisLockClient.eval returns Promise<unknown> (M117, #525, 0.11.0, @setu-ts/scheduler-plugin). It declared Promise<number | string | null>, which one adapter serving the queue’s and idempotency’s scripts could not honestly satisfy; the lock ignores the result. Any client that satisfied the old type still does.

  • IRedisBackplaneClient gains an optional connect?() (M117, #525, 0.11.0, @setu-ts/realtime-backplane-plugin). When the backplane starts it connects an injected client whose status is 'wait', so a lazily connecting client reports its real health from the start.

  • Node devtool scaffolding (M126, #521, @setu-ts/cli). --devtool and devtool enable now support Node projects and workspace members. Development entries read credentials and exit through runtime services; older CLI-owned entries remain recognized and can be upgraded. Node uses tsx main.dev.ts, keeps the diagnostics dependency development-only, and forwards a workspace session only to its selected member within the launcher process group.

  • redactRecord() returns a snapshot (#513, 0.11.0, @setu-ts/common). Every plain object and array it walks is now copied, unclassified subtrees included, instead of being returned by identity, so a logger serializes exactly the values redaction checked (the same choice pino’s own redactor makes). Code that compared a result’s subtree with the original by === should compare by value.

  • One request completion at info (#512, 0.11.0, @setu-ts/logger-plugin). Request logging emits request completed at info with method, path, status, duration and the child’s requestId. request received moves to debug; set LoggerPlugin({ requestLogging: true, level: 'debug' }) to restore the arrival/completion pair. When the exposed child level excludes debug, arrival metadata is not built. Slow warnings, error-status prediction and path exclusions are unchanged.

  • Pino scaffold default (#512, 0.11.0, @setu-ts/cli). REST, microservice, class-based and full-stack projects use Pino on Node, Deno and Bun, adding its 10.x runtime dependency through the existing client dependency renderer. Workers defaults to console; --logger console keeps console output without installing Pino.

  • Generated projects run setu check (M118, #510, 0.11.0, @setu-ts/cli). Node, Bun and Workers scaffolds carry @setu-ts/cli at the framework range in devDependencies, and their check script is tsc --noEmit && setu check (npm run build && tsc --noEmit && setu check for a Workers project with a build). Deno scaffolds gain a check task: deno check main.ts setu.config.ts && deno run -A npm:@setu-ts/cli@^<version> check; the devtool form adds main.dev.ts, and setu devtool enable replaces the scaffold’s own check task with it. check and sync are built-in verbs and now shadow a plugin command of the same name. setu generate reads setu.config.ts with the scanner’s 1 MiB regular-file limit; its output is otherwise unchanged. See the upgrade guide for existing projects.

  • Optional peers in a workspace (M119f, #505, 0.11.0, @setu-ts/common, @setu-ts/cli and every optional-peer loader). OptionalDependencyUnavailableError accepts requiredBy?: string; all 49 loaders name their npm package and direct installation to that package’s location within its declared version range, usually the workspace root. dependency and cause are unchanged; requiredBy adds no instance field. setu add records its peers in both member and root package.json dependencies for listed Node/Bun Setu workspace members, preserving existing entries (a peer in dependencies, optionalDependencies or a non-optional peerDependencies entry counts; an optional peer is added to dependencies at its own spec; one only in devDependencies is moved to dependencies, pin unchanged, since --omit=dev would drop it), recording a peer the root overrides pins (a string value, or an object’s "." entry) at that override’s spec since npm refuses any other, leaving $name references and conditional overrides (a version-selector key, or several rules for one package) to the user, noting (while still writing the root entry at the CLI’s range) a member that pins the peer at another spec, since npm may install a second copy for it, and naming both edited paths. Deno and standalone behavior is unchanged. See the upgrade note.

  • Drizzle tenant columns resolve to properties (M119e, 0.11.0, @setu-ts/database-plugin). The tenant bridge resolves and caches the configured column per entity for every read and write; an absent or ambiguous mapping fails before statement I/O. Payload stamps remain last and update strips the resolved property.

  • Scoped cursor fingerprints (M119e, 0.11.0, @setu-ts/common, @setu-ts/database-plugin, @setu-ts/cloudflare-plugin). sortFingerprint gains optional entity/key-column scope. Memory, Prisma, Drizzle, MongoDB, DynamoDB, Cosmos DB, Bigtable and D1 refuse legacy and cross-entity cursors; restart pagination once on upgrade.

  • BREAKING — one place for routes: controllers (#526, 0.11.0, @setu-ts/cli). Every file that registers routes is a controller, <name>.controller.ts: a class controller (@Controller) or a function controller (register<Pascal>Routes), told apart by its exports. The .routes.ts suffix is gone, and setu g route writes a function controller. A project keeps controllers and services in one place: src/controllers/ and src/services/ until the first setu g module, then only src/modules/<name>/. New projects start flat (the rest greeting example is in src/controllers/), and no longer scaffold src/modules/index.ts. The first g module removes the flat barrels and rewrites setu.config.ts’s registration lines; with flat controllers or services present it refuses, and --adopt-controllers moves them into the module, rewriting imports and keeping URLs, all or nothing; the scaffold’s untouched greeting example moves without the flag. Once a project has modules, controller, route, service, sse and setu add view require --module. Subfolders are scanned (up to 8 deep; linked directories are reported, never followed), and setu sync relocates moved files. A class-based module’s index.ts is now CLI-managed and registers its function controllers, so a modular class-based config also calls registerModules(app.router, app.services); g controller and g service add their class to a plain @Module array literal. setu add view writes views.controller.ts instead of src/views/routes.ts and the registerViewRoutes call. setu check adds setu.E014 (controllers or services outside modules), setu.E015 (an import in a CLI-written file that does not resolve) and setu.E016 (two controllers with one base name). There is no migration for existing projects. See the CLI guide.

  • BREAKING — Zod 3 support removed (#496, 0.11.0, @setu-ts/openapi-plugin, @setu-ts/validation-plugin, @setu-ts/decorator-plugin). The supported range is now zod >=4.6.6 <5; validation still duck-types safeParse but is tested with Zod 4 only. OpenAPI schemas without the public toJSONSchema method, including Zod 3 schemas, now degrade to {} with the existing not a recognized zod schema unrepresentable diagnostic. Upgrade Zod to at least 4.6.6 and migrate any Zod 3 API usage before regenerating OpenAPI documents and clients.

  • BREAKING — DI lifetimes are enforced (M125, 0.11.0, @setu-ts/di-plugin, @setu-ts/common, @setu-ts/decorator-plugin). A singleton reaching a scoped provider, directly or through transients, fails the boot with ContainerValidationError, as do a missing dependency and a cycle — previously the singleton silently kept the first scope’s instance, and the others failed on the first resolve that reached them. Resolving a scoped token on the root container, or a transient there that reaches one, or a transient with a dispose option, throws ScopeRequiredError instead of quietly behaving as a singleton. Reach scoped services through container.forRequest(ctx) or @Params(Service(token)), or createScope() for a scope of your own.

  • BREAKING — DI registration closes at boot (M125, 0.11.0, @setu-ts/di-plugin). DiPlugin closes the container after the last bootstrap hook; register on it afterwards throws ContainerClosedError. Register providers during register(), onInit or onBootstrap. A scope may register a token its parent chain does not have — it lives as long as the scope, a singleton lifetime there being stored as scoped — but no longer shadow one it has — register the replacement on the root, or use overrideProvider in tests.

  • BREAKING — factories receive their dependencies (M125, 0.11.0, @setu-ts/common). FactoryProvider.useFactory is (...deps: never[]) => T, called with the resolved FactoryProvider.inject entries in order. A factory that closed over the container to resolve its own dependencies still works, but boot validation cannot see them; declare inject instead.

  • BREAKING — route patterns outside the route grammar are refused (#518, 0.11.0, @setu-ts/kernel, @setu-ts/graphql-plugin). Route patterns are static segments, :name parameters, a final optional :name?, * segments and a final prefix*. Patterns outside that grammar now throw at registration, naming the part that is not supported: a Hono regular-expression parameter (:name{…}); an empty segment (/api//:id); a * inside a segment that is not the final one (/v*/admin); a parameter name that is not a word (\w+, as LinearRouter read it: :user-id) or is __proto__, which would hit the prototype setter of ctx.params rather than become a parameter; an optional parameter that is not last (/p/:id?/edit); and ? outside a final optional parameter. None was documented, and each routed differently under TrieRouter than under LinearRouter — a :name{…} constraint that can match / (/files/:path{.+}/download) also took time quadratic in the path length, about 300 ms for an 8 KB path. Use :name or * and validate the value in the handler or with @setu-ts/validation-plugin. router.group() now joins its prefix and a path with exactly one /, as its documentation said, so group('/api/', r => r.get('/:id')) registers /api/:id; @setu-ts/graphql-plugin joins a path ending in / to its SSE stream and WebSocket ws paths the same way.

  • BREAKING — IResponse gains setHeaders (#518, 0.11.0, @setu-ts/common). A required member that sets several headers at once, as header() per entry. An application class or a test fake typed as IResponse must add it — a loop over its own header() is enough; 23 fakes in this repository’s own tests needed exactly that. createTestContext from @setu-ts/testing already implements it. Passing the same frozen object on every request lets the kernel validate it once and copy it into each response in one step; HttpSecurityPlugin’s security headers now do.

  • Request identity fields are inherited on framework-created requests (#518, 0.11.0, @setu-ts/common, @setu-ts/kernel, @setu-ts/runtime, @setu-ts/testing). The one-write guard on user, tenant and locale is now installed once on the request class’s prototype (installRequestIdentityGuard) for served, inject() and createTestContext requests, instead of on every request object. On those requests Object.keys(ctx.request) and JSON.stringify(ctx.request) no longer list the three fields; reading and writing them, the one-write rule and the replace* helpers are unchanged. A request object an application builds itself keeps the per-instance guard and its enumerable fields.

  • BREAKING — IContainer gains forRequest and dispose (M125, 0.11.0, @setu-ts/common). Required members, so an application’s own IContainer implementation must add them; OverridingContainer in @setu-ts/testing forwards both. ProviderOptions is now generic, ProviderOptions<T = unknown>.

  • BREAKING — ScopeManager removed (M125, 0.11.0, @setu-ts/di-plugin). The compiled graph replaced it, and nothing outside the package imported it. There is no replacement export.

  • BREAKING — decorated controllers are DI singletons (M125, 0.11.0, @setu-ts/decorator-plugin). Controllers were always built once, at registration; they are now registered as singleton whatever DiPlugin’s defaultScope. A controller constructor that reaches a scoped service fails the boot; take the service as @Params(Service(token)) on the handler instead.

  • A queue-less NATS subscription is shared (V10-13, M119a, 0.11.0, @setu-ts/messaging-plugin). A subscribe() with no queue — respond() with no queue included — no longer gives every replica every message on a per-process consumer. It now uses ONE durable JetStream consumer per topic, named <defaultQueue>.<topic> (default prefix messaging-consumers) and shared by every replica: each message is delivered to one replica, and a restart re-attaches to the existing consumer and resumes from its acknowledgement floor. A group created for the first time starts at messages published after it, as a new Kafka group does. To keep every replica receiving every message, pass a queue that is stable and unique per replica (for example the pod name); a queue made from a random per-start value would leave a durable consumer behind on every start. An explicit queue is unchanged.

  • Redis Streams publishes gain a 15 s default bound (V10-21, M119a, 0.11.0, @setu-ts/messaging-plugin). The arm joins the bounded ones: a publish now rejects at publishTimeoutMs (default 15000 ms, 0 unbounded) with PublishTimeoutError instead of holding indefinitely against a paused Redis. Set publishTimeoutMs: 0 to restore the previous unbounded publish wait; consumer reads are unaffected.

  • The NATS publish bound moves from the client’s fixed 5 s to publishTimeoutMs (M119a, 0.11.0, @setu-ts/messaging-plugin). Before this release an unresponsive NATS server rejected a publish after the client’s own 5 s, with a bare Error the caller could not test for. The bound is now publishTimeoutMs (default 15000 ms, 0 unbounded, refused out of range), passed to the client as its own publish timeout and held by the framework’s deadline as well, and an expired publish rejects with PublishTimeoutError.

  • The mail indicator is out of readiness by default (V10-49, M119b, 0.11.0, @setu-ts/mail-plugin). MailPlugin now registers its indicator with readiness: false. Its status mapping is unchanged — an unreachable mail backend still reads down, so /health reports the outage (503) and alerts still fire — but /ready no longer counts it and stays 200. HealthPlugin({ readiness: { include: ['mail'] } }) restores the previous behaviour.

  • The gRPC health bridge reads readiness (M119b, 0.11.0, @setu-ts/grpc-plugin). grpc.health.v1.Health/Check for the whole server now maps the readiness report (checkReady()) instead of the full health report, so it agrees with /ready; an indicator that is out of readiness (the mail indicator, by default) no longer turns the server NOT_SERVING.

  • An unanswered queue reachability check is up with reachable: 'unknown' (M119b, 0.11.0, @setu-ts/queue-plugin). For EVERY queue adapter, a reachability check that does not answer within its 2 s bound now reports up with reachable: 'unknown' and no depth counts, instead of down; a check that answers false still reads down at once.

  • The CSRF check accepts Origin: null with Sec-Fetch-Site: same-origin (V10-1, M119c, 0.11.0, @setu-ts/http-security-plugin). Under the plugin’s default Referrer-Policy: no-referrer a browser sends Origin: null on a same-origin HTML form POST, which was refused 403. The pair is now accepted (the Fetch Metadata rule Go and Hono use); null with any other Sec-Fetch-Site value, or without it, is still refused. csrf: { acceptNullOriginSameOrigin: false } restores the earlier refusal.

  • Generated Node test scripts force exit (V10-29, M119c, 0.11.0, @setu-ts/cli). test and test:e2e pass --test-force-exit, so a test whose application failed to start fails promptly instead of hanging CI to its timeout.

  • setu add cache|scheduler|realtime-backplane write the Redis arm in a Redis-transport workspace (M119c, 0.11.0, @setu-ts/cli). The registration reads the workspace’s REDIS_URL and ioredis is installed; elsewhere the in-memory default is unchanged.

  • setu agents --dry-run reports what the real run will do (V10-26, M119h, #506, 0.11.0, @setu-ts/cli). A brief whose spliced bytes equal the file is unchanged: the real run prints that and no longer rewrites the file, and the dry run prints would create / would update / unchanged through the same reporter as every other command. Both modes check every changed brief for writability before the first byte and refuse the whole run when one target is unwritable — <path>: not writable by you; nothing was written, or WOULD REFUSE: <path> is not writable by you in a dry run — exiting 1 with nothing written. A temp-file open refused with EACCES/EPERM now reports <path>: permission denied creating a file in <directory> instead of naming the temp file, for every CLI write.

  • setu generate … --module names a module that is not activated (V10-18, M119h, #506, 0.11.0, @setu-ts/cli). The scanner admits a module from its files, never from its activation, so a module whose src/modules/index.ts entry was removed accepted new artifacts and left them unreachable without a word. After the write (and after the plan in --dry-run), the command reads the activation barrel and, when the masked code does not name the module’s registrar or MODULES entry, prints the exact import and registration line to add. When the module is active but setu.config.ts does not reference the barrel’s export, it prints the config lines instead. A comment-only mention does not count; the files are still written and the exit status is unchanged.

  • A workspace ownership refusal names a remedy for its reason (V10-27, M119h, #506, 0.11.0, @setu-ts/cli). A symlink says to replace it with the directory it points at or to run setu agents from its real path (named in parentheses when resolvable); a non-directory member path says to fix the workspace layout; only a directory owned by another user, or one whose ownership cannot be checked, still says to run in a workspace you own. From inside a symlinked member, the verified logical path is walked and refused in every mode rather than writing into the link target.

Security

  • Clear two High dependency advisories (#496). Remove Zod 3 from the workspace and require Zod >=4.6.6 <5 for GHSA-mrhx-c7xc-m3w5. Override @azure/msal-node to ^7.1.0 for GHSA-xcx6-jx8p-x3h5, because @azure/identity still requests the vulnerable 6.x line. Applications using the Azure secrets provider should apply the same override until Azure ships an identity release accepting the fix; workspace overrides do not propagate to consumers.

  • Redaction cache, single-read redaction and log-call containment (#513, 0.11.0, @setu-ts/common, @setu-ts/logger-plugin, @setu-ts/metrics-plugin). From the security audit of #512. The redaction matcher’s path cache now skips paths longer than 256 characters, so field names taken from request bodies can no longer pin memory (600 requests with 900 KB names held ~1 GiB). Redaction now reads each property once and returns that snapshot: a getter returning a different object on a later read (during traversal, or when the logger serializes the result) could put a secret redaction never checked into the output. Log calls and child() no longer throw on any transport: metadata that throws while being read is replaced by [unserializable metadata], such bindings are dropped, and a custom sink that throws loses that record instead of failing the request, and its first failure is reported once on stderr, by error class only. ConsoleLogger’s constructor is back to (runtime, options?), and metric label values are read once per observation, for both the series key and the stored labels.

Fixed

  • Validate forwarded client addresses (#529, 0.11.0, @setu-ts/http-security-plugin). IP security validates every examined X-Forwarded-For or custom ipHeader entry and strips numeric ports from IPv4 and bracketed IPv6 addresses. An invalid entry stops resolution with no client or forwarded origin, rather than publishing arbitrary text or falling back to the socket peer. Both header families share one address validator and canonical IPv6 form.

  • Full-stack workspace libraries on Node and Bun stay external (#528, @setu-ts/cli). The generated vite.config.ts read a library’s name only from deno.json or deno.jsonc, so a Node or Bun library (named in package.json) was bundled as a second copy whose module state the server did not share. It now reads package.json too. In an existing project, add 'package.json' to the file list in its readLibraryManifest.

  • setu add on Deno writes a call the project’s own deno fmt keeps (M117, #525, 0.11.0, @setu-ts/cli). The provider call setu add inserts into setu.config.ts was written on one line however long its options were — a Redis connection read, a nested options: { … }, a seam merged with stray spaces — so a Deno project failed deno fmt --check after its first setu add messaging --broker redis, queue --queue redis, or Redis-backed add in a workspace member. The call is now laid out for the generated 100-column width, breaking only what does not fit. npm targets were already formatted by Prettier and are unchanged.

  • Full-stack generator wiring and page collisions (#524, 0.11.0, @setu-ts/cli). Route, controller and module generation refuses kernel routes that would shadow existing React Router pages, naming the page file and advising a separate API module. setu add events, cqrs and queue wire managed handler barrels into the starter’s option arms, merging queue processors and transport options in either add order. Unwired queue advice now names the starter’s queue arm instead of a duplicate QueuePlugin registration. Jobs in a queue-owning starter refuse before writing until setu add queue installs the package and wires its processor barrel.

  • Route matching with Hono’s TrieRouter (#518, 0.11.0, @setu-ts/kernel). The kernel now matches routes with Hono’s TrieRouter instead of LinearRouter, which re-parsed every route pattern on every request (13-20% of request CPU on Bun). TrieRouter returns the same full candidate set and never throws on overlapping parameter patterns, so the kernel’s static-over-parameter and registration-order tie-break is unchanged. Trailing slashes behave as they did under LinearRouter: a request may carry one extra trailing slash (/users/ reaches /users), a static route registered with a trailing slash answers only that spelling (/reports does not reach /reports/), and a parameter route’s trailing slash is not significant (/users/:id/ answers /users/42). For a slash-terminated request the kernel ranks the routes matching both spellings together, so a wildcard never wins over the specific route. Route parameters reach Hono under the kernel’s own names and are mapped back, so a parameter named after an Object.prototype member (:constructor, :toString) routes like any other, including after a * segment. A pattern written without a leading slash (admin) is the same route as /admin, and registering both is refused as a duplicate. Measured on Bun: the bare kernel matched faster in five of six alternating passes.

  • Per-request work on the request path (#518, 0.11.0, @setu-ts/kernel, @setu-ts/common, @setu-ts/runtime, @setu-ts/metrics-plugin, @setu-ts/http-security-plugin). From a Bun CPU profile of the REST scaffold, each change measured alone first: the middleware chain runs without an async wrapper per stage when diagnostics are off (koa-compose’s shape); the request identity guard is installed once per request class rather than per request; log redaction fills its copy without per-call scaffolding and walks only into nested values; a request path without % skips decodeURIComponent; a request-scoped service registry allocates storage only when something registers into it; a metric with distinct label names validates and keys an observation in one pass, and a histogram counts in a typed array; the default security headers are copied into each response in one step. Behaviour is unchanged apart from the two entries under Changed.

  • Histogram boundaries: repeats, Infinity and NaN (#518, 0.11.0, @setu-ts/metrics-plugin). A Histogram configured with the same boundary twice counted each observation once per repeat in that bucket; it is now one le bucket, counted once, as Prometheus expects. A configured Infinity was counted a second time beside the +Inf bucket every histogram has; it is now that bucket. A NaN boundary, which corrupted the cumulative counts around it, is refused at construction. buckets still returns the configured list.

  • Logging and metrics hot paths (#512, 0.11.0, @setu-ts/common, @setu-ts/logger-plugin, @setu-ts/metrics-plugin). Compile literal and suffix field matchers and specificity once, bound matcher path results to 1,024 entries per instance, and memoize recursive wildcard states only for multiple globstars. Console children reuse root redaction compilation. Metrics cache byte-identical label keys by configured label values, retaining only observed combinations.

  • Realtime subscriber failures (#511, 0.11.0, @setu-ts/realtime-backplane-plugin, @setu-ts/cloudflare-plugin). Async subscriber rejections are captured and reported without an unhandled rejection; synchronous throws reach the logger by default. Repeated failures debug after the first warning, recovery logs once at info, and describeError collapses CR/LF in diagnostics. Transport handlerErrors remains bounded to the latest 100 failures in either mode.

  • Long-lived instance growth (#511, 0.11.0, @setu-ts/service-discovery-plugin, @setu-ts/feature-flags-plugin, @setu-ts/sse-plugin). Amortised discovery sweeps drop fully expired ejection state without shortening an active ejection. LaunchDarkly evicts least recently used snapshots; an evicted context returns fallback while refilling. SSE membership is reclaimed when the last member leaves, held handles continue working by name, and disconnect cleanup visits only joined channels. peek(name) now returns undefined for an empty channel, and channelCount counts channels with members.

  • Expired in-memory auth state is reclaimed (#509, 0.11.0, @setu-ts/auth-plugin). MemoryRateLimitStore.increment() sweeps expired per-key windows without evicting live limits; MemoryRefreshTokenStore.save() and rotate() sweep expired records even when they are never read. Unexpired revoked refresh records remain available for rotation reuse detection. Both use doubling thresholds.

  • HTTP method metrics have bounded cardinality (#509, 0.11.0, @setu-ts/metrics-plugin). Unknown method labels now use _OTHER on success and error paths. The nine known uppercase HTTP methods keep their names; comparison is case-sensitive. Consumers querying invented method labels should query _OTHER instead.

  • Subscriber error histories are bounded (#509, 0.11.0, @setu-ts/realtime-backplane-plugin). Memory, messaging, and Redis transports retain the most recent 100 handlerErrors, oldest first.

  • Logged mail has bounded retention (#509, 0.11.0, @setu-ts/mail-plugin). LogProvider.messages keeps the most recent 1,000 messages in send order; its optional sink still receives every send.

  • Idle diagnostics session budgets are reclaimed (#509, 0.11.0, @setu-ts/diagnostics-plugin). Authenticated promotion sweeps fully refilled token buckets at doubling thresholds starting at 64; drained and partially refilled buckets retain their limits.

  • Room lookup allocation is explicit (#509, 0.11.0, @setu-ts/websocket-plugin). Document that RoomRegistry.get(name) creates on lookup and never-joined rooms are reclaimed on disconnection; use peek() for client-supplied room-name reads.

  • A singleton no longer captures a scoped service, and @Optional dependencies honour the resolving scope (M125, 0.11.0, @setu-ts/di-plugin, @setu-ts/decorator-plugin). A scope shared its parent’s singleton cache, so a singleton first resolved inside one scope kept that scope’s instances for the life of the application, and an @Optional class resolved its dependencies from the container it was registered on, never from the scope resolving it. The first is now refused at boot (see the BREAKING entry above); the second registers as a class provider with optional inject entries.

  • Documentation that matches the code (M119i, #528). The upgrade guide, guides, READMEs, agent cards and PUBLIC_API.md now state what the code does: links into the released 0.10.0 sections (V10-7), generated Kubernetes probes (V10-8), swagger-ui-dist on Deno (V10-15), the Workers full-stack allowJs repair (V10-16), which 4xx paths log (V10-39), the module-scoped service token <module>.<name>-service (V10-38), three missing 0.10.0 upgrade entries (V10-46, V10-47, V10-48), the service-discovery RetryPolicy example (V10-12), the request logger’s unscrubbed path (V10-40), the agent cards’ setu add wording (V10-43), React Router 8 (V10-56), unrepresentable Zod types (V10-57), the worker-pool overshoot (V9-29) and the resilience timeout. Every package README is now compiled by the fence gate, and absolute repository links and links to a heading a release renames are checked. The folded documentation adds M124 auth and M121 starter card guidance, scaffold-first development and getting-started paths, module-identity explanations, npm plugin publishing, a learning path and source walkthroughs for every example with app README pointers, and a homepage create command and current package count.

  • Prisma input refusals are named 400s (M119e, 0.11.0, @setu-ts/database-plugin). Unknown payload arguments become UnknownPayloadFieldError; other validation errors and known-request P2007 errors become InvalidFieldValueError. Conditional writes use the same mapping, replacing their previous 501. Prisma refusal causes are fresh errors with fixed messages naming only the original name and code; query text and payload values are discarded.

  • D1 payload refusals are named 400s (M119e, 0.11.0, @setu-ts/cloudflare-plugin). Invalid payload identifiers and SQLite unknown-column errors naming payload fields become UnknownPayloadFieldError; STRICT-table type refusals become InvalidFieldValueError.

  • PostgreSQL refused values are 400s through Drizzle (M119e, 0.11.0, @setu-ts/database-plugin). Malformed keys and mistyped payload values become InvalidFieldValueError with a scrubbed cause; existing HTTP-hinted errors retain their identity.

  • Missing configured Drizzle keys fail connect (M119e, 0.11.0, @setu-ts/database-plugin). Registered tables with explicit incorrect primary keys throw MissingKeyColumnError. An id-less table registered for the typed query builder remains valid until repository creation.

  • Unhandled Drizzle pool errors no longer exit the process (M119e, 0.11.0, @setu-ts/database-plugin). The plugin attaches an idle-error reporting listener only when the app’s pool has none, using describeError and the optional logger’s warn; without a logger the listener still attaches.

  • A generated full-stack app can run behind a TLS-terminating proxy (V10-17, V10-41, M119c, 0.11.0, @setu-ts/cli). Every React Router action answered 400 because nothing configured IP security. The template now reads TRUSTED_PROXIES (comma-separated CIDRs; empty leaves it off), and the react-router-plugin README and docs/deployment.md name the failure.

  • The CLI installs swagger-ui-dist@5.33.3 exactly (V10-36, M119c, 0.11.0, @setu-ts/cli). A ^5 range resolved a newer release whose files fail the OpenAPI plugin’s SRI hashes, so a { baseUrl } docs page rendered blank.

  • setu add telemetry installs its OpenTelemetry peers (V10-6, M119c, 0.11.0, @setu-ts/cli). The printed console-exporter registration crashed at start; the four peers are now recorded, with @opentelemetry/api held at ~1.9.0 because the 2.x SDK line refuses 1.10.

  • Kubernetes discovery outside a pod names token (V10-44, M119c, 0.11.0, @setu-ts/service-discovery-plugin). A missing service-account token file now gives one startup warning and a DiscoveryUnavailableError naming token and apiServer, instead of a silent down; the README no longer lists token as Consul-only. Only a missing file is reported that way; any other token read failure inside a pod still reaches the caller as a temporary outage.

  • Documentation (V10-3, V10-14, M119c, 0.11.0). The http-security CORS + CSRF example allows the x-csrf-token header it requires; the session-plugin README says the bearer exemption also skips the session commit on a csrf.excluded path and that exemptBearer: false restores it.

  • A malformed or oversized Forwarded header no longer erases the client behind an appending proxy (V10-19, M119d, 0.11.0, @setu-ts/http-security-plugin). parseForwarded refused the whole field, so with forwardedHeader: 'forwarded' behind a proxy that appends its element a client sending one malformed element, more than 64 elements or more than 8 KiB resolved to no client — and then to the shared rate-limit/audit fallback bucket. A malformed element is now a boundary: the elements to its right are still read and the trust walk stops at the boundary instead of crossing into client-written text. An over-bound field is re-read from the right within 8 KiB and 64 elements. A direct client keeps its socket peer, and a boundary with only trusted entries to its right still resolves no client. A trusted proxy that writes a comma inside a quoted value to the right of malformed input loses that element to the boundary and resolves no client (fail closed).

  • IPv4-mapped and non-canonical IPv6 clients have one normalized identity (V10-28, M119d, 0.11.0, @setu-ts/http-security-plugin). IP security published a forwarded client exactly as the proxy wrote it while the runtime un-mapped the socket peer, so one client could key the rate limiter and audit under two forms. Every published address is now normalized — IPv4-mapped becomes dotted IPv4, and IPv6 becomes canonical lowercase compressed — and trusted literals are normalized the same way before comparison. Every spelling of a mapped address — dotted tail, hexadecimal tail (::ffff:c633:6407) or uncompressed — is one identity. Clients arriving in IPv4-mapped (::ffff:198.51.100.7) or non-canonical IPv6 (2001:DB8::1) form therefore get a NEW rate-limit/audit key after upgrading; the limiter windows are short-lived.

  • Connection-error lines no longer read error: "" for an AggregateError (V10-63, M119g, 0.11.0, @setu-ts/common). createConnectionErrorReporter logged error.message only, and an ioredis AggregateError carries an empty message, so a two-address ECONNREFUSED outage produced error: "" in every consumer (cache, idempotency, auth rate-limit store, messaging Redis Streams, queue, realtime backplane, scheduler). The line is now rendered by describeError, so it carries each inner message and its code=… classifiers; the de-dup key is that rendering, so a stable outage still reports once at warn and repeats at debug. The idempotency plugin’s claim/release/settle lines use the same renderer.

  • A sign-in provider outage names its reason at warn, once per state change (V10-62, M119g, 0.11.0, @setu-ts/auth-plugin). A login route that answered 503 provider-unavailable wrote no log line at any level, so a Keycloak issuer mismatch (X74 b) was silent. resolveEndpoints now returns a fixed reason code (key-set-missing, discovery-endpoint-invalid, or the key set’s last discovery failure), and the routes report auth-plugin: signIn['<provider>'] provider unavailable (<reason>) at warn on a new or changed reason, at debug on a repeat, and warn again after a successful resolve. The logout path shares the reporter. The outside-issuer bearer path reports auth-plugin: issuer '<name>' key-set refresh failed (<reason>) the same way; token rejections stay at debug.

  • A service-discovery cold outage is answered as a named 503 (V10-34, M119g, #500, 0.11.0, @setu-ts/service-discovery-plugin). With nothing cached, a backend failure threw an unbranded DiscoveryUnavailableError, which errorHandler normalized to a masked 500. That instance now carries a 503 Service Unavailable status hint with the fixed detail “Service discovery is temporarily unavailable.”; configuration failures stay unbranded and are still answered as 500.

  • Kafka watermark capture on a new topic (#493, @setu-ts/messaging-plugin). When a new consumer group subscribed to a topic created moments earlier, the read of that topic’s offsets could fail with NOT_LEADER_OR_FOLLOWER before its partitions had leaders. The broker logged a warning and skipped the capture, so messages published after subscribe() resolved but before the group’s first join were not delivered. The read now retries errors kafkajs marks retriable, within the KafkaOptions.retry budget, and stops on disconnect(); a non-retriable error or an exhausted budget behaves as before.

  • A queue-less NATS subscription no longer replays the stream on every start, and leaves no consumer behind (V10-13, M119a, 0.11.0, @setu-ts/messaging-plugin). Before this release a subscribe() with no queue created a durable consumer named messaging-<uuid> per process with the server’s all delivery policy, so every start replayed the whole stream and left another durable consumer behind — measured at an RPC responder’s call count 6 → 14 → 24 across restarts, 16 durable consumers after four boots. The subscription now shares one durable consumer per topic with the other replicas (the Changed entry above), and the per-instance request() reply inbox is an ephemeral consumer that is deleted on shutdown and re-created if the server removes it. On connect() the broker also sets a 5-minute inactive_threshold on the consumers earlier releases leaked — durable consumers named exactly messaging-<uuid> or rr_2einbox_2e<uuid> whose recorded setu.queue metadata is absent or matches — so the server removes them once nothing reads from them; a replica still running an older version keeps its consumer while it reads.

  • A fresh Kafka consumer group no longer delivers its first message twice (V10-2, M119a, 0.11.0, @setu-ts/messaging-plugin). The first-join seek rewound every assigned partition whose committed offset was still -1, including one already delivered, so a message published after subscribe() resolved but before the first join’s seek arrived twice (4 of 8 runs in the v0.10.0 run). Each delivery now records the first offset it saw for its partition, and the first join skips a partition already delivered from its start point — seeking only one that was never delivered, or whose first delivery was past the minimum start offset — so the delivery is not repeated when the consumer happened to start at the right offset.

  • A Redis Streams publish() no longer hangs against a paused Redis (V10-21, M119a, 0.11.0, @setu-ts/messaging-plugin). The publish had no time bound and held the call indefinitely — measured 14–21 s against a paused Redis — while every other arm failed at its bound. The publish’s XADD is now wrapped in the publish deadline and rejects with PublishTimeoutError at publishTimeoutMs (see Added and Changed above); consumer reads keep their own bounds and are unaffected.

  • Publish timeouts reject with PublishTimeoutError instead of a bare Error (V10-30, M119a, 0.11.0, @setu-ts/messaging-plugin). The Kafka, Pub/Sub and RabbitMQ arms’ publish timeouts rejected with a bare Error whose text named the bound but which a caller could not test for. They now reject with the exported PublishTimeoutError (see Added); the message texts for those three arms are unchanged, so a caller matching on the text keeps working.

  • The README’s outbox store-write-failing claim is corrected (V10-5, M119a, 0.11.0, @setu-ts/messaging-plugin). The README said only a relay paused inside its publish reports the store-write-failing health reason; a pause at a settlement write (markSent or markFailure) that outlives storeTimeoutMs does too, and the store’s late answer is still recorded. The behaviour is unchanged; the health row now reads “this instance’s last sweep ended on a store call that was rejected or did not answer within storeTimeoutMs”.

  • V10-49: a shared mail outage no longer takes every replica out of rotation at once (M119b, 0.11.0, @setu-ts/mail-plugin, @setu-ts/health-plugin). One SMTP outage stays visible on /health (503, alerting) without failing /ready on every replica at once, through the mail indicator’s new readiness membership; the Changed entry above carries the default and the restore lever.

  • V10-20: a paused Redis no longer takes the replica out of rotation through the realtime-backplane indicator (M119b, 0.11.0, @setu-ts/realtime-backplane-plugin). The indicator’s reachability check is now cached for 5 s and bounded at 2 s; a check that does not answer reports reachable: 'unknown' and the status stays up (previously the unbounded Redis PING outlived the health indicator’s 5 s deadline and read down).

  • V10-22: the RabbitMQ queue indicator no longer reports reachable: true while the broker is paused (M119b, 0.11.0, @setu-ts/queue-plugin). The adapter’s reachability check now opens and closes a throwaway AMQP channel on the connection instead of reading a supervisor flag: a refused round trip answers false, and a hung one resolves reachable: 'unknown' through the queue probe’s 2 s bound.

  • V10-31: a paused database no longer reads down after two stacked probes (M119b, 0.11.0, @setu-ts/database-plugin). The migration readiness wrapper now skips the ledger read unless the connection result is not down and its data.reachable is not false or 'unknown'; otherwise it returns the result unchanged with data.migrations.state: 'unknown' added (an adapter with no reachability check is still checked). A paused database therefore reads degraded within one ~2 s bound instead of down after ~4 s.

  • setu new --runtime cloudflare-workers refuses a name wrangler refuses (V10-9, M119h, #506, 0.11.0, @setu-ts/cli). The project name is written into wrangler.toml, and wrangler accepts only lowercase letters, digits and -, at most 63 characters; setu new a.b --runtime cloudflare-workers therefore scaffolded a Worker that wrangler refused to build or run. The name is now checked before any write (exit 2, Invalid Workers project name: "<raw>". wrangler accepts only lowercase letters, digits and -, at most 63 characters.), for the flag and the interactive answer alike. Other runtimes keep the path-segment rule. On every runtime, a name containing a control character (CR, LF, TAB, NUL, DEL) is now refused as typed rather than folded to -, as PROJECT_NAME_RULE already said.

  • A re-run of an interrupted setu adopt reads the manifest where the interruption left it (V10-10, M119h, #506, 0.11.0, @setu-ts/cli). Plugin and runtime detection read only the original project root, so a SIGKILL after the manifest moved to apps/<member>/ recorded healthProbes/metricsEndpoint: false, and a moved deno.json turned a Deno workspace into a Node one. Both probes now read the root and the planned member directory: plugins are their union, and a runtime marker counts when present in either. The member directory is read only when it is really inside the project, never through a link planted at apps/<member>. A fresh adoption is unchanged.

  • A generated module’s 404 no longer echoes the caller’s id (V10-35, M119h, #506, 0.11.0, @setu-ts/cli). GET/PATCH /<module>/:id rendered <Pascal> <id> was not found. into the Problem Details body and the warn log, so a caller-supplied value — including markup or control characters — was reflected back. The detail is now the fixed <Pascal> not found.; the request logger still records the path.

  • The generated injectable-service comment names the class-level @Inject(...) (V10-11, M119h, 0.11.0, @setu-ts/cli). It said the token goes “on a constructor parameter”, where the decorator does not apply; it now says consumers list the token in their class-level @Inject(...), one token per constructor argument in order.

  • Documentation links printed by the CLI name its own version tag (V10-42, M119h, #506, 0.11.0, @setu-ts/cli). The migration readiness link and the starter README link pointed at develop, which can move ahead of the installed release. Both now use blob/v<version>/….

0.10.0 — 2026-10-22

Fixed

  • CLI writes (M116, @setu-ts/cli): every Node CLI write (new, generate, add, agents, …) snapshots regular files read-only, then writes and restores through a fresh same-directory temp file renamed over the target. Existing inodes are never written through; static leaf symlinks, hard links, special files and prior content over 1 MiB are refused independently of ownership. An existing file’s temp is created owner-only (0o600), so its contents are never readable by others mid-write; the replacement then keeps the file’s owner and group and only its 0o777 permissions, never setuid, setgid or sticky bits. A file the user cannot write, or whose owner or group cannot be kept, is refused unchanged; ACLs and extended attributes are not carried over. Cleanup covers handled failures only: a process killed between temp creation and rename can leave a .<name>.setu-<hex>.tmp file. Specific to setu agents workspace writes on POSIX, every root and member target’s directory chain is checked for ownership and symlinks (git’s safe.directory rule; permission bits are not checked); untrusted chains are refused with nothing written, whether invoked from the root or a member, and --check skips the check. Missing directories are checked from their nearest existing ancestor; a missing member still exits 1 with <member>: not a project, writing nothing. The ownership check is skipped where uid/metadata are unavailable, including Windows; standalone projects are unchecked.

  • CLI file reads and member paths: manifest reads now refuse non-regular files, files over 1 MiB and files that are not valid UTF-8; runtime-marker checks use metadata without opening content. A POSIX path containing \ is no longer read as a workspace member path.

  • Documentation gaps (M113l). Corrected optional telemetry/storage peer installation, upgrade and runtime test instructions, locale error headers, worker hand-over sizing, repository/audit/health/OpenAPI/TOTP contracts, principal-based tenancy and construction-time config. Added inbound-webhook and backup/restore guidance, tier-C scheduler wiring, process-env secret loading, and Node/standalone deployment guidance. New examples compile and behavior tests pin the documented compositions; no package source changed.

  • An over-limit body on a validated route answers 413 (M114, @setu-ts/validation-plugin). A body the runtime refuses for size (RuntimePlugin({ maxBodyBytes }) set) now keeps its 413 on a route carrying validateBody — previously the validation middleware’s own body read masked the refusal and the route answered 400. The 413 applies to JSON, urlencoded and multipart bodies alike.

  • A decorator barrel the config does not consume is reported (V9-8, M113h, #477, 0.10.0, @setu-ts/cli). Service and controller comments and the decorator barrel headers name the inline DecoratorPlugin form and a starter’s decorators arm. After writing a barrel, setu generate prints the exact DecoratorPlugin or starter decorators lines and the import when setu.config.ts does not reference the barrel’s export.

  • Every NATS scaffold carries a stream subject set (V9-17, M113h, #477, 0.10.0, @setu-ts/cli). Standalone and workspace scaffolds emit streamSubjects: ['app.>', 'rr.req.app.>', 'rr.inbox.>'], so every published topic starts with app.. The generated README and Compose note name the scope and the RealtimeBackplanePlugin topic: 'app.realtime' that the 'messaging' transport needs.

  • setu adopt records declared probe support (V9-38, M113h, #477, 0.10.0, @setu-ts/cli). The member record in setu.workspace.json gains healthProbes and metricsEndpoint from the project’s own health-plugin/metrics-plugin or starter dependency, so its Kubernetes probes and Prometheus annotations follow declared support and survive later re-renders.

  • setu adopt moves the configured dotenv pair (V9-39, M113h, #477, 0.10.0, @setu-ts/cli). A single-segment envFilePath and its .example move into the member, and a multi-segment path stays put with one line saying to move it by hand and why the root ignore rule cannot cover it. The moved dotenv file also gains apps/<member>/.gitignore with /<name>, because a root rule such as /.env is anchored to the repository root and would otherwise stop matching after the move; it is written through the conversion’s transaction, so an interrupted adopt rolls it back. A custom name the generic .env/.env.* rules do not cover (for example local.env) prints one line naming it by its path from the workspace root, the form the root .dockerignore needs. The workspace .gitignore gains .env, .env.* and !.env.example; the .dockerignore gains **/.env, **/.env.* and !**/.env.example, and its comment states that a custom --env-file name is not covered. A conversion that fails part-way is now rolled back, so no moved file is left under the member.

  • A Workers full-stack scaffold type-checks and builds first (V9-9, M113h, #477, 0.10.0, @setu-ts/cli). The Workers tsconfig.json names node beside @cloudflare/workers-types and the manifest adds @types/node, so the emitted vite.config.ts no longer fails its own check. allowJs reads the compiled server build, and a Workers full-stack check script is npm run build && tsc --noEmit so that build exists first; every full-stack manifest pins TypeScript ~6.0.3.

  • The React Router typegen directory is generated output (V9-11, M113h, #477, 0.10.0, @setu-ts/cli). .react-router/ joins the build output in the standalone .gitignore, ESLint ignores, .prettierignore and the Deno fmt/lint excludes, and the workspace root .gitignore ignores it for every member.

  • setu adopt rewrites the entry the CLI emits (V9-42, M113h, #477, 0.10.0, @setu-ts/cli). The port rewrite matches the current port: Number(runtime.env.PORT ?? '3000') form as well as the legacy literal 3000, inserting the discovery import after the createApp import, so an adopted member binds SERVICE_PORT instead of printing a hand-edit message for the form the CLI itself writes. The config import is found by scanning lines rather than by a import { [^}]+ } from … regex, which backtracked quadratically on project-supplied text (80k repetitions of import { took ~20 s).

  • setu add websocket declares the ws peer on Node (V9-67, M113h, #477, 0.10.0, @setu-ts/cli, @setu-ts/runtime). ws is written into package.json at ^8.18.0, also when @setu-ts/websocket-plugin is already present, and an existing entry is never overwritten. HttpAdapterOptions.reportError lets a failed Node WebSocket handshake report once per distinct message through the registered logger, or the runtime sink when none is registered.

  • The kernel announces it is listening and scaffolds log requests (V9-81, M113h, #477, 0.10.0, @setu-ts/kernel, @setu-ts/cli). After a successful listen the kernel logs Server listening with the port and hostname, or Server listening on an ephemeral port for port 0, and logs nothing without a logger. New scaffolds enable request logging and exclude /live, /ready, /health and /metrics, so probe traffic writes no line.

  • Long scheduler waits no longer fire early (V9-63, M113m, @setu-ts/scheduler-plugin). delay, every, cron and retry backoff now wait in chunks of at most 2147483647 ms and check the wall clock on each wake, so a job never runs before its due time. Previously, Node, Deno and Bun clamped an over-limit timer to about 1 ms: delay(30 days) fired at once, and monthly cron jobs could fire early. Delays and intervals have no upper limit. Disconnect, remove and pause cancel retry backoff without another attempt. A one-shot that already succeeded or exhausted its retries is removed even while paused and is not re-run; one paused mid-retry stays registered and resumes from attempt 1 using the original delay. Recurring jobs resume at the next cron/interval boundary. Cancelled fires settle diagnostics with false (counted in failed, never succeeded). Disconnect releases one-shot slots regardless of outcome, and an old fire cannot remove or release a replacement registered under the same name. Each release at disconnect is bounded by acquireTimeoutMs. resume() now rejects on a disconnected service, waits for a fire still settling before arming, and re-claims a one-shot slot released at disconnect.

  • JWT validity boundaries now honour fractional seconds (V9-70, M113m, @setu-ts/auth-plugin). First-party tokens are refused at and after exp, closing the window of up to one second after expiry; nbf is checked against the same unfloored time. Outside-issuer tokens are refused at and after exp + clockToleranceSec.

  • The SDK generator no longer emits source that shadows its own bindings (M113k, #476, 0.10.0, @setu-ts/sdk). A path parameter whose identifier is client, opts, request or encodeURIComponent is emitted with a _ prefix; before, client made the method call .request on a string and opts beside a query parameter emitted a duplicate parameter. Path parameters are positional, so no caller changes. An operation whose method name is client or encodeURIComponent is emitted as _client or _encodeURIComponent; before, client was a duplicate identifier and encodeURIComponent shadowed the global every generated path calls. A document component named OperationRequestOptions is refused like ClientResponse rather than shadowing the generated file’s import.

  • Idempotency documentation corrected and completed (V9-14, V9-16, M113k, 0.10.0, @setu-ts/idempotency-plugin, @setu-ts/http-security-plugin, @setu-ts/database-plugin, @setu-ts/sdk). The http-security-plugin CORS example now lists idempotency-key in allowedHeaders and idempotent-replayed in exposedHeaders, so a keyed cross-origin browser call preflights and the page can read Idempotent-Replayed. The tier-C table schema is documented in the database-plugin README; the idempotency README names the ioredis install step and the OptionalDependencyUnavailableError startup failure; its guarantee 2, M105 purge-race sentence and within ttlMs floor are corrected; a “From a browser” section documents the CORS options, the request parameter and a renamed key header. The SDK README’s Keyed retries names the per-call form and the Access-Control-Expose-Headers requirement.

  • setu add wires the barrel generated handlers live in (@setu-ts/cli, #472). In a functional project setu add events registered a bare EventsPlugin(), so every handler a later setu generate event-handler wrote into src/events/index.ts was subscribed by nothing and never ran, with no message saying so. setu add now registers the seam setu new emits, derived from the same registry: EventsPlugin({ handlers: EVENT_HANDLERS }), CqrsPlugin({ commandHandlers, queryHandlers }), and the indicator and metric barrels for health and metrics, each with its barrel import, and creates the empty barrel when the project has none. Class-based projects are unchanged: their handlers reach the plugins through DecoratorPlugin’s ingress barrel. A config that already imports the barrel, or that the command cannot rewrite, gets printed guidance naming the seam form and the import. Existing projects are not rewritten; re-running setu add on one whose call lacks the option names the option and the import to add by hand. A project composed through a starter keeps its config, and the guidance now names the arm value to pass (events: { handlers: EVENT_HANDLERS }), so the created barrel is reached.

  • setu add health on a Deno project says the start task needs --allow-sys (@setu-ts/cli, #472). Without it the built-in self check fails and /health reports down. The Deno permission notes (this one and storage’s --allow-write) are no longer printed on other targets, where they do not apply.

  • setu generate job points at the queue and scheduler registration options (@setu-ts/cli, #472). The generated job’s comment recommended calling queue.process() or scheduler.cron() inside another plugin’s register, where the capability may not be registered yet. It now shows QueuePlugin({ processors: [...] }) and SchedulerPlugin({ jobs: [...] }), both checked to type-check against the published packages.

  • The database plugin README’s Prisma example reads process.env['DATABASE_URL'] (@setu-ts/database-plugin). It read Deno.env.get(...), which does not exist on Node or Bun, the runtimes npm users run.

  • A value a client sends no longer makes a database error read as a timeout or an outage (M113g, 0.10.0, @setu-ts/database-plugin). PostgreSQL quotes a rejected value in its error message, and the node-postgres timeout messages were matched anywhere in the error chain, so a body sending "Query read timeout" to an integer column answered 504, and the two connect timeout messages answered 503. The three messages now match only as the whole message of an error that carries no SQLSTATE code anywhere in its chain. The pool-wait message (timeout exceeded when trying to connect) had the same flaw since 0.5.0. The SQLite unique-violation message is matched only as a message prefix too (UNIQUE constraint failed: or D1_ERROR: UNIQUE constraint failed:), so the same text no longer answers a false 409, and an unknown-column refusal from the Drizzle and Memory adapters now quotes the field name escaped and cut to 64 characters, so a field name cannot forge a log line. The M113f scrub of a Drizzle Failed query or a SQLSTATE class 22 error now removes the whole stack header even when a bound value contains a line shaped like a stack frame; before, that line and every later bound value survived into the logged stack.

  • The second node-postgres connect-timeout message answered a masked 500 (V9-34, M113g, 0.10.0, @setu-ts/database-plugin). pg-pool raises Connection terminated due to connection timeout when a new client’s connect times out — the common case during a pause, when the pool has room to open a connection — and the message was unclassified, so a call that never reached the database answered a masked 500. It is now classified unavailable and answered 503 beside timeout exceeded when trying to connect; under either message the query was never sent, so DatabaseUnavailableError’s “the operation did not happen, and may be retried” holds. A connect that does not complete inside the bound is also why the Drizzle probe reports degraded rather than down.

  • A paused relay’s late publish settlement (M113j, #469, 0.10.0, @setu-ts/messaging-plugin). A markSent or markFailure for a publish the relay already invoked now runs to its own storeTimeoutMs bound even when a pause has exhausted the sweep budget, and its answer is observed when it arrives after the bound: a markSent or markFailure that finds the row already sent records the duplicate overlap, a claim-lost answer is booked as an overlap, a late markFailure that made the row failed records poisoned without touching the sweep budget, and a late rejection is swallowed. A sweep that overran by a pause can outlive sweepDeadlineMs by at most one storeTimeoutMs, and the shutdown drain is bounded by the same amount. A publish the budget refused to invoke is no longer counted as a publish failure: the sweep ends deadline, writes nothing and reports no store-write-failing. The database outbox store’s unrecognized-status error no longer quotes the row id (@setu-ts/database-plugin), and the check:docs continued-line rule (P3) joins each command once instead of from every line.

  • Stale npm: specifiers in committed docs (M113j, #469, V9-22’s widened scope and V9-10’s npm:zod half). Every npm: import and every prose, table or comment claim that a package loads as npm:<pkg> — about 80 lines across ~20 files, plus one cli JSDoc — now names the bare package the loaders have used since M111a. The fence engine no longer rewrites npm: specifiers (the @hono/hono → hono alias rewrite stays), and check:docs refuses a stale one by name: P7 names a quoted npm: specifier in an import position, P8 any other npm:<pkg> reference (Deno add/install arguments and @setu-ts/@jsr specifiers are exempt).

  • A Redis client built without a reporter wrote its errors to the console (M113c). Since 0.8.0 the cache store, queue adapter, Redis Streams broker, realtime backplane, rate-limit store, scheduler lock and idempotency store attached their 'error' listener only when given a connectionErrorReporter. Each plugin passes one, but RedisRateLimitStore, which no plugin builds, and any store or adapter constructed directly got none, so ioredis printed [ioredis] Unhandled error event on every failed attempt. attachConnectionErrorReporter’s reporter is now optional: omitted, the listener drops events, as a reporter with no logger already does, and the health probe still reports the outage.

  • Kafka shutdown with sends in flight. With publishes abandoned at publishTimeoutMs during a broker pause, app.stop() waited on kafkajs’s pending-request drain (about 26 s) and the process never exited, because the abandoned sends kept retrying and reopened connections. The producer close is now bounded at 10 s, and a second close runs once those sends settle.

  • npm installation pointers (M111f). Auth, queue and GraphQL README Deno install commands now select npm explicitly. The website install commands resolve the npm packages.

  • @setu-ts/cli the devtool runtime refusal names the situation it is in (V9-5, M113i, #479, 0.10.0). The refusal takes a required context of 'scaffold' or 'enable', so no call site can keep the old wording by omission. The shared first sentence (why the devtool needs Deno) stays, but its last clause now reads “could never start its diagnostics connector” so it is correct in both contexts: the scaffold remedy keeps “Scaffold without —devtool, or create the project with —runtime deno.”, and the enable remedy says “This project targets ; the devtool can only be enabled on a Deno project.” with no “scaffold” wording.

  • @setu-ts/cli the CLI documentation states what rollback actually covers (V9-13, M113i, #479, 0.10.0). docs/cli.md, packages/cli/README.md and PUBLIC_API.md now state that a write is rolled back on SIGINT (Ctrl+C) and a caught error only — SIGTERM, SIGKILL (kill -9) and power loss all end the command with no rollback — and that re-running setu adopt converges while an interrupted new or generate leaves the files it wrote, which the next run’s refusal lists.

  • @setu-ts/cli setu add accepts the published packages its help promises (V9-40, M113i, #479, 0.10.0). The addable catalogue gains view → view-plugin, localization → localization-plugin and idempotency → idempotency-plugin (the full specifier works too), so the help line “The full specifier works too” is now true for the published framework packages. setu add diagnostics — and add @setu-ts/diagnostics-plugin — refuses with “@setu-ts/diagnostics-plugin is installed and wired by setu devtool enable.” and exits 2, because the devtool composition is setu devtool enable’s job and the bare package would produce a project that cannot start the connector. A refusal for any other @setu-ts/<name> specifier names that the package is not one this command installs and lists the catalogue, as before.

  • @setu-ts/cli a CLI-owned file that is a link or a special file no longer hangs setu generate (M113i, #479, 0.10.0). A barrel such as src/jobs/index.ts committed as a link to /dev/zero made g job and its --dry-run read forever. The CLI now reads an existing file it generates only when it is a regular file under 1 MiB, and refuses the path by name otherwise.

  • @setu-ts/cli the schematic tables match the source (V9-62, M113i, #479, 0.10.0). The packages/cli/README.md table now lists plugin as src/plugins/<name>.plugin.ts (it said src/plugins/<name>.ts), its controller row no longer says decorator-plugin is required and names the functional and class-based shapes instead, and the class-based src/ingress/<name>-<kind>.ingress.ts outputs of ws-route, job, command-handler, query-handler and event-handler are listed, with the functional job row naming the QueuePlugin wiring. The docs/cli.md and PUBLIC_API.md tables receive the same corrections.

  • @setu-ts/cli a functional setu generate job is wired when QueuePlugin is installed (V9-75, M113i, #479, 0.10.0). In a functional project with queue-plugin, g job now emits the module with its existing <SCREAMING>_JOB and <Pascal>JobData plus an exported create<Pascal>JobProcessor(services): QueueProcessorDefinition<<Pascal>JobData> factory and the managed src/jobs/index.ts barrel exporting QUEUE_PROCESSORS, and the wiring renders QueuePlugin({ processors: [...QUEUE_PROCESSORS] }) — QueuePlugin({ adapter: 'redis', url: …, processors: [...QUEUE_PROCESSORS] }) for a --queue redis microservice — so a job is no longer written and registered nowhere. After a write, setu generate reports an unconsumed jobs barrel with Pass the barrel to QueuePlugin: QueuePlugin({ processors: [...QUEUE_PROCESSORS] }) and the import line. Without queue-plugin the standalone function is emitted unchanged and the jobs seam is not scanned, so a project holding standalone src/jobs/*.job.ts files gets no skip report on setu generate; a class-based g job is refused without queue-plugin and, with it, writes the ingress seam, so a class-based project never holds a jobs barrel. On a Cloudflare Workers microservice the runtime swap removes queue-plugin, so the jobs seam contributes nothing there. The stale registry premise that QueuePluginOptions has no processors option is rewritten: a processor polls only its own job name, the same trade the class-based arm already makes.

Changed

  • The supported Node floor is a patched release (#489, 0.10.0, every package). Every manifest now declares engines.node ^22.23.4 || ^24.22.1 || >=26.12.1 (was >=22): the first release on each supported line carrying the Node.js security fixes of 2026-10-21. The compatibility workflow pins Node 24.22.1. npm warns rather than refuses on a mismatch unless engine-strict is set. Rebuild container images with --pull and rebuild every single executable (SEA): a SEA embeds the Node binary it was built with. Until patched, any outbound fetch to a server an attacker controls reaches the Brotli decoder flaw (CVE-2026-111497), because fetch decompresses br responses itself; see the upgrade guide. The lockfile is refreshed in the same change (issue #449).

  • Declared OpenAPI operation metadata now reaches the document (#487, 0.10.0, @setu-ts/common, @setu-ts/decorator-plugin, @setu-ts/openapi-plugin). Optional RouteSchema.operationId and RouteSchema.description carry programmatic declarations and @ApiOperation metadata. A declared id is used verbatim: @ApiOperation({ operationId: 'listUsers' }) now emits listUsers instead of get-users. Regenerating an SDK client renames the method and id-derived types: <Op>Args, <Op>Error, is<Op>Error, <Op>Error<status>Body, <Op>Body, <Op>Response<status> and <Op><Param>Param. Hoisted components also follow the id (for example GetUsersBody → ListUsersBody, only where a schema is hoisted), as does x-setu-unrepresentable[].at. Declared ids are reserved first, so an undeclared route’s get-users can become get-users-2 when another route declares get-users. Blank or duplicate included declared ids now fail start() with exported OperationIdError (reason: 'blank' | 'duplicate'); direct OpenApiGenerator.generate() calls throw it too. Excluded routes do not claim ids. A document with no declared ids or descriptions remains byte-identical. Distinct ids such as getUsers and get-users remain valid OpenAPI, but the SDK refuses their sanitized method-name collision with Duplicate operation name. Migration: check existing declarations, choose distinct nonblank ids, regenerate clients and update callers and references to renamed types, components and diagnostics. See docs/upgrading.md, ## 0.10.0.

  • Environment secrets use the config snapshot as a fallback (V9-77, #485, 0.10.0, @setu-ts/secrets-plugin). The default EnvProvider reads the environment first, then string values from CAPABILITIES.CONFIG when available. .env-only secrets loaded by ConfigPlugin now resolve, including SessionPlugin’s SESSION_SECRET and secrets in full-stack starter apps. As a result, a dotenv file present at runtime now satisfies SessionPlugin’s missing-secret startup refusal. Set SecretsPlugin({ options: { configFallback: false } }) to retain environment-only reads. Non-boolean values are refused at construction for the env provider; other providers ignore the option. Schema-stripped keys remain missing, and config-sourced ${NAME} references are expanded unless ConfigPlugin’s expandVariables is false. Environment strings are unchanged.

  • agents is a built-in CLI command (M116, @setu-ts/cli). Built-in verbs are matched before any plugin-registered command and always win, so a plugin command named agents is no longer reachable. No in-repo plugin registers one; rename the plugin command if yours does.

  • Validated body media types (M114, @setu-ts/validation-plugin, @setu-ts/decorator-plugin, @setu-ts/common). The first-party validation service requires application/json or any application/*+json media type, ignoring case and parameters; { forms: true } also accepts recognised form types. Unsupported types, including text/plain and an absent header, now answer 415 Unsupported Media Type before reading the body, through the application’s error responder. Migration: send Content-Type: application/json when posting JSON; a string-body fetch without headers sends text/plain and is now refused, and an empty body-validated request without the header answers 415 instead of 400. Direct ctx.request.json() reads are unchanged; replacement validation services own their media-type handling. Cookie-authenticated routes still need csrfMiddleware or csrfFormMiddleware.

  • Generated output: the functional module layout (M114, @setu-ts/cli). setu generate module <name> in the functional style now writes the whole feature into src/modules/<name>/ — <name>.routes.ts, <name>.service.ts, <name>.schemas.ts (with validation-plugin), <name>.test.ts, <name>.e2e.test.ts (socket targets only) and a CLI-owned index.ts — and writes nothing under src/controllers/ any more; the module registers through registerModules(app.router, app.services) from the CLI-owned src/modules/index.ts, which a functional scaffold calls right after registerGeneratedRoutes(...). Existing projects keep working: their src/controllers/<name>.routes.ts files are still wired by the controllers barrel, and only newly generated modules use the new layout. To adopt the layout for a pre-M114 functional module, move src/controllers/<name>.routes.ts into src/modules/<name>/, change its service import to ./<name>.service.ts, widen its registrar to register<Pascal>Routes(router: IRouterApi, services?: IServiceRegistry) (importing the type from @setu-ts/common; the regenerated index passes both), replace src/modules/<name>/index.ts with the registrar the CLI now generates — it imports register<Pascal>Routes from ./<name>.routes.ts and exports register<Pascal>Module(router: IRouterApi, services?: IServiceRegistry), which calls register<Pascal>Routes(router, services) — in the same change. The module scan admits a functional module only when its index.ts exports register<Pascal>Module, and the moved routes file no longer reaches the controllers barrel, so until the index is replaced the routes are not registered and every --module <name> command refuses the module. Then run any setu generate … --module <name> command (or add the import line and the registerModules(app.router, app.services); call, which setu generate module prints for a project whose setu.config.ts does not call it yet) so the activation barrel is regenerated.

  • Generated output: rest sample and generated tests (M114, @setu-ts/cli). The rest template’s sample is now emitted as a generated module at src/modules/greeting/ (routes stay /greetings and /greetings/:name), so a new project’s first files are exactly what g module produces. <name>.test.ts replaces <name>.service.test.ts: instead of asserting a stub, it drives the module in-process through createTestApp({ app: await createApp() }) and asserts a contract the generated code and the developer’s code both keep. setu new emits test/support/server.ts (boots createApp() on a free loopback port) and test/e2e/app.e2e.test.ts on every socket target, and g module adds a per-module <name>.e2e.test.ts over real HTTP. The test task now runs every *.test.ts except *.e2e.test.ts; a new test:e2e task runs only *.e2e.test.ts over real HTTP. A module generated on Cloudflare Workers emits no test (a Workers project’s test task permits an empty suite).

  • Generated output: a functional module’s index.ts no longer re-exports the service (M114, @setu-ts/cli). The CLI-owned src/modules/<name>/index.ts exports exactly one symbol, register<Pascal>Module(router, services?); the pre-M114 export * from './<name>.service.ts' is gone. Import the service from ./<name>.service.ts directly.

  • Generated output corrections (M113h, #477, 0.10.0, @setu-ts/cli, @setu-ts/runtime, @setu-ts/kernel). New scaffolds opt into request logging with /live, /ready, /health and /metrics excluded, and a booted application logs one Server listening record when a logger is registered. NATS scaffolds carry a stream subject set under the app. scope, every full-stack manifest pins TypeScript ~6.0.3, and a Workers full-stack project’s check script builds before it type-checks. React Router’s .react-router/ typegen and dotenv files are treated as generated/ignored output.

  • Migration ledger and readiness (M113o, 0.10.0, @setu-ts/database-plugin, @setu-ts/cli). Added createMigrationLedger, IMigrationLedger, MigrationLedgerOptions, MIGRATION_LEDGER_ENTITY, MigrationLedgerError, and DatabaseReadinessOptions. Optional readiness: { migrations: MIGRATION_NAMES } drains replicas with pending migrations and marks unreadable ledgers down; unknown names preserve status for rolling deploys. SQL schema remains application-owned. Generated runners record successful migrations and skip applied names. Breaking generated signature: replace runMigrations('up') with runMigrations('up', { database, ledger: createMigrationLedger(database) }); migrations may accept { database }. Socket entry points boot and stop the app; Workers emit only the callable runner. Because the runner imports @setu-ts/common, setu generate migration now also requires that package to be declared (every scaffold declares it), and setu add common adds it. A ledger created without an explicit entity uses its connection’s readiness.ledgerEntity, so the generated runner and the readiness check agree.

  • Discovery warm-cache readiness (M113o, 0.10.0, @setu-ts/service-discovery-plugin). A serving warm cache now reports up during backend outages instead of degraded, keeping replicas in rotation. data.degraded, reachable, and everResolved remain visible; never-resolved and unreachable stays down. Update status-based alerts to inspect the data fields.

  • Breaking: proxy client identity and public URLs (M113n, #473, 0.10.0, @setu-ts/runtime, @setu-ts/common, @setu-ts/http-security-plugin, @setu-ts/auth-plugin, @setu-ts/react-router-plugin). Node, Deno and Bun now populate IRequest.ip with the socket peer, normalizing IPv4-mapped IPv6. Unauthenticated default rate-limit keys move from one anonymous bucket to ip:<peer>. trustedProxies now checks that peer before accepting forwarded entries; a direct caller’s forged header is ignored. With IP security configured and a trusted proxy, React Router loaders/actions receive the public URL. Migration: list every real proxy or sidecar in trustedProxies, review rate-limit bucket sizing, and update code that expected internal loader URLs. Workers and plain fetch have no peer; proxyHops and leftmost selection retain their header-only semantics for the client IP and never publish a forwarded origin. A forwarded origin is believed only in trustedProxies mode with a trusted socket peer, and a forwarded host must be a DNS name or bracketed IPv6 literal. In the X-Forwarded-* family a proxyHops header too short for the chain, or an all-trusted chain, now resolves no client rather than the proxy’s own address, matching the Forwarded family; in trustedProxies mode so does a trusted proxy sending no forwarding header.

  • CSRF own origins behind TLS termination (M113n, #473, @setu-ts/http-security-plugin). csrf: {} also accepts the published public origin and, when the browser sends Sec-Fetch-Site: same-origin, the validated leftmost X-Forwarded-Proto/X-Forwarded-Host candidate; without that gate a credentialed CORS origin could name its own host and pass. A request whose Sec-Fetch-Site is present and not same-origin is checked against the URL origin and trustedOrigins only. Migration: remove redundant same-origin allowlist entries if desired; keep explicit trustedOrigins for other application origins. The zero-config candidate grants no proxy trust to other consumers.

  • Breaking: invalid scheduler time values reject at registration (M113m, 0.10.0, @setu-ts/scheduler-plugin). The async delay call rejects with RangeError for a non-finite or negative delayMs; every rejects for a non-finite or non-positive intervalMs; delay, every and cron reject for a non-finite or negative retry.delay. Messages name the job and the option and rule without echoing the rejected value: delayMs must be a finite number >= 0, intervalMs must be a finite number > 0, or retry.delay must be a finite number >= 0. Zero delayMs and zero retry.delay remain accepted, with no upper delay or interval limit. Migration: fix the invalid value before registering the job; these values were previously accepted.

  • Breaking: JWT expiry is exclusive (V9-70, M113m, 0.10.0, @setu-ts/auth-plugin). First-party JwtService.verify uses fractional seconds (runtime.now() / 1000) without clock tolerance: it rejects with Token expired once now >= exp, and with Token not yet valid while now < nbf. The outside-issuer verifier (issuers) rejects once now >= exp + clockToleranceSec (was >); its not-before check remains now + clockToleranceSec < nbf. Migration: refresh before exp and update tests or clients that assumed a first-party token remained valid during the second of its exp; outside-issuer tokens also stop being accepted exactly at the tolerance-adjusted expiry boundary.

  • Breaking: a primary-key change through update is refused on every adapter (M113g, 0.10.0, @setu-ts/database-plugin, @setu-ts/cloudflare-plugin). Every update and updateWhere (and the transaction-scoped variants) of the eight data sources — Memory, Drizzle, Prisma, MongoDB, DynamoDB, Cosmos DB, Cloud Bigtable and D1 — now runs one shared check (assertPrimaryKeyUnchanged, exported from @setu-ts/common) before any I/O: a payload that carries a key column whose value is not undefined and differs from the addressed key’s value is refused with PrimaryKeyChangeError (branded 400 Bad Request, fixed detail “An update cannot change a row’s primary key.”); the message names the entity and the column, never a key value. An equal value is accepted, compared as a string for string, number, bigint and ObjectId-like values and by time for Date values, so update('5', { id: 5 }) works while '05' against 5 is refused. Before: Drizzle, Prisma and D1 moved the row; MongoDB, Cosmos DB and DynamoDB answered success with the key column stripped; Memory answered a masked 500.

  • Breaking: Drizzle refuses a missing key column and an unknown payload field by name (M113g, 0.10.0, @setu-ts/database-plugin). A Drizzle data source whose key column is not a column of its table is now refused when the repository is created — getRepository and the Unit of Work’s data source — instead of surfacing lazily inside findById, update or delete. A create, update or updateWhere payload that names a field that is not a column of the table is now refused by name before any I/O (UnknownPayloadFieldError, 400 Bad Request, fixed detail “The request names a field this resource does not have.”) instead of being dropped by Drizzle and surfacing as the driver’s raw Failed query. Before: the field was dropped and the row was written without it, or the write failed NOT NULL with raw driver text.

  • Breaking: a cursor the backend cannot use answers 400 with InvalidCursorError (M113g, 0.10.0, @setu-ts/database-plugin, @setu-ts/cloudflare-plugin). Every findPage implementation of the eight data sources now refuses a cursor the backend cannot use — a malformed token, a sort-fingerprint mismatch, or a decoded key-value list whose length or kinds do not fit the target — with InvalidCursorError (branded 400 Bad Request, fixed detail “The pagination cursor is not valid for this query.”) before any backend call; the message names the entity and which check failed, never the token or its values. On Drizzle and Prisma a cursor that decodes but carries a value of the wrong type for its sort column (PostgreSQL 22P02, 22003, 22007 or 22008, or Prisma’s validation refusal) is answered the same way instead of a masked 500; a page without a cursor keeps the driver error. UnsupportedQueryFeatureError (501) remains for a backend that cannot page the query at all, so instanceof UnsupportedQueryFeatureError no longer matches a malformed cursor.

  • Breaking: Bigtable’s key-change refusal answers 400 with PrimaryKeyChangeError (M113g, 0.10.0, @setu-ts/database-plugin). A Bigtable update or updateWhere whose key differs from the addressed row now answers 400 Bad Request with PrimaryKeyChangeError (fixed detail “An update cannot change a row’s primary key.”) instead of 501 with UnsupportedQueryFeatureError (row-key); the served detail no longer names the feature, and the message no longer echoes either key value.

  • BREAKING V9-47 — public health bodies (M113f, @setu-ts/health-plugin). /health, /live and /ready omit indicator data and latency by default. Migration: set details: true for dashboards needing the old body. Programmatic reports stay full; gRPC and diagnostics are unaffected.

  • BREAKING V9-49 — Swagger self-hosting (M113f, @setu-ts/openapi-plugin, @setu-ts/cli). Swagger now serves three fixed local assets and excludes those routes from its spec. The new SwaggerOptions type selects self-hosted, CDN, or custom-base delivery. Migration: install the optional swagger-ui-dist peer (^5) and allow filesystem reads on Deno, or select swagger: { assets: 'cdn' } (a starter uses openapi: { swagger: { assets: 'cdn' } }). Missing packages fail startup with OptionalDependencyUnavailableError. CDN and custom-base modes use pinned 5.33.3 files with SHA-384 SRI; a custom base must be printable ASCII with no backslash, ? or #, an HTTP(S) URL in serialized form without credentials, or a path starting with a single /. On Workers the starters select the CDN when no Swagger asset mode is chosen. Generated Node, Bun and Deno projects declare the package, and a generated standalone or npm workspace-root package.json sets "scarfSettings": { "enabled": false }: swagger-ui-dist depends on @scarf/scarf, whose npm postinstall otherwise reports platform, Node version and dependency data. If you install the package yourself, add the same setting to your root package.json (in a workspace, the root; Scarf ignores a member’s). Workers selects CDN and no longer declares the package.

  • M113f logging (@setu-ts/common, @setu-ts/runtime, @setu-ts/kernel, @setu-ts/exceptions, @setu-ts/logger-plugin, @setu-ts/database-plugin). Scrub Drizzle bound values from messages and multiline stack headers while retaining SQL and classification, and rebuild a SQLSTATE class 22 cause (whose PostgreSQL message quotes the rejected input) from its code. The scrub covers errors that pass through IDatabaseService; a query built through getDrizzleDatabase(...) or a raw driver call is logged as the driver wrote it, and causes nested more than five deep are not walked. Log caught errors once, at warn below 500 and error otherwise; request completion uses the responder’s pure optional statusFor rule. Runtime adapters provide optional reportError as a last-resort JSON sink for unhandled errors and handled 5xx without a logger. Logger excludePaths now accepts patterns.

  • Breaking: outbox and inbox release refusals carry HTTP status hints (M113j, #469, 0.10.0, @setu-ts/messaging-plugin). An unhandled release refusal under errorHandler answers its own status instead of a masked 500: 400 for a bad row id (invalid-id — the outbox’s former missing-id TypeError is now OutboxRowStateError('invalid-id')), 404 for an absent row (missing), and 409 for a row in the wrong state (not-failed outbox, not-parked inbox). InboxRowStateError and OutboxRowStateError are branded with withHttpStatusHint, so the answer carries the mapped status with the class’s own title and detail; the row id and the stored status stay out of the body. OutboxRowStateError.outcome gains 'invalid-id', so an exhaustive switch on it with no default needs a new case.

  • Breaking scoped RBAC: principal authority no longer counts in every scope (M113e, 0.10.0, @setu-ts/auth-plugin). A scoped check (requireScopedRole, requireScopedPermission, @ScopedRoles, @ScopedPermissions) is now decided by grants alone: the roles and permissions a principal carries itself — a JWT roles claim, a '*' permission — count in no scope. Before, each principal role was a global grant and each principal permission satisfied every scoped permission check, so a token role admin passed requireScopedRole('admin') in every tenant with no grant rows. Migration: issue grants, or set scopedRbac.principalAuthorityIsGlobal: true to restore the old answer (still subject to grantableIn). The unscoped requireRole and requirePermission guards are unchanged.

  • Breaking form CSRF: a bearer request is exempt only when a bearer strategy authenticated it and it restored no session (M113e, 0.10.0, @setu-ts/session-plugin). With SessionPlugin({ csrf: {} }), a request carrying Authorization: Bearer <token> skips form-CSRF verification only when a bearer (header-credential) strategy authenticated it AND it restored no session — the session session-plugin parks is new — so a JWT- or API-token-authenticated JSON API in the same application reaches its guard instead of answering 403. The exemption is decided by the credential that authenticated the request and the session it restored, not by the header’s presence: a request carrying a valid session cookie must present a token even with a valid bearer token, because the handler would otherwise act on that browser’s session (an origin CORS lets send credentials can send its own valid bearer token beside the victim’s cookie, M113e, L3). A non-browser API client sends no session cookie, so its new session keeps the exemption. A request the bearer rule exempts also gets no browser session: whatever its handler writes to the session is not committed and no Set-Cookie is sent (M113e, L4), so it cannot plant a session in a browser whose cookie no longer restores. Otherwise, a junk bearer header beside a session cookie is still verified, and an unauthenticated bearer header is verified too (it fails closed when the session or the credential source is missing). For that, the form-CSRF middleware moved from priority 275 to 325 — after authentication (300), before authorization (350) — so the credential source authentication recorded (the IAuthStrategy.credential member, published under common’s CREDENTIAL_SOURCE_STATE_KEY) and the session it parks exist when the check runs. Basic credentials are not exempt. A non-boolean exemptBearer is refused with a TypeError. csrf.exemptBearer: false restores the previous refusal; csrfFormMiddleware and verifyCsrfToken honour the option alike.

  • Breaking session storage: anonymous sessions stay in the cookie on the store strategy (M113e, 0.10.0, @setu-ts/session-plugin). A session holding only the form-CSRF token and the tenant binding is sealed into the cookie and writes no store row, so 200 anonymous form views no longer add 200 rows (measured against Redis). The first write of any other key moves the session into the store, rotating its id so a copied anonymous cookie can never read the signed-in row; a commit leaving only those keys on a stored session deletes its row. During a rolling deploy, an older replica reading such a cookie starts a fresh session, so an anonymous form loses its token once; signed-in sessions are unaffected.

  • Breaking password hashing default (M113e, 0.10.0, @setu-ts/auth-plugin). PasswordHasher now hashes with PBKDF2-SHA256 at 600,000 iterations (OWASP 2023), up from 100,000; a hash costs about 80 ms on Node instead of 15 ms. Existing hashes keep verifying, because verify reads the count stored in each hash. A deployed Cloudflare Worker is reported to refuse counts above 100,000 (unverified against a deployed Worker): pass { iterations: 100_000 } there.

  • Breaking recovery and shutdown behaviour (M113c, 0.10.0). RabbitMQ queue faults now reconnect automatically through the shared common supervisor; unacked jobs may be redelivered with their original attempt count. Make processors idempotent. Deno HTTP close now waits for the first of shutdown completion and a 1000 ms grace, catching rejected shutdown, so lifecycle hooks run after the bounded drain even with requests in flight; move resource cleanup into shutdown/close hooks. After the grace, close() returns without waiting for those requests; they finish only if the process outlives them.

  • Breaking Redis delivery claims (M113c, 0.10.0). visibilityTimeoutMs defaults to 30000, must be at least 1000, and is renewed every third of the lease. Redis 5+ server-time scripts reclaim expired claims; crashes count as attempts and an over-budget job is passed to onFailed, then dead-lettered without running. Drain old replicas before upgrading: their stuck processing entries now return. Redis and RabbitMQ reject stale settlements with internal StaleClaimError, reported as failed settlement while leaving the current delivery intact. Make processors idempotent. A Redis client without eval retains the prior behavior (no claims or reclaim), with one warning that visibilityTimeoutMs is inactive, sent through the connection-error reporter when one is configured (QueuePlugin always configures one). The claim is renewed while an over-budget job’s onFailed runs. Due jobs are still selected by the reserving replica’s clock, so skew between an application host and Redis does not shift delays.

  • RabbitMQ queue connection bound and unreadable messages (M113c). Each connection attempt RabbitMqQueue makes is bounded at 10 seconds in total, so a broker that never answers, or answers too slowly, is retried; a connection that opens late is closed. A ready-queue message whose body is not a stored job is moved, bytes unchanged, into the dead queue with one warning, rather than throwing out of reserve and stranding the messages already fetched in that batch. If the dead queue refuses the move, the jobs already claimed are returned and the message waits, unacknowledged, until the channel closes.

  • Breaking processor definition mutability (M113c, 0.10.0). QueueProcessorDefinition<T = unknown>.processor is now a method, admitting typed payloads in declarative lists and preserving this. Code relying on its former readonly restriction must enforce immutability itself.

  • Redis namespaces (M113c). RedisQueueOptions.keyPrefix and QueuePluginOptions.keyPrefix configure every Redis key; the default remains queue:. Use distinct prefixes for applications sharing Redis; drain or migrate existing keys before changing a deployed prefix. RabbitMQ’s prefix does not configure Redis.

  • Shared reconnect exports (M113c). Common now exports ReconnectSupervisor, ReconnectSupervisorOptions, ReconnectMode and fullJitterDelay; messaging’s three supervisor callers import them from common. The implementation moves without behavioural changes.

  • Workers builds and the edge cache (M113d). A Cloudflare Worker importing a Setu-TS package now builds and deploys whether or not that package’s optional peers are installed. Every bare dynamic import() in packages/*/src is now awaited inside the try block of a same-function try/catch (the loaders of 14 published packages and cli converted; the rest already were), and the catch throws the new OptionalDependencyUnavailableError from @setu-ts/common — a missing optional dependency now rejects with that named error, or with a package-specific wrapper that keeps it as cause (the original rejection remains the shared error’s cause), instead of the runtime’s raw error. cacheApiMiddleware stores the body it serves with or without a waitUntil host, a failed write is logged with the request path instead of the cache key (which carried the query string and the tenant id) and by error class rather than message, since a custom cache’s rejection may quote its key (a put that throws rather than rejects is handled the same way, and the response is still served), and CloudflarePlugin warns once on Workers when no host was passed. A newly scaffolded Workers project’s entry passes the platform’s waitUntil to createApp as its second parameter (named _waitUntil until something reads it); a full-stack Workers project passes its compiled server build from the entry (the new ReactRouterPluginOptions.serverBuild option), renders .dev.vars, and its wrangler.toml carries [assets] and [build] stanzas. setu add cloudflare prints the waitUntil form and tells you to rename the factory’s _waitUntil parameter; docs/upgrading.md carries the edits for projects scaffolded by 0.9.0 or earlier. ReactRouterPluginOptions changes from an interface to a union type (serverBuildPath | serverBuild, exactly one required) — breaking for implementors who extends it; a caller passing serverBuildPath is unaffected. A custom loadRequestHandler receives the module as options.serverBuild, its new optional third parameter. Ships in minor 0.10.0.

  • Breaking readiness behaviour (M113b, 0.10.0). Seven indicators now remain up where they previously reported degraded, so /ready changes from 503 to 200 and /health no longer changes overall status for these conditions. Migrate alerts to inbox data.parked / data.reasons, outbox data.reasons (all six reasons), realtime-backplane data.reachable, worker-pool data.available, static-files data.fs, auth data.current / data.issuers, and feature-flags data.healthy / data.detail. degraded still means out of rotation: /ready 503, /health 200. Healthy Kubernetes and Consul discovery probes now preserve their receiver and allow readiness. The discovery probe is cached for 5 s, coalesced, and bounded at 2 s, with runtime-timed request cancellation for Consul and Kubernetes. Health reads now issue at most one backend probe request per 5 s window per service instance; before M113b they issued none because the detached provider call threw before I/O.

  • Connection-fault code predicate (M113b). The unreleased CONNECTION_FAULT_CODES export is removed so callers cannot mutate shared outage classification. Replace .has(code) with isConnectionFaultCode(code) from @setu-ts/common; only the same eight string codes match. Database classifier behaviour is unchanged.

  • Breaking Redis outage responses (M113b). Cache and rate-limit connection faults now carry 503 status hints instead of reaching errorHandler as masked 500s; cache-backed sessions inherit the cache response. Update response handling to accept 503 and inspect cause instead of checking raw driver errors. WRONGTYPE/programming errors are unchanged.

  • Breaking rate-limit wait bound (M113b). Built RedisRateLimitStore clients now use a 15000 ms command bound. Configure commandTimeoutMs higher for slow Redis, or 0 for the previous unbounded wait; configure injected clients directly.

  • Messaging broker bounds and recovery (M113a, @setu-ts/messaging-plugin). Kafka and Pub/Sub publishes now reject after 15000 ms by default, including RPC and outbox traffic; set publishTimeoutMs higher or to 0 for the previous unbounded wait, and account for possible late delivery before retrying. Kafka factories without admin now report unknown reachability instead of true; supply an admin-capable factory for a probe. Lazy NATS connections reconnect forever; set reconnect: { maxAttempts: 10, waitMs: 2000 } to restore the finite budget, or configure injected connections in the application. Fresh Kafka groups now start at the high watermark captured at subscribe time: remove join sleeps and publish retries used to avoid losing the first message. First joins seek assigned uncommitted partitions to the minimum start offset advertised by the current members, covering concurrent first starts without replaying history before the first subscription when every group member is trusted (a client able to join the group, which needs only group Read, can move the start earlier without a log line; advertisements are keyed by topic and bounded at 64 KiB and 19-digit offsets). Existing committed groups are untouched; a group subscribing before the broker’s coordinator is ready still captures its watermarks, since the first join re-checks which partitions are committed. Without admin the step is skipped; injected factories without a loadable kafkajs module seek only their own offsets, so concurrent starts can miss messages. A member crashing before the join takes its start offsets with it; admin/metadata failures warn once per subscription and remain non-fatal.

  • Governing toolchain instructions (M111f). Repository commands use pnpm, Node, Vitest, ESLint, Prettier and TypeDoc. A categorized prose gate prevents obsolete instructions. JSR metadata tooling derives npm migration pointers for the 49 existing JSR packages; applying them is a post-merge maintainer step. npm descriptions are unchanged.

  • CI, examples and website on Node and pnpm (M111e3). The root deno.json and deno.lock are gone: every repository command is pnpm run <name>, with pnpm run test:tooling and pnpm run test:tooling:coverage for the root suite that deno task test used to run. The authored pnpm-workspace.yaml is the workspace list. CI’s main job is now “Format / Lint / Check / Test” on Node, and the Deno runtime proofs run in their own “Deno runtime lane” job against a committed test/deno-lane/deno.lock; Deno is installed only where it is the subject. The examples under apps/ join the pnpm workspace and run through tsx, apps/compiled-binary becomes a Node single executable application, and the repository images move to node:24-alpine (268–289 MB) and a Node SEA on distroless (211 MB, previously 44.9 MB as a Deno-compiled binary). The release’s resolved-set.json now records the resolved set from pnpm-lock.yaml: { version, lockfileVersion, packages }, where each packages key is an exact name@version with its registry integrity when present; it no longer embeds deno.lock. The generated API reference is TypeDoc HTML, so its page URLs change (modules/<package>_src.html replaces <package>/src/index.ts/index.html), and the website builds with pnpm.

  • Repository tooling on Node (M111e2). Repository scripts use Node 24 and a shared subprocess helper. Root tests join the Vitest tooling member, which enforces the existing script coverage inventory per file. Root tasks forward to pnpm; the tooling-only and all-member test commands keep their separate scopes. The type-check gate includes package tests and repository tooling. The JSDoc ratchet runs on TypeDoc 0.28.20, and the README export tables and documentation fence compiler use the TypeScript compiler API. Two README tables change as a result, both correcting deno doc output: @setu-ts/cli/main now lists runMain, nodeProcess, emitInterrupt, MainProcess and MainRuntime (shipped in 0.9.0, previously rendered as an empty table), and @setu-ts/storage-plugin’s deprecated IAwsS3Client alias is listed as an interface rather than reference.

  • Errors keep their cause (M111e1). Four errors that were thrown from a catch without the original error now carry it as error.cause: the ConfigPlugin env-file read failure, both react-router-plugin server-build load failures (the build module and react-router), and the localization-plugin refusal of an invalid exposeCatalogues.cacheControl. Messages are unchanged. Found when ESLint’s preserve-caught-error replaced deno lint.

  • @setu-ts/cli setu add registers the zero-configuration plugins it installs (V9-30, M113i, #479, 0.10.0). For worker-pool-plugin, secrets-plugin, storage-plugin, audit-plugin, mail-plugin, resilience-plugin, view-plugin and idempotency-plugin, setu add now inserts the bare plugin call into setu.config.ts exactly as it does for cache, and a second add is a no-op. The two packages that need a user choice print the exact wiring line instead and leave the config byte-identical: telemetry TelemetryPlugin({ serviceName: '<service-name>', exporter: 'console' }) (inserting it adds span middleware to every request with no exporter) and localization LocalizationPlugin({ supportedLocales: ['en'], catalogues: { en: {} } }). Starter-composed projects keep the existing starter-arm guidance path; on a Workers project the inserted calls are runtime-portable except WorkerPoolPlugin, which registers and reports itself unavailable (documented M45 behaviour), so no new runtime restriction is added.

  • @setu-ts/cli --dry-run reports the refusal the real run makes (V9-61, M113i, #479, 0.10.0). The --dry-run of generate, new, library and generate app now classifies the files first and prints would create <path>, would update <path> for managed files that exist, or unchanged <path>, then WOULD REFUSE: <path> already exists for each collision, exiting 1 when any collision exists and 0 otherwise — setu adopt’s wording and exit code; before, a dry run printed would create for every file and returned 0 before the existing-file check ran. setu adopt keeps its own branch because it also prints moves.

  • @setu-ts/cli class-based ingress files carry their kind in the name (V9-60, M113i, #479, 0.10.0). The five class-based ingress schematics — job, event-handler, command-handler, query-handler and ws-route — now write src/ingress/<kebab>-<kind>.ingress.ts with the class <Pascal><Kind>Ingress (for example src/ingress/<kebab>-job.ingress.ts / <Pascal>JobIngress), so two kinds of the same name no longer collide. Every other symbol inside the file keeps its plain name, so the emitted addresses do not change, and existing src/ingress/<name>.ingress.ts files keep scanning as <Pascal>Ingress and stay registered.

  • @setu-ts/cli generated text names a CLI invocation that resolves (V9-12, M113i, #479, 0.10.0). Generated files, Next: lines and messages for a project whose runtime the command has already resolved render the CLI as the pinned package for that runtime: npm-family (Node, Cloudflare Workers) npx @setu-ts/cli@<VERSION>, Bun bunx @setu-ts/cli@<VERSION> and Deno deno run -A npm:@setu-ts/cli@<VERSION>, where <VERSION> is the CLI’s own version; before, the bare setu named the unrelated npm package setu. Usage, help and remedies without a resolved runtime retain setu (PROGRAM_NAME); Deno-only devtool guidance uses the pinned Deno invocation. The generated sites include the project README “Generate code” fence, the Workers wrangler.toml comment, the workspace root README fences and the ports --reallocate instruction, the full-stack architecture table cell, and the Next: line setu new --workspace prints. A provenance comment that names what wrote a file is unchanged, and no scaffold gains a dependency.

Added

  • Agent briefs and per-package cards (M116, @setu-ts/cli). A new built-in setu agents writes the project’s AGENTS.md from its real state — installed @setu-ts/* packages, style, runtime, scripts, available schematics — keeping the CLI-owned section between <!-- setu-agents:begin --> and <!-- setu-agents:end --> and leaving every other byte untouched; a marker arrangement it cannot read is refused instead of guessed at. --check reports a missing or stale section and exits nonzero without writing, for CI; --dry-run prints the paths; --dir selects the project. In a workspace it writes the root AGENTS.md and every member’s, run from the root or from a member’s own directory. setu new --agents runs the same writer after scaffolding, and a successful setu add prints a reminder when a sectioned brief already exists. Every published library package now ships a hand-written AGENT_CARD.md in its npm tarball, so a normal install delivers the card at the node_modules/@setu-ts/<pkg>/AGENT_CARD.md path the brief names.

  • Self-contained feature modules, --module and g schema (M114, @setu-ts/cli). setu generate module <name> makes a functional module self-contained: the whole feature lives in src/modules/<name>/ (routes, service, schemas, tests, module index) and registers through registerModules(app.router, app.services) from src/modules/index.ts; a class-based module keeps its controller/service/@Module shape and gains <name>.schemas.ts and the two tests. A new --module <name> flag places a service, route (functional only), controller or schema inside an existing module: functional mode writes the file into the module folder and regenerates the module’s index.ts, mounting a module-scoped route or controller at /<module>/<name>; class-based mode writes the file and prints the @Module({ providers | controllers }) line to add — it never edits the declaration — registering a module-scoped service under the token <module>.<name>-service (for example billing.invoice-service). Every other schematic is app-wide and refuses the flag. A new schema schematic (requires validation-plugin) writes src/shared/schemas/<name>.schemas.ts — or src/modules/<module>/<name>.schemas.ts with --module — and never edits an existing file; a second run is the ordinary overwrite refusal. SchematicOptions gains the optional targetModule? and moduleArtifacts? fields for custom schematics. zod is declared by every template that installs validation-plugin, setu add validation adds it on every runtime, and g module/g schema print the runtime’s install command (npm install zod@^4, bun add zod@^4, deno add npm:zod@^4) in a project that lacks it.

  • Generated e2e tests and the test:e2e task (M114, @setu-ts/cli). setu new emits test/support/server.ts (CLI-owned; boots the real createApp() on a free loopback port) and test/e2e/app.e2e.test.ts on every socket target; setu g module adds src/modules/<name>/<name>.e2e.test.ts, which drives the module’s routes over real HTTP (and re-emits test/support/server.ts, so a project scaffolded earlier gains it). A new test:e2e task runs only *.e2e.test.ts over real HTTP; the test task keeps running the in-process unit tests without a socket.

  • ValidatorMiddleware/ValidatedOutput and value(ctx) (M114, @setu-ts/validation-plugin). validateBody/validateQuery/validateParams/ validateHeaders/validateCookies and createValidationMiddleware now return ValidatorMiddleware<ValidatedOutput<S>> — a MiddlewareFunction (so every existing call site type-checks unchanged) with one added member, value(ctx). ValidatedOutput<S> is the schema’s parsed output type — coercions and defaults included — for Zod 3 and 4 alike, inferred from the schema, so the handler reads a typed value from the same instance it registered — const body = validateBody(S), then body.value(ctx) — with no cast. value(ctx) throws a TypeError naming the target when that validator did not run on the request or a later validator for the same target overwrote it. The ctx.state.get(validatedStateKey(...)) read still works and is unchanged.

  • Form-body validation (M114, @setu-ts/validation-plugin). validateBody(schema, { forms: true }) and @ValidateBody(schema, { forms: true }) also validate application/x-www-form-urlencoded and multipart/form-data bodies with the same schema they validate a JSON body with. Off by default: a cross-site HTML <form> reaches a route that accepts forms with no CORS preflight, so pair an opted-in route with form CSRF protection (csrfFormMiddleware from @setu-ts/session-plugin). Without the option a form body answers 415 “Unsupported Media Type”. IValidationService.middleware gains the matching optional third parameter, ValidationMiddlewareOptions in @setu-ts/common. The form converts to a record: a field sent once is a single value, a repeated field becomes an array in wire order, a name ending [] is always an array (kept under the name as written); values are strings or the framework’s FormFile (not a web File, so z.instanceof(File) refuses it — check the FormFile shape instead), and numbers and booleans arrive as strings, so schemas use z.coerce. A _csrf field from the session plugin reaches the schema, where a Zod object strips it by default and forbidNonWhitelisted refuses it. The generated OpenAPI document still describes a validated body as application/json.

  • Reverse-proxy origin support (M113n, #473, @setu-ts/common, @setu-ts/http-security-plugin, @setu-ts/session-plugin, @setu-ts/kernel). REQUEST_ORIGIN_STATE_KEY, RequestOrigin, requestOriginOf(ctx) and publicRequestUrl(ctx) share the trusted public origin; forwardedHeader: 'forwarded' selects bounded RFC 7239 parsing instead of X-Forwarded-*; session cookie.secure: 'auto' follows the resolved protocol (default stays true, and 'auto' conflicts with sameSite: 'none'); InjectRequest.ip supplies a peer in tests.

  • OperationRequestOptions and a per-call key on generated operations (V9-15, M113k, 0.10.0, @setu-ts/sdk). @setu-ts/sdk exports OperationRequestOptions, whose idempotencyKey and signal carry the same meaning as the same-named fields of ClientRequest; headers is deliberately absent, because header parameters the document declares are typed fields of opts. Every generated operation now takes an optional trailing request?: OperationRequestOptions, after its path parameters and after opts; the generated code forwards only those two fields. Existing calls are unaffected, and an object implementing a previously generated Api stays assignable.

  • Primary-key and cursor refusals, and a database call timeout (M113g, 0.10.0, @setu-ts/common, @setu-ts/database-plugin). @setu-ts/common exports assertPrimaryKeyUnchanged, the one shared check every update/updateWhere of the eight data sources runs before any I/O, and the two classes that check and the findPage implementations throw: PrimaryKeyChangeError (a key change through update, branded 400 Bad Request with the fixed detail “An update cannot change a row’s primary key.”) and InvalidCursorError (a cursor the backend cannot use, branded 400 Bad Request with the fixed detail “The pagination cursor is not valid for this query.”). @setu-ts/database-plugin exports UnknownPayloadFieldError (a Drizzle write naming a field that is not a column, 400 Bad Request) and DatabaseTimeoutError, the class a DatabaseService interception throws when node-postgres’s query_timeout fires (the driver’s Query read timeout rejection), branded 504 Gateway Timeout with the fixed detail “The database did not answer in time.” The statement may have run, so a write’s outcome is unknown and a retry of a write must be idempotent.

  • PasswordHasherOptions and PasswordHasher.needsRehash (M113e, @setu-ts/auth-plugin). new PasswordHasher(runtime, { iterations }) sets the count for new hashes (100,000–10,000,000, refused at construction otherwise, NaN included); needsRehash(stored) reports a hash made with fewer iterations so the application can replace it on the next successful sign-in, and never asks to downgrade one.

  • scopedRbac.principalAuthorityIsGlobal and csrf.exemptBearer (M113e). The opt-outs for the two breaking defaults above.

  • Scheduler lock reachability (M113b). IDistributedLock gains optional isHealthy?(): Promise<boolean | undefined> and IRedisLockClient gains optional ping?(): Promise<unknown>. The scheduler indicator adds data.lock.reachable (true / false / 'unknown'), cached for 5 s and bounded at 2 s while connected. Status remains lifecycle; shared lock outages do not change readiness. Memory locks report reachable; Redis locks probe PING and clients without it report unknown.

  • Redis outage vocabulary (M113b). @setu-ts/common exports isConnectionFault and isConnectionFaultCode; cache-plugin exports CacheStoreUnavailableError; auth-plugin exports RateLimitStoreUnavailableError. RedisRateLimitStore gains commandTimeoutMs.

  • Messaging reachability probes (M113a). Kafka uses a dedicated short-timeout metadata client; Pub/Sub uses bounded getTopics. Both cache probes for 5 s and bound them at 2 s, including direct health calls. Kafka/Pub/Sub gain publishTimeoutMs; NATS gains validated reconnect. Stopped Kafka/Pub/Sub report down; a hung probe reports unknown, which still leaves health up.

0.9.0 — 2026-10-12

Added

  • The CLI is a Node program (M111d). setu ships as an npm bin (npx @setu-ts/cli new my-app) and the new @setu-ts/create package (exporting createArgs) makes npm create @setu-ts my-app the same as setu new. Project TypeScript — setu.config.ts for plugin commands, custom schematics — loads through tsx, a CLI dependency. An npm-target scaffold (Node, Bun, Workers) carries its own tooling: check (tsc, TypeScript ~6.0.3), lint (ESLint with typescript-eslint) and format / format:check (Prettier); everything the CLI writes into such a project is Prettier-formatted, so it passes its own format:check from the first run. A selected broker, queue or gRPC transport now adds its client package (ioredis, amqplib, nats, kafkajs, the cloud SDKs, Connect) to the project — package managers never install an optional peer, so such a scaffold used to fail at its first boot. The generated migration runner (src/migrations/run.ts, run with npx tsx, bun run or deno run -A) and gRPC code generation (proto:gen) run on every target.

  • npm publishing machinery (M111c). All 51 manifests gain publish metadata, src and LICENSE in their tarballs, and audited sideEffects declarations. The decorator metadata module is retained; other members declare false. Publishing packs with pnpm, checks the packed manifest and registry version, and publishes with npm; CI separates gates from the OIDC publish job. README-only placeholders support the future one-time bootstrap. Nothing is published here. JSR’s dry-run/slow-type and @module-first checks are replaced by the TypeScript build, npm dry run, and non-empty README check. Historical @since claims stay on JSR through 0.8.x; later claims read npm tarball sources. Compatibility declares all 51 npm packages and arms on publish.

  • Package tests on Node 24 (M111b). Every member uses Vitest, source export resolution, TC39 decorator lowering, shared-resource isolation, and enforced per-file V8 coverage. Deno-only runtime proofs retain a named lane; root tooling stays on Deno. The reproducible codemod, host helpers, count parity report, and CI lane guards accompany the move.

  • A Node build for every package (M111a). Every workspace member builds with TypeScript 6.0.3 (tsc -b) to dist/, with a generated package.json (ESM only, engines.node >=22, exports per subpath, workspace:^ internal dependencies) and tsconfig.json, under a pnpm workspace. scripts/node-manifests.ts generates them — and each member’s deno.json imports, from the same range table — from the member’s sources, and refuses drift (--check); every lazily imported driver is an OPTIONAL peerDependency, and @hono/node-server is a regular dependency of @setu-ts/runtime so a bare install can serve on Node. deno task check:node-build packs every member, refuses a tarball still naming workspace:, installs kernel, runtime and decorator-plugin with plain npm into an empty project, and serves a request and a tsc-compiled decorated controller on Node 24, Node 22 and Bun; a new CI job runs it with --require-all. Nothing is published to npm yet (111c). The Deno toolchain and every Deno gate are unchanged.

  • @setu-ts/decorator-plugin installs Symbol.metadata on Node and Bun (M111a). Neither runtime defines it, and TypeScript’s TC39 decorator emit creates decorator metadata only when it exists, so a tsc-compiled decorated class crashed at definition with a bare TypeError. Importing the package now installs Symbol.for('Symbol.metadata') when the symbol is absent (never overwriting one — a no-op on Deno), and a decorator that still finds no carrier throws DecoratorMetadataUnavailableError naming both remedies. It is the one module-level exception to AI_GUIDELINES §11.5.

  • Per-field redaction in a policy (@setu-ts/common, M101h). RedactionPolicy.fields accepts a FieldRedaction — { classification, redactor? } — as well as a classification string, so one classification can carry two treatments (mask the email, erase the name, both pii) without an invented classification string. A new FieldRedaction export names the object arm, and createRedactionService selects a redactor in this order: the field’s own redactor, then redactors[classification], then defaultRedactor, then eraseRedactor. Every existing string-valued policy behaves byte-identically. One reader-side change: code that READS policy.fields[path] as a DataClassification now sees the union and must narrow with typeof value === 'string'. A field’s redactor and classification are read only as own properties: an inherited redactor (a polluted Object.prototype, or an entry built with Object.create) is ignored, and an entry without its own string classification is erased.

  • Conditional repository writes (M105). Optional updateWhere and deleteWhere on IRepository and IDataSource, with shared WritePrecondition and checkWritePrecondition validation (WritePreconditionCheck), native adapter conditions, bounded Cosmos version guards and Bigtable newest-cell checks. checkWritePrecondition returns private copies of the predicate and payload, and every implementation writes the copies, so the predicate the backend receives is the one that was validated. Non-finite numbers and an own __proto__ field (in the predicate or the payload, which Node and Bun drop when MongoDB or Prisma rebuild the object) are refused. checkWritePrecondition’s refusal reasons quote no caller field name or value, and Prisma’s validation message, which renders the whole query, is replaced with a fixed sentence; other adapter and driver refusals may quote caller input, in server logs only, as for every repository operation. Tenant, outbox and inbox bridges use conditional writes when supported and retain their fallback for other sources.

  • Scoped RBAC — a role held in a tenant, organisation, team or region (@setu-ts/auth-plugin, @setu-ts/decorator-plugin, @setu-ts/database-plugin, @setu-ts/common, M110b). The new AuthPluginOptions.scopedRbac (requires rbac) defines one built-in policy, scoped-rbac, on the M110a evaluator — no new capability token. requireScopedRole (any-of) and requireScopedPermission (all-of) guard a route in the request’s scope, taken from scopeFromTenant() (the default), scopeFromParam(param, type), a fixed ScopeRef or a function; @ScopedRoles / @ScopedPermissions are the class form, validated at register(). Grants come from static, claims and custom sources, unioned, and one failing source denies the check; inheritsFrom walks parent and delegating scopes with cycle refusal and bounds (child requests inherit parent grants); a route scope naming another tenant than the resolved request tenant denies; grantableIn limits where a role counts; customRoles lets a scope define roles bundling catalogue permissions; timing is 'request' (default), a TTL cache, or 'sign-in' (stored under a private session key, never in claims, and carried through a pending second factor); a custom source receives only the principal’s id and iss claim, and a claims source is mapped from the credential on every check and never memoised, cached or shared, because two tokens for one principal can carry different claims. A scoped guard or decorator naming a permission or role outside the catalogue fails at startup. common adds ScopeRef, ScopedGrant, GrantQuery, IGrantSource, IScopedRoleSource, ScopedRoleDefinition, ScopedRbacTarget, ScopeSource, SCOPED_RBAC_POLICY, scopedPermissionAbility, scopedRoleAbility, isScopeType, MAX_SCOPE_ID_LENGTH, scopeFromTenant and scopeFromParam; database-plugin adds createDatabaseGrantSource and createDatabaseRoleSource with DatabaseGrantSourceOptions, DatabaseRoleSourceOptions, GrantFields and RoleFields (verified against real PostgreSQL and MongoDB). auth-plugin exports the option types ScopedRbacOptions, GrantSourceConfig, StaticGrant, ClaimsGrantMapper, ScopedRoleLimit, ScopedRbacTiming and ScopedGuardOptions, and GrantResolutionError (a 503 hint, with a GrantResolutionReason), which a sign-in-timing source failure rejects with. The name scoped-rbac is reserved: AuthPlugin({ policies }) refuses a policy of that name. Nothing existing changes, so there is no upgrade step.

  • Authorization policies — an asynchronous, target-aware check (@setu-ts/auth-plugin, @setu-ts/decorator-plugin, @setu-ts/common, M110a). Roles answer “does this principal hold this role, anywhere”; a policy answers “may this principal do this, to this target”. common adds CAPABILITIES.AUTHORIZATION_POLICIES and the IAuthorizationPolicyService contract (can, authorize, describe, define) with PolicyDefinition, PolicyAbility, PolicyCheck, AnonymousPolicyCheck, PolicyRef, PolicyAbilityInfo and PolicyTarget, plus authorizationFailureInit, now the one owner of the refusal status, title and detail that respondWithAuthorizationFailure writes. AuthPlugin always registers the policy service and gains a policies option; auth-plugin adds definePolicy, the requirePolicy route guard, and the AuthorizationDeniedError (with PolicyDenial) and UnknownPolicyError rejections, and re-exports IAuthorizationPolicyService. decorator-plugin adds the class form — @Policy, @Ability (with AbilityOptions), DecoratorPluginOptions.policies — and @RequirePolicy, typed by PolicyClassAbility and PolicyClassTarget. The evaluation rules are fixed: only a literal true allows; a throwing check denies and is logged once, never with the target; an anonymous request is refused 401 unless the ability opted in with { anonymous: true, check }; for a signed-in principal before runs first (true allows, undefined falls through, anything else denies); a denied signed-in principal gets 403 with the same body requireRole writes, and a thrown authorize denial gets that body too under errorHandler. A requirePolicy guard naming an unregistered policy or ability fails app.start(), after which the policy registry is sealed; @RequirePolicy is validated at register(), including against a same-named policy that declares an ability differently. Both refuse an anonymous request to an ability that needs a principal BEFORE running the target extractor, so an unauthenticated caller costs no lookup and cannot probe which records exist — the guard reads that from the registered policy on every request, so a route added after start() is covered too; an undefined principal is anonymous; and an UnknownPolicyError truncates names over 128 characters. Both are branded for OpenAPI deriveSecurity. New guide: docs/authorization.md. Not breaking: a new capability, new types and new exports — no existing interface gains a member.

  • Idempotency tier C — the work and its record committed together (@setu-ts/idempotency-plugin, @setu-ts/database-plugin, @setu-ts/common, @setu-ts/sdk, M109b). A new IIdempotencyService.within(options, fn) opens ONE database transaction, creates a claim row in it FIRST, runs fn with that transaction’s unit of work, creates a result row holding fn’s JSON result and commits; a repeated key whose record is committed returns the stored result without running fn, and a concurrent duplicate loses the race on the claim’s primary key and its business writes roll back with it — then it replays, or, where the backend reports a write conflict before the winner committed, rejects with a retryable 409. The plugin gains IdempotencyWithinError (with IdempotencyWithinErrorReason), IdempotencyVerifyTimeoutError, TransactionalIdempotencyOptions and TransactionalIdempotencyPurgeOptions; common gains the port ITransactionalIdempotencyStore with TransactionalIdempotencyClaim, TransactionalIdempotencyRecord, the IDEMPOTENCY_RECORD_KIND discriminator, and the IdempotentWithinOptions / IdempotentWithinResult option and result types; database-plugin gains createDatabaseIdempotencyStore (with DatabaseIdempotencyStoreOptions) and TransactionalStoreUnavailableError; and @setu-ts/sdk gains ClientOptions.idempotency (ClientIdempotencyOptions) and ClientRequest.idempotencyKey, so a keyed request keeps ONE key across every retry attempt (a request that already carries the configured header uses it as the key) and may retry any method and 409, but keyed POST/PATCH requests are never retried after a 2xx response arrives, including a body-read failure or a throwing response interceptor; the original thrown value reaches the caller unchanged. On both the first call and a replay, within returns the JSON round trip of what fn returned: dates become ISO strings, undefined object members are absent, and nested toJSON() output is respected. The service also exposes purgeTransactional() for retention cleanup. On PostgreSQL (Prisma), a concurrent loser blocks on the winner’s claim insert, which the adapter’s transactionTimeout does not interrupt, and then replays; a loser whose own work exceeds that timeout answers 'store-failed' (503), with no timeout-to-409 mapping.

  • A “How It Fits Together” guide (docs/how-it-fits-together.md, PR #429). One page answers the questions a newcomer hits first: the order start() and stop() run in, why a capability lookup misses and what each error message is telling you, whether a service lives in the service registry or the DI container (and how DiPlugin’s autoRegister fallback links them), and whether a test should use overrideCapability, overrideProvider or without. Every example compiles under the guide fence gate.

  • A Cloudflare Durable Object idempotency store (@setu-ts/cloudflare-plugin, M109a). The plugin gains DurableObjectIdempotencyStore (the Worker side, over a durable_objects binding and a required namespace) and IdempotencyObjectCore (the Durable Object side, with IIdempotencyObjectState and IdempotencyObjectCoreOptions), plus DurableObjectIdempotencyStoreOptions. One object per store key, one alarm per record, and an input gate that keeps each claim atomic.

  • @Idempotent() makes a decorated route idempotent (@setu-ts/decorator-plugin, M109a). The decorator plugin gains Idempotent, which records a route’s idempotency options and appends the provider’s middleware LAST — after guards, declarative authorization, the middleware band and the validation band — so an invalid or unkeyed request never consumes a key. A safe-method route (GET/HEAD/OPTIONS) or a missing CAPABILITIES.IDEMPOTENCY provider fails register().

  • Idempotency contracts in @setu-ts/common (M109a). CAPABILITIES.IDEMPOTENCY ('idempotency') names the provider of the idempotency service. The package gains the store port IIdempotencyStore and its IdempotencyClaimRequest, IdempotencyClaimResult and IdempotencySettleResult types, the service contract IIdempotencyService, the route option type IdempotentRouteOptions with IdempotencyKeySource and IdempotencyFingerprintSource, and the ingress option types IdempotentIngressOptions with its shared half IdempotentIngressCommonOptions and the IngressIdempotencyKeySource / IngressIdempotencyFingerprintSource sources. IngressContext gains an OPTIONAL consumer dispatch identity so per-consumer state (an idempotency record) is keyed per subscriber rather than per topic. The mechanism itself ships in @setu-ts/idempotency-plugin.

  • A repeat of a completed HTTP request, queue job or broker message is answered from its record or skipped (@setu-ts/idempotency-plugin, M109a). This is deduplication, not a single-execution guarantee: a failure after a handler’s external side effect but before complete lets a retry repeat that effect, which only a derived key forwarded to a de-duplicating provider avoids. The new package exposes IdempotencyPlugin, which registers an IIdempotencyService under CAPABILITIES.IDEMPOTENCY; idempotent() for a route’s middleware array and idempotentIngress() for queue/broker behaviours; and derivedIdempotencyKey(), which a handler forwards to a provider that de-duplicates. It ships an in-process store, a Redis store (atomic Lua, an inject-or-lazy ioredis client, a connect-time maxmemory-policy warning and a required namespace) and, from @setu-ts/cloudflare-plugin, a Cloudflare Durable Object store. A refusal carries IdempotencyRefusedError with IdempotencyRefusalReason; a bad option carries IdempotencyConfigurationError; IdempotencyPluginOptions, IdempotencyStoreConfig, IRedisIdempotencyClient, IDEMPOTENCY_KEY_HEADER and IDEMPOTENT_REPLAYED_HEADER complete the surface. The idempotency health indicator reports lifecycle truth then a bounded store probe.

  • A full-stack project’s dev task serves route edits without a restart, on Deno, Node and Bun (@setu-ts/cli, @setu-ts/react-router-plugin, PR #426). setu new --template full-stack now emits a dev.ts entry and a dev task (deno task dev, npm run dev, bun run dev); a Workers project gets neither, since the entry runs Vite in-process. On Deno, Vite’s development SSR runner could not load a route that imports a @setu-ts package: it resolves imports through node_modules, so every page answered 500 with Cannot find module '@setu-ts/react-router-plugin', and a package it loaded itself would be a second copy whose contextKeyFor() keys match nothing. The new viteDevExternals() resolves those packages with the runtime’s own resolver and keeps them external in the SSR environment; a client import, which a browser could not load from that specifier, is left to Vite. On Node and Bun the packages are in node_modules, which Vite externalises already, so the same dev.ts runs unchanged there. The plugin is typed structurally (ViteDevExternalsOptions, ViteDevExternalsPlugin, ViteDevExternalId, ViteHookContext, ViteResolvedEnvironments), so the package still imports no Vite. Verified on all three runtimes against the published packages, and on Deno against the workspace sources too: /login renders with its CSRF token, client modules load through the app port, an edited route is served on the next request, and SIGINT stops the app and Vite with exit code 0. If either fails to stop, the failure is logged and the process exits 1.

  • overrideProvider() replaces a DI container provider in a test (@setu-ts/testing, PR #426). With DiPlugin registered, DecoratorPlugin puts each @Injectable class into the container, where overrideCapability() cannot reach it, and the container refuses a second registration. overrideProvider('pricing-service', { useValue: fake }) registers the double before the real class, so a decorated controller is constructed with it. A mistyped token, an application without a container, or a provider registered too early fails start() instead of testing the real class. A child scope from createScope() keeps the double even when it registers the token itself.

  • Publish options (M106, PR #427). IMessageBroker.publish takes an optional third argument, typed PublishOptions — orderingKey, deduplicationId and headers — carried as x-setu-* transport headers (ORDERING_KEY_HEADER / DEDUPLICATION_ID_HEADER) beside each broker’s native primitive (Kafka message key, Pub/Sub orderingKey, NATS Nats-Msg-Id, Service Bus and RabbitMQ messageId), so the options are observable through MessageMetadata.headers on all seven brokers and on WorkersBroker — the one exception is a NatsBroker with an injected connection and no headersFactory, which drops caller headers and the ordering key (reported once) while still applying the de-duplication id as nats.js’s native msgID. enableMessageOrdering creates the Pub/Sub broker’s own subscriptions with ordering; it is accepted only where the broker loads the SDK, and refused beside an injected client. The Pub/Sub SDK facade’s topic handle gains an optional resumePublishing, so a hand-built module double still type-checks. publishIntegrationEvent takes PublishOptions as an optional sixth argument and passes the envelope id as the default de-duplication id, and defineIntegrationEvent takes an opt-in orderingKey selector, called only when the caller supplied no key. Invalid options are refused by name, case-insensitively covering the header names a broker or its server acts on, and a refusal never echoes the refused value. common gains the shared rule set — publishIdProblem / isValidPublishId with MAX_PUBLISH_ID_BYTES for the ids, and publishHeaderNameProblem / publishHeaderValueProblem with MAX_PUBLISH_HEADERS, MAX_PUBLISH_HEADER_NAME_BYTES, MAX_PUBLISH_HEADER_VALUE_BYTES, RESERVED_HEADER_NAMES and RESERVED_HEADER_PREFIXES for the headers, and parsePublishOptions (returning ParsedPublishOptions) — the one copy-once parse of a whole options object — so the seven brokers, WorkersBroker and the envelope reader enforce one copy of every rule. WorkersBroker carries the caller’s headers on the envelope beside the two ids. Header names are bounded at 255 bytes, the AMQP limit, so a name is accepted on every broker or refused on all of them. The Pub/Sub adapter keeps at most 1024 idle Topic handles, evicting the least recently used one with nothing in flight. The docs state the guarantee honestly: orderingKey decides placement, not the order handlers finish in.

  • Outbox relay fencing (M107b). Versioned per-row leases and native conditional claims on every supported backend; held-version status writes, including immediate deletion, and a pre-publish time fence. Several relays preserve per-key first-publish order under the existing commit-order and writer-clock conditions; pauses after the fence and late broker acceptance remain duplicate windows. New relay.claimLeaseMs (30 000) and maxClockSkewMs (5 000); sweepDeadlineMs is now 30 000 with strict headroom over the claim/publish/status reserve. Unsupported conditional sources fail startup. The unreleased M107 schema requires 64-bit claimVersion and leaseUntil; old SQL DDL is refused and old JSON rows without the fields are poisoned as invalid-row. MAX_SAFE_INTEGER is exhausted; max-minus-one can be claimed exactly. The store bridge reads every integer bigint (Prisma Client’s type for a BigInt column) as a number, so a Prisma-mapped outbox is not poisoned row by row. not-pending.sentBy is removed, while the stored diagnostic remains. Health uses relay-overlap for fenced, claim-lost, duplicate, and outbox_overlaps_total uses kind instead of origin. A shared scheduler lock now saves redundant scans; its TTL rule no longer bears on correctness. Retry clears the lease and lastError and keeps the version; shutdown failures preserve the claim.

  • Transactional outbox (M107, @setu-ts/messaging-plugin). MessagingPlugin({ outbox }) writes an integration event as a row in the SAME database transaction as the business change — IOutbox.write(uow, definition, payload, input?) through the caller’s own unit of work, with the envelope, its ordering key and its deduplication id fixed at write time through one implementation shared with publishIntegrationEvent — and relays committed rows afterwards: at least once, per ordering key in write order among committed rows, and never exactly once. The relay is a scheduled job (outbox-relay) that reads the PENDING set in keyset-paged laps rather than a watermark (a row that commits behind the relay is still published — measured on real PostgreSQL), blocks a key behind a failed or backed-off row, bounds each sweep with one deadline, drains on shutdown before any close hook, purges settled rows on its own interval (outbox-purge, retainSentMs), re-parents each publish to the trace that wrote the row, and reports an outbox health indicator and optional metrics. IOutbox (write, dispatch, sweep, purge, release) is registered under CAPABILITIES.OUTBOX; the options are OutboxOptions (OutboxCommonOptions plus exactly one of store and stores, with OutboxStoreEntry), OutboxRelayOptions and OutboxHealthOptions; write takes OutboxWriteInput and sweep resolves OutboxSweepResult. Refusals are OutboxEnvelopeTooLargeError (inside the transaction, so the business write rolls back), OutboxRelayUnscheduledError (a scheduled relay with no CAPABILITIES.SCHEDULER), OutboxUnknownTenantError, OutboxRowStateError (release of a row that is not failed), OutboxNotReadyError and OutboxStoreVerifyTimeoutError (a store’s startup verify() did not settle within relay.storeTimeoutMs). On Cloudflare Workers the relay runs from a Cron Trigger with relay: { schedule: false } and background: waitUntil. Driven against real PostgreSQL with real RabbitMQ 4 and Redis Streams (including a process dying between a publish and its status write), a MongoDB replica set, DynamoDB Local, the Bigtable emulator, the Cosmos emulator, and the real OpenTelemetry SDK; the Workers composition is driven at unit level, not on real workerd.

  • The outbox store port in @setu-ts/common (M107). IOutboxStore — append, scanPending, failedKeys, claim, markInvalid, markSent, markFailure, release, stats, purge and verify, every method rejecting rather than throwing — with IOutboxWriteScope (the one-method slice of a unit of work the write needs, satisfied by database-plugin’s IUnitOfWork), the row shape OutboxRecord and OutboxStatus, OutboxKey, OutboxTransition, OutboxStoreStats, the 'setu-outbox' discriminator OUTBOX_RECORD_KIND, and the token CAPABILITIES.OUTBOX ('outbox', outbox.<name> for a named messaging instance). It is in common so a database package can implement it without importing the messaging plugin.

  • createDatabaseOutboxStore in @setu-ts/database-plugin (M107). The shipped IOutboxStore over IDatabaseService, as a RegistryFactory (DatabaseOutboxStoreOptions: entity, default 'Outbox', and database for a named connection). Every row carries kind: 'setu-outbox' and every read requires it, so an outbox sharing a table or Cosmos container with business documents never touches them; transitions write only from the expected status. Its verify() refuses a backend that cannot serve the relay with OutboxStoreUnavailableError, naming the reason: Bigtable, a DynamoDB entity without the { partitionKey: 'status', sortKey: 'position' } GSI, a standalone MongoDB, or a missing or unreadable table. The DDL for PostgreSQL and SQLite/D1 is in the messaging-plugin README.

  • Consumer inbox (M108, @setu-ts/messaging-plugin). MessagingPlugin({ inbox }) plus onIntegrationEvent(definition, handler, { inbox: { consumer } }) applies an integration event’s database writes once per consumer: a marker keyed by (consumer, topic, envelope id) is created in the SAME transaction as the handler’s writes, and the handler receives that transaction’s unit of work as a fourth argument (IntegrationEventInboxHandler, its type inferred from the annotation). A duplicate delivery is acknowledged without running the handler; a failed handler leaves no marker, so the redelivery runs it again; after any rejection the marker is re-read to tell a lost race from a real failure. The broker queue defaults to inbox. plus a 16-hex-character hash of the consumer and the topic. With the inbox option the call returns a RegistryFactory<SubscriptionDefinition> resolved at onInit after the store is verified; without it nothing changes. Optional maxAttempts counts failures outside the transaction and parks a delivery at the limit (so a Kafka partition or a NATS consumer moves on); IInbox lists parked deliveries and releases them (retry hands back the envelope to re-publish, discard keeps them skipped). A scheduled purge (inbox-purge, retainMs) — which deletes parked markers past the window too — and an inbox health indicator ship with it. Refusals: InboxNotConfiguredError, InboxConsumerConflictError (two handlers sharing a consumer name on one topic), InboxNotReadyError, InboxPurgeUnscheduledError, InboxStoreVerifyTimeoutError, InboxRowStateError. The types are InboxOptions (with InboxPurgeOptions and InboxStoreEntry), IntegrationEventInboxOptions, IntegrationEventSubscribeOptions, ParkedInboxEntry and InboxReleaseResult. Driven against real PostgreSQL with real RabbitMQ 4 and Redis Streams (duplicates, a failure rolled back and redelivered, two consumer groups, and an outbox row relayed twice handled once), a MongoDB replica set and the Bigtable emulator; D1 at unit level over real SQLite.

  • The inbox store port in @setu-ts/common (M108). IInboxStore — find, run, recordFailure, park, parked, release, stats, purge and verify, every method rejecting rather than throwing — with InboxRecord, InboxStatus, InboxIds, InboxFailureUpdate, InboxReleaseOutcome, InboxStoreStats, the 'setu-inbox' discriminator INBOX_RECORD_KIND, and the token CAPABILITIES.INBOX ('inbox', inbox.<name> for a named messaging instance).

  • createDatabaseInboxStore in @setu-ts/database-plugin (M108). The shipped IInboxStore over IDatabaseService, as a RegistryFactory (DatabaseInboxStoreOptions: entity, default 'Inbox', and database). Every row carries kind: 'setu-inbox' and every read requires it. Its verify() refuses Cosmos DB and Bigtable by adapter arm or class, then runs a two-row transactional probe that always rolls back, refusing a standalone MongoDB and a one-row-per- transaction adapter with InboxStoreUnavailableError. The DDL for PostgreSQL and SQLite/D1 is in the messaging-plugin README.

  • SpanOptions.root in @setu-ts/common (M107). An optional root: true starts a parentless span, ignoring both parentContext and the active span; @setu-ts/telemetry-plugin carries it to OpenTelemetry’s own SpanOptions.root. The outbox relay uses it for a row with no valid stored traceparent, so a sweep dispatched from inside a request no longer attributes that row to the request. An ITelemetryService implementation that ignores it starts the span under the active span.

  • DuplicateKeyError in @setu-ts/common (#420). A write that would duplicate a primary key or a unique index, branded 409 Conflict. It carries the targeted entity when known and the driver error as cause, and it is not retryable. It lives in common so @setu-ts/cloudflare-plugin’s D1 adapter, and any store that records “already processed” with a unique insert, can raise or recognise it without importing @setu-ts/database-plugin.

  • Scaffold testing (M101g, V8-12, V8-39). Generated factories return IKernelApplication, or its promise for starter compositions, so createTestApp({ app: await createApp() }) type-checks. setu add testing pins @setu-ts/testing in Deno’s import map or npm’s devDependencies. New Deno, Node, and Bun scaffolds include a smoke test of the real application composition; Workers requires its platform environment and permits an empty test suite. Devtool factory-upgrade instructions name the kernel type and import. Existing projects: replace the factory’s IApplication return annotation with IKernelApplication from @setu-ts/kernel (see docs/upgrading.md).

  • Wired scaffolds (M101g). Workspace full-stack SSR builds externalize library package identities — declared in deno.json or deno.jsonc, with their exported subpaths; projects that acquire npm dependencies ignore node_modules. Adding safe zero-configuration plugins wires both generator styles, starter compositions name their option arm, and dependency additions preserve the manifest’s existing order.

  • Development source policies and browser gate (M101g). Opted-in scaffolds and devtool enable generate names-only diagnostics approval maps, gated off in production; adding a plugin refreshes an existing policy. A dedicated pinned Chromium gate exercises both the example and a fresh scaffold, including missing-client and missing-asset controls. Full-stack configurations select same-origin referrer policy for native form actions; existing projects can configure httpSecurity: { headers: { referrerPolicy: 'same-origin' } }. CSRF checks stay enabled.

  • Devtool lifecycle (M101f). Enabling verifies both composition spreads and refuses mismatched framework pins. Pin and edited-entry refusals escape project-controlled fields so embedded newlines cannot forge additional CLI output lines. The entry detects dropped composition and stops with a named error. Workspace connectors use a recorded devtoolBasePort range, and application ports stay in their own sequence after a connector is allocated (skipping, never walking past, a connector port); reallocation updates CLI-rendered development entries and refuses edited entries before writes. Standalone defaults probe 4919–5019; the generated README reports the selected port; subsequent enablement retains the existing entry port even when availability changes. New entries are formatted; an unedited entry rendered by the 0.8.0 CLI is still recognized as CLI-owned, so reallocation and re-enabling upgrade it instead of refusing it as edited. Production images exclude main.dev.ts and remove the development connector pin before installing the remaining import map, including JSONC and inherited-import layouts. Diagnostics aliases now refuse Unicode format (Cf), line separator (U+2028) and paragraph separator (U+2029) characters through hasForbiddenAliasCharacter in common, in addition to controls (Cc), and the twelve refusal messages now read “contains a control, format or line-separator character”; replace bidi, zero-width and separator aliases with printable names. Re-enabling an unchanged workspace member now reports that nothing changed rather than “Enabled the devtool”. Launcher dependency: the setu-ts-devtool framework pin must be updated to the merge commit and its recipe catalog must retain the previous entry renderings, because the probe changes the generated entry text. This repository does not change that pin.

  • CLI write safety (M101e). Writing commands now cooperate with SIGINT and roll back partial batches, interactive cancellation exits 130, generation refuses non-project directories and workspace roots, JSONC manifests are read safely, and setu add refuses runtime-incompatible plugins. Interrupted adoption restores relocated project files as well as workspace writes; plugin commands stop awaiting their handler and run application shutdown before returning 130. Adoption supports prototype-based filesystem adapters and retains exit 130 when nested recovery fails. JSONC reads preserve comment token boundaries instead of accepting malformed literals.

  • BREAKING: Programmatic CLI prompts (M101e). Prompter.select now returns the discriminated PromptSelection type instead of a string or undefined. Implementors must wrap answers as { kind: 'answer', value } and return unavailable or cancelled for the two no-answer cases. This change ships in the 0.9.0 minor.

  • Tenant data-store bridge (M101c, V8-8). @setu-ts/database-plugin now ships the one ITenantDataStore over a real backend: createDatabaseTenantDataStore() returns a RegistryFactory the multi-tenancy plugin resolves in onInit, so DatabasePlugin may be registered before or after the tenancy plugin. It returns a DatabaseTenantDataStore over service.getRepository(entity), supporting the 'column-per-tenant' strategy only — the tenant column is conjoined to every read (spread last, so a caller’s filter cannot override it) and stamped on every write, and stripped from every update payload so no write can move a row between tenants. The column comes from the plugin’s isolation strategy ('tenant_id' by default, database: new ColumnPerTenant('org_id') for another), and 'schema'/'database' throw TenantStoreStrategyUnsupportedError because IRepository offers no schema or database switch. Lookups by key go through the repository’s own findById, so an entity whose primary key is not id is addressed the way its adapter is configured, and the tenant column is checked on the row. MultiTenancyPluginOptions.dataStore widens to ITenantDataStore | RegistryFactory<ITenantDataStore>; a factory is resolved once at onInit, validated, and handed the strategy, and the service’s late-binding slot throws TenantDataStoreNotReadyError for a repository call made before onInit. The port types ITenantDataStore and ITenantIsolationStrategy move to @setu-ts/common (the multi-tenancy plugin re-exports both), so the two plugins can type the bridge across the §2.2 boundary.

  • Session tenant binding, two-sided (M101c, V8-7). The binding compare now runs on whichever side sees the tenant second: the session middleware keeps its load-time compare, and the tenant middleware compares the session it finds in ctx.state right after it resolves a tenant. Both sites share the pure tenantBindingMismatch(session, tenantId) in @setu-ts/common, which also owns the keys the two packages must agree on byte-for-byte: SESSION_STATE_KEY, SESSION_TENANT_BINDING_KEY, and SESSION_TENANT_BINDING_STATE_KEY, which the session middleware sets to true when tenantBinding is on. The tenant side compares only then, so SessionPlugin({ tenantBinding: false }) disables both compare sites in every middleware order.

  • CSRF path exclusion (M101c, V8-9). CsrfOptions.exclude (http-security) lists paths — exact string or RegExp — that skip the CSRF check entirely, checked before the method test so an excluded path is never inspected; it matches the session plugin’s existing CsrfFormOptions.exclude. The documented SAML recipe becomes exclude: ['/auth/<provider>/acs'] on BOTH plugins, because an origin allowlist cannot admit an IdP that serves Referrer-Policy: no-referrer (Keycloak does): the browser posts the ACS with Origin: null, and trustedOrigins: ['null'] would admit every opaque-origin POST on every route.

  • Versioning policy from 0.9.0, with two gates. A patch is the normal release and breaking changes are batched into an occasional minor (README “Versioning”, ROADMAP “Versioning Policy From 0.9.0”). release:verify gains check 10 (scripts/release-shape.ts): it classifies the bump from the previous tag and refuses a patch whose shipping changelog section carries a BREAKING entry, and a minor whose section carries none unless --allow-quiet-minor is passed. deno task check:plan requires every plan’s “Exported surface” section to carry a **Breaking for implementors:** statement — none, or the break and the minor that carries it. No package source changed.

  • Release tooling: release:bump and release:verify check 9. deno task release:bump <version> moves every site a version bump has to touch — discovered by package name wherever a reference carries one, enumerated with its reason where it does not — and re-sweeps the result with check:versions’ own reader; --dry-run prints the plan. release:verify gains check 9 (scripts/changelog-prs.ts): every pull request merged since the previous tag is represented in the shipping changelog section, and no milestone PR’s entry sits under an already-published heading — the two manual release-cutting checks that each saved a release. CI’s publish-dry-run job checks out full history so the merge log is reachable, and the release workflow calls the release:verify task rather than an inline copy. No package source changed.

  • Service-call agreement (M101d). The SDK adds createTraceContextInterceptor and ClientRetryPolicy; the full-stack starter adds fullStackConfigOf and FullStackConfigUnavailableError for the exact composition snapshot; and contextToTraceparent accepts the new TraceparentSource shared by both telemetry context shapes.

  • withDeadline, deadlineRangeError and DeadlineOptions in common (M101a). A bound on one backend call whose expiry is a REJECTION with the caller’s own error, never a swallowed timeout: the call receives an AbortSignal, is raced against the deadline in case it ignores the signal, and its own rejection is never masked. It is the request-path counterpart of createCachedProbe, which never rejects. deadlineRangeError lets an option holder refuse a bad bound at construction.

  • New bound options (M101a). CachePlugin’s Redis store and QueuePlugin’s 'redis' adapter take commandTimeoutMs (default 15000, 0 disables), applied as the ioredis commandTimeout on the client they build. SchedulerPlugin’s DistributedLockOptions take acquireTimeoutMs (default 5000, 0 waits) and commandTimeoutMs (default acquireTimeoutMs, or 15000 when that is 0). The vault secrets provider takes requestTimeoutMs (default 5000). Each commandTimeoutMs applies only to a Redis client the plugin builds, never an injected one; acquireTimeoutMs bounds every acquire, an injected lock included, and requestTimeoutMs bounds an injected http too. An out-of-range value — including NaN, which Number(env.X) yields for an unset variable — throws RangeError at startup.

  • SecretProviderUnavailableError (M101a). Exported by @setu-ts/secrets-plugin and carrying a 503 status hint, so an application running errorHandler answers an unreachable provider with a retryable 503 instead of a masked 500. The transport error is kept as cause and never reaches the response body.

  • Mail bodies rendered through the view engine (M102). MailPluginOptions.templates gains a component arm beside the released string arm: { view, text? }, where view and text are Components (a JSX function, an html tagged template, or a plain (props) => string) rendered through the IViewEngine registered under CAPABILITIES.VIEW, with sendTemplate’s data passed verbatim as each component’s props. MailPlugin declares CAPABILITIES.VIEW in optionalDependencies and resolves the engine once at register(); a component template configured with no provider fails at register() naming both remedies, never on the first send. MailTemplate is now the union MailStringTemplate | MailComponentTemplate (both exported), and the two arms cannot mix in one template (a compile error). Escaping is the rendering runtime’s, and the component arm performs no missing-key check — an absent key renders as undefined. The committed IMailer contract is unchanged, so every holder calling sendTemplate gets the arm with no code change.

  • @setu-ts/localization-plugin (M103). A new package: LocalizationPlugin registers an ILocalizer under the new CAPABILITIES.LOCALIZATION, validates every catalogue at register() (a malformed or Intl-unknown tag, a catalogue for an unlisted locale, a malformed message, and a locale missing default keys are refused by name; allowPartialCatalogues downgrades the last to one warning per locale), and resolves each request’s locale at middleware priority 45 — query parameter, cookie, Accept-Language (bounded parse, q-values, de-AT → de, q=0 honoured under *), a tenantLocale default, then the default — matching every candidate against the supported set only. Every governed response carries Vary: Accept-Language (plus Cookie while the cookie source is on, a stated CDN cost that middleware.cookie: false removes) and a Content-Language written after the handler from the final locale. localizerFor(ctx) binds the localizer to the request; localeMiddleware registers the resolution per route group; exposeCatalogues serves GET <basePath>/:locale for browsers, private, max-age=3600 by default (a session refreshing its cookie must never reach a shared cache); MissingMessageError, MissingPluralCountError and UnsupportedLocaleError are exported, with the option types LocalizationPluginOptions (exactly one of catalogues and source, a compile error otherwise), LocaleMiddlewareOptions and IMessageSource (catalogues loaded once at register()). The formatter and locale negotiation ship as the import-free subpath @setu-ts/localization-plugin/format (format, negotiateLocale, parseAcceptLanguage, and the types FormatValues, FormatOptions and AcceptLanguage), whose runtime graph a test confines to its own modules. It escapes nothing — escaping is the renderer’s — and promises one implementation, not identical output across runtimes: Intl data differs, so dates take an explicit timeZone. Zero npm dependencies.

  • Localization contracts in common (M103). CAPABILITIES.LOCALIZATION, ILocalizer, LocalizationMessage, PluralForms and MessageCatalogue, so a plugin formatting text for a person resolves the localizer without importing the localization plugin.

  • IRequest.locale and replaceLocale in common (M103). The request’s resolved BCP 47 tag is a first-class optional field on the tenant precedent, sealed by the same one-implicit-write guard (a second plain assignment throws naming replaceLocale), with replaceLocale(request, tag) as the deliberate escape — for example, to apply a signed-in user’s saved preference once authentication has run. Optional and source-compatible: no IRequest implementor breaks. A reader running below priority 45 sees undefined, exactly as one below 40 sees no tenant.

  • @setu-ts/testing’s createTestContext carries a seeded request.locale (M103), sealed as the kernel seals a request, so a test seeding a locale gets it — and the same one-write guard.

  • Three named real-broker errors in @setu-ts/messaging-plugin (M101b). PubSubSubscriptionBoundElsewhereError, NatsConsumerNameCollisionError and KafkaTopicUnavailableError, each thrown by subscribe() (so a declared subscription rejects start()) for a naming or startup rule the real broker imposes and a permissive fake accepted.

  • KafkaOptions.retry / the kafka arm’s retry (M101b). Forwarded to new Kafka({ retry }) unless a client is injected, and read by subscribe() as its unknown-topic retry budget. Every field is validated at construction, factor (kafkajs’s jitter) included, held to [0, 1].

Changed

  • The installation documentation teaches npm. 0.9.0 is published to npm only, while the README, PUBLIC_API.md, the getting-started, runtime-deployment, decorators and custom-plugins guides and eighteen package READMEs still taught jsr: installs — pins that could never resolve at 0.9.0. Each install line is now npm install @setu-ts/<pkg> (bun add, or deno add npm:@setu-ts/<pkg> on Deno), and the examples import hono rather than @hono/hono. PUBLIC_API.md’s description of setu add pins, the Workers manifest and the workspace root no longer mentions the @jsr scope or an .npmrc, which the CLI stopped writing in M111d. Earlier releases stay on JSR.

  • Breaking: node is the default generated target, and every target installs the framework from npm (M111d). setu new and setu generate app without --runtime now scaffold a Node project; pass --runtime deno for the previous default. Generated manifests pin @setu-ts/* from npm under their own names — ^0.9.0 in package.json, npm:@setu-ts/…@^0.9.0 in a Deno import map — with no .npmrc and no @jsr/ aliases. A Deno project sets nodeModulesDir: "auto", because the CLI imports its config from Node: run deno install before setu <plugin-command>, which otherwise refuses by name. A Deno project pinning the framework from JSR is refused the same way, pointing at the upgrade guide; setu adopt rewrites pre-0.9 pins (jsr:@setu-ts/…, npm:@jsr/setu-ts__…) onto npm and reports each. A directory with no manifest now reads as Node rather than Deno wherever the CLI detects a target.

  • Breaking for embedders: @setu-ts/cli/main (M111d). MainProcess.prompt is asynchronous, (message, signal?) => Promise<string | null>, so an interrupt ends a pending question instead of waiting for Enter; denoProcess is removed and nodeProcess replaces it. Both executables set process.exitCode rather than calling process.exit.

  • Lazy-import error messages and reported specifiers name the bare package (M111a). Source imports ioredis, not npm:ioredis@5.x, so GrpcRuntimeLoadError.specifier, the telemetry instrumentation loaders’ reported specifier, SAML_SPECIFIER and the React Router load failure now read @connectrpc/connect, @opentelemetry/instrumentation-http, @node-saml/node-saml and Failed to import 'react-router'. Code matching the old npm:… strings must match the bare name. The install guidance in those messages is unchanged and still names deno add npm:… for Deno.

  • Worker pool sizing (M45c, #433). WorkerPoolPluginOptions.maxWorkers now bounds the sum of module pools, defaulting to the largest of available parallelism, defaultPoolSize and the sum of explicit pool sizes. Multi-module applications share the cap fairly; pass maxWorkers: Infinity to restore the previous unbounded sum. Health reports the budget.

  • A generated full-stack setu.config.ts takes an ssr parameter, and its vite.config.ts exports frameworkPackages and workspaceLibraries (@setu-ts/cli, PR #426). dev.ts passes the development SSR runtime as createApp’s third argument, which replaces assetsDir; omitted, production behaviour is unchanged. Workers projects declare the parameter as _ssr and read none of it. Existing projects are unaffected; to adopt the dev loop, copy those changes and dev.ts from a fresh scaffold.

  • BREAKING: DiPlugin() now defaults autoRegister to true (@setu-ts/di-plugin, PR #426). With the old false default, a bare DiPlugin() could not resolve any framework capability, so @Inject(CAPABILITIES.LOGGER) or CAPABILITIES.CACHE failed at startup with “No provider registered for DI token”. The container now falls back to the kernel ServiceRegistry for a token it does not hold; explicit container registrations still win. Migration: pass DiPlugin({ autoRegister: false }) to keep resolution confined to the container. A starter’s di: {} arm passes its options through, so @Inject(CAPABILITIES.LOGGER) — the pairing both starter READMEs show — now resolves there too. createContainer() outside the plugin is unchanged.

  • BREAKING: RabbitMQ consumer retry defaults (#421). Durable messaging consumer groups now retry failures with five total attempts and tiered delays, create durable retry/dead queues (retry queues are quorum queues with at-least-once dead-lettering, so an expiring copy is never dropped), and bound unacked deliveries to prefetch 32 per consumer. Handlers must be idempotent. Configure consumerRetry: false for the existing operator-DLX behavior; private queues and RPC reply inboxes retain discard behavior. ConsumerRetryOptions names the shared RabbitMQ/Redis shape. Retry topology reserves group suffixes .dead and .retry.<digits>ms; rename conflicting groups or disable retries. Generated names must fit 255 UTF-8 bytes. Injected recovery channels require confirms, on/off return listeners and close(); without them subscribe() on a retrying group rejects. The RabbitMQ user needs configure permission on Q.dead/Q.retry.<delay>ms, read on Q.retry.<delay>ms, and write on amq.default; otherwise the helper declarations fail with 403.

  • BREAKING: Redis Streams messaging recovery (#419). Failed messages now retry across restarts; handlers must be idempotent. Redis ≥6.2 is required. Injected IRedisStreamsClient facades must implement xpending, xclaim, xinfo, and xgroup('DELCONSUMER', ...). Configure consumerRetry, reclaimIntervalMs, deadLetterMaxLen, and consumerIdleSweepMs; see docs/upgrading.md.

  • Generated guards, class-based jobs and setu add on a Deno project (M101g, V8-32, V8-33, V8-40). These change what the CLI writes or refuses. setu generate guard <name> now emits requirePermission('<name>') from @setu-ts/auth-plugin where it used to emit a stub that let every authenticated caller through. The permission is evaluated by the authorization service AuthPlugin registers only when given an rbac option, so without one every authenticated request to a guarded route answers 501. setu add auth therefore prints AuthPlugin({ jwt: { … }, rbac: { roles: {} } }). Guards generated earlier are unchanged. In a class-based project without @setu-ts/queue-plugin, setu generate job now refuses, naming setu add queue, where it used to write an unwired function. On a Deno target setu add no longer writes an npm:@jsr/… entry into package.json, which there holds only the frontend build’s own npm dependencies; a Node or Bun project is unchanged. setu add and setu devtool enable now edit setu.config.ts only when they can read its meaning from the file alone; otherwise they leave it untouched and print the registration line to add by hand. A configuration that imports a starter or whose import map points a @setu-ts/ name at another package, a computed member access such as x['k'], an import from a package that is not a framework package or a relative file, and template literals or escapes in code all get the printed line instead of an edit. The managed src/devtool/diagnostics.ts declares every source key, so a row it stops emitting still type-checks where setu.config.ts spreads it. Import maps are read from the project’s manifests, any importMap file and the workspace root. A backplane options object that names no transport no longer receives the development policy, and a configuration that binds its own sources gets the printed line rather than the CLI’s declaration.

  • RabbitMQ publish() and queue.add() now wait for the broker (#418). They resolve only once RabbitMQ has accepted the message, which costs one broker round trip per publish, and they can now reject — when the broker refuses the message, the channel closes first, or publishTimeoutMs expires — where they used to resolve unconditionally. See docs/upgrading.md.

  • The memory adapter refuses a duplicate primary key (M101c). A create whose caller-supplied primary key is already stored now rejects (Entity '<name>' already has a row with this primary key), in and out of a transaction — a commit re-checks its buffered creates against rows committed since and writes nothing on a collision — where it used to store a second row that no findById, update or delete could address. Every real backend already refuses this; a test that relied on inserting the same key twice must use distinct keys or let the adapter generate them.

  • The memory adapter’s primary key is immutable (M101c). An update whose payload changes a primary-key value now rejects (Entity '<name>': an update cannot change the primary key), in and out of a transaction; restating the same value is allowed. Rewriting a key onto another row’s key recreated the duplicate the entry above refuses. To change an identifier, delete and recreate the row.

  • The tenant data-store bridge’s find accepts equality filters only (M101c). A filter key starting with $ or a non-scalar value rejects with a TypeError, because MongoDB read them as query operators ($where ran server-side JavaScript). An update whose written row turns out to belong to another tenant — possible only if a key is reused across tenants between the ownership check and the write — rejects instead of returning the row.

  • A tenant-bound session is never re-bound (M101c, V8-7). The session plugin now seals the tenant only into a session that carries no binding yet. Previously a session whose tenant was resolved after the session loaded could be re-sealed to a different tenant on commit, so a refused request re-bound the session to the tenant it was refused under. A regenerated session still adopts the current tenant, since regenerate() drops the binding.

  • SAML ACS binding checked before the request is consumed (M101c, V8-25). The binding compare moves into the per-request request-cache adapter’s getAsync, the first point at which the library has parsed the response’s InResponseTo and before any consumption. A refused peek sets a per-request flag that makes removeAsync return without consuming — load-bearing, because node-saml calls removeAsync on its failure path and on an unmatched InResponseTo, which is how a foreign post consumed the victim’s request. A refused peek now answers state-invalid (the code the case already uses) instead of assertion-invalid.

  • cloudflare-plugin’s cacheApiMiddleware keys a localized request on its locale (M103). Its key is a URL string, which the Cache API matches with no request headers, so Vary could never separate locales there and one locale’s page was served to everyone for the TTL. When ctx.request.locale is set, the default key is the URL text as sent with a setu-cache-locale parameter naming the locale concatenated after it (on the key only; a client-supplied copy and every encoding variant stay distinct in the key, so a client can reach neither another locale’s entry nor an entry another URL is served from). Without a locale the key is unchanged, except in the last case below. Four kinds of request are now served uncached, because no key can keep them apart from another: a URL whose text is not in its parsed form (Deno, and Node for some targets, hand the handler the request target as sent — /a/./b, a raw " in the query — while the Cache API parses a key before matching, so such a request could fill its normalized sibling’s entry), a URL carrying a fragment (Deno, Node and Bun hand it to the handler, while the Cache API ignores it when matching), a locale that is not well-formed UTF-16, and a request with no locale whose URL already contains setu-cache-locale= (its key would equal a localized request’s). Workers normalizes the URL and strips the fragment, so the first two do not arise there. As for cache-plugin, the middleware must run after the locale middleware, and a handler-time replaceLocale is not reflected. A custom key is untouched and must include the locale itself.

  • cache-plugin keys on the resolved locale (M103). The cache key gains a length-prefixed locale segment from ctx.request.locale, between the tenant and vary segments, so one locale’s cached body is never served to another. An application without the localization plugin has no locale on its requests and keeps byte-identical keys. The segment is read when cacheMiddleware runs, so it must run after the locale middleware: a GLOBAL cacheMiddleware needs a higher priority number than that middleware’s (45 by default, or the configured middleware.priority), and where the locale middleware is applied per route, the cache must be on that route after it. A replaceLocale override is reflected only when it runs before the lookup.

  • SDK-generated clients keep an error arm for every declared 3xx, the auto-follow statuses (301, 302, 303, 307, and 308) included, since Fetch returns one unfollowed when it carries no Location. Operations that may follow a redirect return unknown. Retry policies now cap honoured Retry-After delays (retry.maxRetryAfterMs, defaulting to the policy’s largest computed backoff) and surface the original response error immediately when the hint exceeds the cap. Client construction rejects invalid/non-finite retry counts, delays, and derived exponential backoffs so malformed configuration cannot disable that cap; delays above the portable JavaScript timer maximum are also rejected so runtimes cannot clamp a long wait into an immediate retry. The client keeps a frozen copy of the validated policy, so mutating the caller’s retry object afterwards no longer changes the cap.

  • Every backend call M101a covers is now bounded by default, and an expired bound is a recorded failure. A paused or partitioned Redis keeps its socket open, so a cache, queue or scheduler-lock command used to wait forever. It now rejects inside the bound: a cache call is counted failed and the cache indicator reports down; queue.add() rejects and a poll records the failure — though the server may still apply a timed-out command, so a rejected add() can have enqueued the job and a retry can run it twice; a lock acquire that does not settle is a skipped fire, logged, counted lockFailed and re-armed for the next slot, so a paused backend skips fires instead of stopping the schedule. A token an abandoned acquire returns later is released, and RedisLock releases the exact token of a timed-out SET, which can still apply once Redis answers. Verified against a real paused Redis 7.

  • RedisQueue transitions are atomic (M101a). enqueue, reserve, ack, requeue and dead-lettering each run as one Lua script when the client exposes eval — always true of the client the adapter builds. They used to run as separate commands, so a command that timed out locally but was applied by the server afterwards could leave a reserved job in neither the ready nor the processing set, lost with its payload still stored. An injected client without eval keeps the separate commands. A reserve the server applies after its local timeout still leaves the job in the processing set, and nothing reclaims it, so it needs moving back by hand. Verified against a live Redis 7.

  • A Vault request that fails on the network or does not answer in requestTimeoutMs now rejects with SecretProviderUnavailableError (503) instead of a plain error served as a masked 500. The bound covers reading the response body too. connect() now refuses an address that is not an absolute http:/https: URL, so a malformed address fails at startup rather than as a 503 on every read. A Vault that answers with an HTTP error keeps its handling: 404 reads as null, any other error status rejects with a plain Error.

  • The Vault provider encodes secret names and caps response bodies (M101a security audit). Each /-separated segment of a name is percent-encoded, and a name with an empty, . or .. segment is refused before any request — ../../sys/health used to reach another Vault endpoint with the token attached. A name that relied on a literal %, ? or # reaching Vault unencoded now reads a different path. A name longer than 4096 characters once encoded is refused as an input error instead of failing in the transport and reading as an outage (503). A read body over 1 MiB is refused with a plain Error, and the memory a read holds stays within that 1 MiB however small the chunks a dependency sends. Secret names quoted in secrets-plugin error messages have C0 and C1 control characters, DEL, and U+2028/U+2029 escaped, so a name cannot start a new log line, and quoted only up to its first 256 characters.

  • The database indicator tells pool saturation from an outage (M101a). When a Drizzle registration’s poolStats reports every connection busy with callers waiting, the probe queues no SELECT 1 and the indicator reports up with reachable: 'unknown', so /ready does not pull every saturated replica at once — but only while queries through the adapter keep completing (one within the last 10 seconds). A full pool with no completed query is a hung database and reports degraded. Once getDrizzleDatabase or getDrizzleTransaction has handed out the native instance, its queries bypass the adapter, so progress is unobservable and a saturated pool reads up. A pool connection timeout now reports degraded rather than down — the probe used to answer false for it, though the database never answered either way. Verified against a live PostgreSQL 16 pool.

  • A retained Service Bus outage is answered at once (M101a). With a recorded network failure, reachability() now returns false immediately and runs the management probe in the background, where a true answer clears the outcome for the next read. It used to await that probe, whose 2-second bound tied the indicator’s, so the indicator’s bound fired first and a recorded outage was reported up.

  • Queue depth observations drop a row the latest cycle did not read (M101a) instead of keeping the previous count. The source’s failure and depthCoverage: 'partial' say why; an unreadable depth is never reported as a retained zero.

  • BREAKING: TemplateEngine.render is asynchronous (M102). The exported class’s render(name, data) now returns Promise<RenderedTemplate>, because IViewEngine.render may answer a promise and both template arms share one lookup; every refusal (unknown template, missing placeholder key) is now a rejection rather than a synchronous throw. MailService is unaffected (it awaits). A direct caller of TemplateEngine adds an await; the constructor also gains an optional second parameter, the view engine, which the plugin supplies. See docs/upgrading.md.

  • BREAKING: the log and SendGrid mail providers refuse a send while not connected. Both accepted a send outside connect()..disconnect(), unlike the SMTP and SES providers: a send after app.stop() reported success on the log provider and still POSTed a real email through SendGrid. Both now reject with 'LogProvider is not connected' / 'SendGridProvider is not connected'. Through MailPlugin nothing changes before stop(), since the plugin connects the provider during register(). A test constructing LogProvider directly must now call await provider.connect() before sending.

  • BREAKING: MailTemplate is a union type, no longer an interface (M102). It is now MailStringTemplate | MailComponentTemplate, so interface X extends MailTemplate fails with TS2312 and class Y implements MailTemplate with TS2422. Extend or implement MailStringTemplate instead, which has the released { html?, text? } shape. A value typed MailTemplate, and every object literal assigned to one, compiles unchanged.

  • BREAKING: a Pub/Sub subscription with no queue is named per topic (M101b, V8-2). The default is <defaultQueue>.<topic ID> (messaging-consumers.<topic>; a fully-qualified topic name contributes only its ID, since / is illegal in a subscription ID) instead of one shared messaging-consumers. Subscription names are project-global, so the shared default attached a second topic to the first topic’s subscription — its handler consumed the other topic’s messages with no log — and the RPC channel of one run to the previous run’s. An existing subscription bound to another topic is now refused with PubSubSubscriptionBoundElsewhereError rather than attached to, and a name over 255 characters is refused at subscribe(). Migration: an existing messaging-consumers subscription keeps its backlog with no consumer after upgrade; pass SubscribeOptions.queue: 'messaging-consumers' on the ONE topic that owns it until it is drained, then delete it.

  • A Kafka subscription to a topic that does not exist yet retries, then is named (M101b, V8-26). A KRaft broker with auto-creation answers UNKNOWN_TOPIC_OR_PARTITION to the request that creates the topic — measured on Kafka 4.0 — and kafkajs does not retry that, so such a subscription died at boot with a raw KafkaJSProtocolError naming no topic. subscribe() now retries that one error within retry (kafkajs’s defaults, about 9 s) and the topic is found; a broker that does not auto-create rejects after the budget with KafkaTopicUnavailableError. A consumer that failed to join is disconnected rather than leaked, and a rejected run() (kafkajs’s crash handler rethrows a disconnect that fails) is reported through KafkaOptions.logger (its first reader) instead of escaping as an unhandled rejection.

  • A NATS consumer records its raw queue as setu.queue metadata (M101b). Consumer metadata needs NATS 2.10 or later; on an older server every subscribe() now rejects.

Fixed

  • release:bump completes, and leaves history alone. The task granted run access to git only, so a real bump rewrote every file and then died with NotCapable before regenerating the member manifests. It also rewrote the versions recorded in archived plans under plans/archive/ and the fixtures of a colocated *.test.ts; both are now left as written.

  • setu adopt moves a project’s deno.jsonc (M111d). Adoption moved deno.json but left a deno.jsonc at the repository root, beside the workspace’s new deno.json, so the member had no manifest and the project’s own was ignored. It now moves with the member, and its pre-0.9 pins are rewritten in place like a deno.json’s, comments kept.

  • setu generate custom <name> refuses a path-like schematic name (M111d). ../../x or a percent-escaped %2e%2e segment imported, and ran, a module outside .setu-ts/schematics/. A name is now letters, digits, - and _, and anything else exits 2 naming the rule.

  • Consolidated SSE writes and cache coalescing settlement cleanup, preserving closed-connection and owner-isolation behavior across runtimes.

  • Node filesystem removal now removes an empty directory without recursive deletion, so CLI rollback can restore the original workspace while preserving nonempty directories.

  • Messaging transport headers preserve own __proto__ fields and framework-header precedence without invoking the object prototype setter.

  • A streamed response without a content-type no longer reaches Node clients labelled as text (M111b). @hono/node-server writes text/plain; charset=UTF-8 for any body that lacks a content-type, so ctx.response.stream(...) with no type set arrived as text on Node while Deno, Bun and Workers send none. The Node adapter now removes that default whenever the framework response is a byte or stream body that sets no type, matching the Fetch standard, which implies a type only for a string body. Explicit types (send, text, json, a set header) are unchanged. A Node client that relied on the injected text/plain must now set the header itself. A body with no type can now be MIME-sniffed by a browser, as it already could on the other runtimes: set a type on any streamed body that may carry untrusted bytes, or send X-Content-Type-Options: nosniff. NodeServeHost.serve’s fetch callback gains an optional second env parameter (node-server’s { outgoing }); a host that never passes it is unaffected.

  • MongoDB refuses an operator-shaped key (@setu-ts/database-plugin, M105). Since M78 a key value that was an object — findById({ $ne: 'x' }), or such a value inside a composite key — reached the driver filter and was read as a query operator, addressing other documents. Every keyed operation now rejects a key value that is not a string or number, before any driver call, without quoting it.

  • Cosmos DB and DynamoDB refuse a __proto__ payload field (@setu-ts/database-plugin, M105). A JSON payload carrying an own "__proto__" key reached create/update unchecked. On Cosmos (since M81) every row read back then carried that object as its prototype on Node and Bun (row.isAdmin === true), because the read mapping assigned keys; on DynamoDB (since M80) the row became permanently unreadable on every runtime, because the AWS SDK returns a __proto__ attribute with no value. Both adapters now refuse such a field on write without quoting it — at any depth on DynamoDB, whose SDK drops nested ones too — and their read paths no longer let a stored one (written by anything) inject a prototype or crash the read: Cosmos keeps it as an ordinary field, and DynamoDB skips the valueless attribute the SDK delivers (one an injected client delivers with a value stays an own property).

  • Bigtable and DynamoDB field maps read own entries only (@setu-ts/database-plugin, M105). A field named after an inherited member (constructor, toString) resolved the inherited function as a column address or date encoding; Bigtable then threw a TypeError. Both maps are now null-prototype records, so such a field is an ordinary unmapped field.

  • Worker lifecycle and example hardening (M45c, #433). Expired pending tasks reclaim excess starting slots; stale startup callbacks cannot reject unrelated work. Termination throws and rejections are contained, and shutdown bounds its wait to one second per termination, including retired workers. NaN legacy sizes no longer poison the default shared budget for valid modules. The example binds loopback and uses scoped Deno permissions. The budget limits managed slots, not physical worker exit when a host fails to terminate.

  • Worker spawn failures (M45c, #433). A synchronous host.spawn throw now rejects the oldest queued task as a crash, clears its timeout and holds no budget, instead of leaving a ghost task queued after its caller rejected. Remaining queued tasks continue scheduling after a failed spawn, including from a budget hand-over. Hand-over continuations cannot leak the spawn error.

  • Worker slots held by workers that never start (M45c, #433). A spawned worker that does not signal ready within the new WorkerPoolPluginOptions.startupTimeoutMs (default 10 000 ms, applied even with taskTimeoutMs: 0, not disableable, and refused above 2 147 483 647 ms because a larger timer delay overflows and fires at once) is terminated, its slot returns to the shared budget, and the oldest waiting task for that module rejects with WorkerTaskError (remoteName: 'WorkerStartupTimeout'). Before this, one call to a module that never became ready held a shared slot until restart with task timeouts off, and under steady demand starved every other module even with them on; a queued task expiring now also yields a starting worker to a module with no worker. A worker whose listeners fail to attach is no longer charged a slot. register() warns when taskTimeoutMs is 0 under a finite maxWorkers, because a task that never settles keeps its slot. Every sizing and timeout option, including each own pools entry, is read once into a validated snapshot that the pools use (an inherited pools entry is ignored; a null entry means no overrides), a per-call timeoutMs is read once, refused values are rendered bounded and escaped, a Symbol maxWorkers is refused with the documented RangeError, and an Infinity legacy pool size no longer makes the derived default unbounded. Breaking: taskTimeoutMs (plugin-wide and per pool) and the per-call timeoutMs must now be 0 or a positive integer no greater than 2 147 483 647. NaN and negative values used to disable the timeout silently and larger values timed every task out after about 1 ms; they now throw RangeError at construction or reject that run(). Pass 0 for no timeout. The sizing documentation now states that the bound counts slots, not threads: on Deno a timed-out CPU-bound task keeps running, and can keep writing to a SharedArrayBuffer, after its promise rejects.

  • Idle worker crashes are reported (M45c, #433). A worker error that settles no task (an idle worker crashing, or a startup crash with nothing queued) is now logged by the worker-pool plugin as a warning naming the task module; before, it rejected nothing and was visible nowhere.

  • Prometheus label values cannot split exposition lines (metrics-plugin, M45c, #433). Control characters the 0.0.4 text format cannot escape (carriage return, NUL, the rest of C0 and DEL) are encoded in label values and HELP text as U+FFFD plus two hex digits (a lone UTF-16 surrogate as U+FFFD, u and four hex digits), with a literal U+FFFD doubled, so distinct values never collapse into one sample line. A worker-pool task_module label carrying a raw CR or NUL passed straight through before.

  • A Deno task module that throws at import no longer kills the host process (runtime, M45c, #433). The web-worker host now cancels the worker error event after reporting it to the pool. Without that, Deno re-raised it in the parent as Unhandled error in child worker and the application exited.

  • Two JSDoc corrections (M107). ITelemetryService.withSpan in @setu-ts/common named a parentSpan option; the option is parentContext. MongoAdapter.beginTransaction in @setu-ts/database-plugin said a standalone server fails there; measured against a standalone mongo:8, the real driver’s startTransaction() resolves and the refusal surfaces at the FIRST operation inside the transaction as the driver’s own MongoServerError (code: 20, IllegalOperation), unwrapped — MongoTransactionUnavailableError therefore never fires on the real driver (a behaviour fix left for a fix/… branch). The outbox’s startup check detects that code itself.

  • A capability lookup that misses now names the actual cause (@setu-ts/kernel, PR #428). The error said “Register a plugin that provides it, or check the token spelling” for every miss, which is wrong in the two commonest cases. Before start(), the plugin is listed but has not run, so the error now says the application has not started and to resolve the service in a handler, in a lifecycle hook, or after await app.start(). During a plugin’s register(), a capability that a later plugin provides now names that plugin and says to add the token to the consumer’s dependencies or optionalDependencies. Every other miss keeps the old advice, and the first sentence of the message is unchanged.

  • A full-stack application could not start on Cloudflare Workers (@setu-ts/full-stack-starter, PR #426). The starter always registered SchedulerPlugin, which refuses Workers at register(), so createFullStackApp(), and every project from setu new --template full-stack --runtime cloudflare-workers, threw SchedulerUnavailableError before serving a request. The starter now leaves the scheduler out on Workers unless a scheduler arm is passed; schedule there with cloudflare-plugin’s WorkersCron. Verified by booting the composed set with the runtime forced to cloudflare-workers on Deno, not on workerd. The starter README’s Workers section, which said the starter does not run there, is corrected.

  • A newly scaffolded full-stack project’s smoke test now also requests / (@setu-ts/cli, PR #426). populateLoadContext runs only on an SSR request, so any DI misconfiguration left /health at 200 while every page answered 500 and the generated deno task test passed over it. The emitted test/app.test.ts now fetches / through app.fetch — an SSR body streams, which inject() refuses — and asserts 200; other templates are unchanged.

  • export default { fetch: app.fetch } answered every Workers request with 500 (@setu-ts/kernel, PR #426). It is the Workers entry the cloudflare-plugin README, PUBLIC_API.md, the rest-starter README and the Workers adapter’s own listen() error all name, and Cloudflare calls it as exported.fetch(request, …), so this was the exported object and Application.fetch threw Cannot read private member #registry. Measured on workerd. The application now binds fetch at construction, so it works passed around as a bare function.

  • A fresh full-stack project on Node or Bun crashed on its first start (@setu-ts/cli, PR #426). setu new --template full-stack --runtime node (or bun) prints npm install && npm start, and the generated README says the same, but the start script ran tsx main.ts (or bun run main.ts) without building first, so the React Router plugin failed with “Failed to load React Router server build” because build/server/index.js did not exist. start now runs the build first, as the Deno start task and the npm test script already did. An existing project can prefix its own start with npm run build && (or bun run build &&).

  • A generated README now says what the project serves (@setu-ts/cli). A new section lists each route a fresh project answers, such as /docs (Swagger UI), /openapi.json, /health and /metrics, with the address to reach them. The scaffold e2e requests every listed path on a booted project, so the list cannot name a route that does not answer.

  • The runtime deployment guide’s setup commands did not run. npm install jsr:…, npm add jsr:… and bun add jsr:… all fail, and wrangler init --type takes no --type; the guide now uses npm install / bun add against the npm packages (see Changed) and setu new. It also showed a package.json imports block Node cannot use, a node:20 Docker base below the documented Node 22 floor, Workers startup that cached a failed start for the isolate’s life, ctx.runtime in request handlers (where ctx has no runtime), and a 120-second Workers execution limit that does not exist: an HTTP Worker has no wall-clock limit, and its CPU time is what is capped. Each startup retry constructs a fresh application, because the kernel refuses restarting one after plugin registration has begun.

  • The getting-started guide’s CLI install command did not work. It omitted -g, which Deno 2.9 requires for a global install, and -n setu, without which the binary is named cli and every setu … command in the guide is not found. It now matches the other four documented sites, and a test fails if any documented install command drifts from them.

  • An absent header resolves to undefined, and a validated query can be typed (@setu-ts/decorator-plugin, PR #426). @Params(Header('X-Name')) declared its source as string | undefined but passed null for an absent header, because Headers.get answers null. It now passes undefined, like a missing cookie or query parameter. Migration: a handler that tested === null for a missing header must test === undefined. The whole-query source is now generic like Body, so Query<z.infer<typeof schema>>() declares the shape @ValidateQuery wrote instead of a cast. apps/static-site takes its port as the first argument (default 8000).

  • A name over 255 bytes no longer jams a RabbitMQ channel (@setu-ts/messaging-plugin, M106, PR #427). AMQP limits the exchange, a queue, the routing key (the topic), the message id and each header name to 255 bytes, and amqplib prepares a publish’s confirm callback, or a channel operation’s reply slot, before it encodes. An oversized value was rejected, but it left the channel wrong for the rest of its life: after a publish, each later awaited publish resolved only when the one after it was confirmed; after a subscribe(), every later channel operation waited forever, so publishes timed out and subscriptions never completed. A payload messageId or a topic over 255 bytes reached this before 0.9.0. RabbitMqBroker now refuses each of these with a RangeError naming the field before the channel is touched: the topic, queue, message id and header names on publish/subscribe, and exchangeName (and a defaultQueue over 218 bytes, which leaves no room for the private queue’s uuid suffix) at construction. @setu-ts/queue-plugin’s RabbitMqQueue had the same defect through its derived queue names, <prefix>.<name>.ready|.delay|.dead: a job name that makes them exceed 255 bytes (240 with the default prefix) is now refused before the channel is touched — at registration too, by process() and addRecurring(), so a declared processor with such a name fails start() — and a prefix over 247 bytes at construction. QueueService also isolates each name in its poll and recurring loops: a reserve or recurring enqueue that fails for one name is reported and skipped, where it used to abort the tick and starve every processor and recurring job registered after it.

  • A new Cloudflare Workers project installs and type-checks again (@setu-ts/cli, #424). setu new --runtime cloudflare-workers emitted wrangler: '^4.0.0' beside @cloudflare/workers-types: '^4.20250109.0', and wrangler moved its workers-types peer from ^4 to ^5 inside its own 4.x line — so npm install, the first step the CLI prints, failed with ERESOLVE against wrangler@4.148.0 for every new Workers project. The scaffold now pins wrangler: '~4.148.0' and @cloudflare/workers-types: '^5.20261006.1': a tilde range because a floating ^4 was never a promise about that peer, while a patch release keeps it on ^5. Its tsconfig.json also gains allowImportingTsExtensions, noEmit, lib: ['ES2022'] and types: ['@cloudflare/workers-types']: without them the generated npm run check failed on a pristine scaffold (TS5097, every emitted import carries .ts) and the types package was installed and read by nothing, so cloudflare:workers and KVNamespace did not resolve. A template’s own types and lib are kept. Verified end to end: scaffold, npm install, npm run check, npx wrangler dev, and GET / answers 200 on workerd. A new guarded e2e runs a dry-run npm install of every Workers-capable template’s generated manifest, so the next upstream peer change fails the suite (including the weekly dependency-drift job) instead of reaching users; the root suite grants --allow-run=npm for it. Existing projects can apply the same two pins and four tsconfig.json options by hand.

  • A scaffolded Worker kept its old bindings after a bindings-only deploy (@setu-ts/cli, #423). The src/index.ts that setu new --runtime cloudflare-workers generates memoized the application built from the first request’s env and reused it for every later request. Cloudflare may keep an isolate running across a deploy that changes only bindings, so that application kept serving with the previous bindings, variables and secrets. The entry now keeps one application per env object. On workerd, fetch and queue handlers receive the same object while bindings are unchanged, so the steady state is one lookup. Up to two applications are kept, so requests alternating between two versions during a gradual deployment reuse both instead of rebuilding each in turn; a third evicts the least recently used, and an application no request has used for 100 requests is retired. Retirement waits until the application now in use has started, so a broken binding never costs the working application, and a retired application is stopped only once no request or queue batch still holds it. Only newly scaffolded projects change; an existing project can copy the new cache, from interface BootedApp through stopQuietly, and the acquire/release calls in each export into its own src/index.ts.

  • A refused NATS publish resolved as a success (@setu-ts/messaging-plugin, #425). NatsBroker never awaited JetStream’s acknowledgement, so a publish to a subject no stream captures resolved while the server’s 503 escaped as an unhandled rejection, which terminates a Deno or Node process by default; a successful publish was not confirmed either. publish() now resolves once JetStream has stored the message and rejects with an error naming the subject (and, for 503, the streamSubjects remedy), keeping the client error as cause. An unresponsive server rejects after the client’s own 5 s timeout. A failed RPC reply now surfaces as a responder failure, so the request is redelivered rather than its reply silently lost.

  • A duplicate key answers 409 Conflict instead of a masked 500 (@setu-ts/database-plugin, @setu-ts/cloudflare-plugin, #420). Every backend’s unique violation reached errorHandler as a plain Error: measured on the memory adapter, PostgreSQL through Drizzle, MongoDB and DynamoDB Local, a duplicate primary key and a duplicate secondary unique key both answered 500. DatabaseService now classifies SQLSTATE 23505, Prisma P2002, MySQL ER_DUP_ENTRY, MongoDB 11000, Cosmos 409 and SQLite’s UNIQUE constraint failed (the only signal D1 carries) as DuplicateKeyError, and the memory, DynamoDB and Bigtable adapters raise it themselves. A duplicate refused at commit() is mapped too, including a DynamoDB transaction cancelled by a create’s guard; any other commit rejection is still rethrown unchanged. The served detail is fixed and never quotes the duplicated value.

  • RabbitMQ discarded handler failures (@setu-ts/messaging-plugin, #421). Durable groups now confirm persistent retry/dead-letter copies before acknowledging the original, preserve message properties and prototype-safe transport headers, drop the broker-interpreted userId, CC and BCC, classify deterministic failures immediately, and replay retry/dead declarations plus prefetch on reconnect. Retry delays, attempt budget, classifier, dead-letter cap and prefetch are configurable and validated at construction. Mandatory replacement publishes now reject correlated unroutable returns before acknowledging; reserved/oversized group names are refused before declarations can close the shared channel. A failed disposition now closes its channel and starts the reconnect-and-replay recovery, so unacked originals return to Q instead of filling prefetch on a live channel until the consumer stalls. Dead-letter diagnostics normalize application-supplied queue/topic names and bound the complete log line, preventing control characters from forging log records while preserving the original routing names.

  • Redis Streams stranded messages (@setu-ts/messaging-plugin, #419). Failed entries are reclaimed with tiered idle backoff through one delivery path, instead of staying in the pending list forever. Terminal failures enter a bounded per-group dead-letter stream before acknowledgement. Clean consumers are deleted only with no pending entries, and old foreign empty consumers are swept. Real Redis tests cover restart recovery, competing replicas, dead-letter fields, and clean stop.

  • Generated WebSocket and SSE routes fail the project’s own deno fmt --check (M101g). setu generate ws-route emitted a broadcast line wider than the generated project’s formatter allows for every name, and setu generate sse emitted a one-line registrar signature that overflowed for longer names. Both now emit what deno fmt produces, and the every-family formatting sweep generates these families too.

  • setu generate no longer reports adopting a file into a barrel it does not write (#416). On a fresh --template rest project every setu generate printed “Adopted src/services/greeting.service.ts into src/services/index.ts … Remove any manual registration of it”, although no src/services/index.ts was written and that barrel only re-exports, registering nothing. Adoption is now reported only for a barrel the command writes, only for a barrel that registers something, and only after the write succeeds; --dry-run previews it as “Would adopt”. A custom schematic’s output never reports an adoption, since the CLI cannot vouch that its barrel registers the file.

  • CLI output cannot be forged by project-controlled text (M101f security audit). A member name, path, task value or parser message carrying a line feed, carriage return, U+2028/U+2029 or a bidi control printed raw, so a hostile workspace could make setu print a line that read as the CLI’s own. Every such value in devtool enable (including the task value a refused merge would write and the stale dev-runner refusal), workspace ports --reallocate, generate (including generate app, generate library and the legacy src/routes/ notice), add, the member reconcile refusal, the duplicate-port refusals, the unreadable-runtime-marker error and the interrupted-run retry hint is now escaped to \uXXXX and stays on one line. The file and failure lines of new, every file, refusal and failure line of adopt (which now also refuses by name, instead of crashing, when an adopted directory holds an entry it cannot stat, such as a committed dangling symlink), the interrupted-rollback message, and the plugin-command missing-config refusal and application-load errors are escaped the same way; an error raised by the project’s own plugin code is printed as written, since that code can already print anything. Messages keep their intended line breaks, and legitimate output is unchanged.

  • The CLI no longer follows symbolic links inside a project (M101f security audit). A link is committed content, so a cloned project could make setu adopt walk a linked directory and move — deleting from where they lived — files outside the project, and could make generate, devtool enable and workspace ports --reallocate overwrite or merge into a file outside it by linking a barrel, deno.json or main.dev.ts there. Every CLI write, and every file adopt moves, must now resolve to exactly its place under the project or workspace root; a path reached through a link inside the project, or a dangling link, is refused by name with nothing written. A path holding a backslash below the root is refused too (on POSIX it is an ordinary filename character, so it names a different file on each platform), and a workspace member name must be one path segment — no /, no \\, not . or .. — or the manifest is refused as unreadable. A project reached through a linked PARENT directory is unaffected. If you deliberately link a file into a project (a shared deno.json, for example), replace the link with a copy before running a writing command.

  • Generated image pruning step pinned and scoped (M101f security audit). The step that removes the development-only diagnostics pin ran deno eval with every permission against a floating jsr:@std/jsonc@^1.0.2, the one unlocked fetch in the image. It now runs deno run against jsr:@std/jsonc@1.0.3, with read and write granted only to the member’s and the root’s deno.json/deno.jsonc. Regenerate the managed Dockerfile with setu generate app, setu devtool enable or setu workspace ports --reallocate.

  • RabbitMQ messages and jobs survive a broker restart (#418). Both RabbitMQ publishers — messaging-plugin’s broker and queue-plugin’s adapter — declared durable queues and then published every message TRANSIENT on a plain channel, so a RabbitMQ restart silently emptied every queue of the messages and background jobs waiting in it, while publish()/add() had already resolved. Measured against RabbitMQ 4.3.5: 5 messages and 5 jobs → 0 and 0 after a restart, the queues surviving empty. Every publish is now persistent (persistentMessages, default true; false restores the old wire behaviour exactly) and goes through a publisher-confirm channel, so publish()/add() resolve only once RabbitMQ has accepted the message and reject when it refuses it. Each publish also listens for its channel’s close and rejects if the channel closes first: amqplib 0.10.x’s close-time drain can stop at a slot an out-of-order confirm already settled and never call a later publish’s callback, which with publishTimeoutMs: 0 would leave the publish — and a retry’s ack of the reserved job — pending forever. A queue retry or dead-letter now acknowledges the reserved job only after its replacement is accepted — previously it acknowledged after an unconfirmed publish, so a broker failure between the two lost the job. The drive-mode reconnect reopens a confirm channel too. IAmqpConnection and IAmqpQueueConnection gain an optional createConfirmChannel(); an injected facade without it keeps a plain channel and logs one warning. Proven by restarting a real broker in CI, with the transient control losing its messages in the same restart.

  • A paused RabbitMQ no longer hangs publish() or add() forever (#418). Each publish made an unbounded broker round trip (an exchange assert in messaging-plugin, queue declarations in queue-plugin) that a paused broker — socket open, nothing answering — never completes. The new publishTimeoutMs option (default 15000, 0 unbounded, out-of-range values refused with RangeError when the plugin is constructed) bounds every round trip of one publish, confirm included; measured against a paused broker, both calls reject at the bound with a message naming the option. A rejection is not proof the message was dropped.

  • SAML binding cookie cleared only on consumption (M101c). The ACS cleared the browser-binding cookie on every outcome, so a cross-site POST of an empty or junk body to the CSRF-exempt ACS, carrying the victim’s SameSite=None cookie, burned the victim’s in-flight login. It is now cleared exactly when a pending request is consumed.

  • cloudflare-plugin’s cacheApiMiddleware keys on the resolved tenant (#407). The default key carried the locale (M103) and not the tenant, so with multi-tenancy tenant A’s cached page was served to tenant B for the TTL — the exact gap the M103 audit recorded as pre-existing. When ctx.request.tenant is set the key appends setu-cache-tenant=<encoded id> before the locale parameter, under the same rules: the URL text is never re-serialized, a tenant-less request whose URL already carries the parameter is served uncached (its key would equal a tenant’s), as is an unencodable id, and an application without the plugin keeps byte-identical keys. A global registration must run after the tenant middleware (40). A custom key must include the tenant itself.

  • The documented Node.js floor is 22, not 18 (#406). docs/getting-started.md, docs/runtime-deployment.md and the runtime README claimed Node 18+; five packages use Promise.withResolvers, which Node 22 was the first to ship, and globalThis.crypto alone needs 19. CI verifies on Node 24. The cacheApiMiddleware JSDoc’s list of skip reasons also names the non-GET pass-through it had omitted since M52b.

  • A Kafka subscribe() racing disconnect() no longer leaves a consumer running. When disconnect() landed while subscribe() was still connecting its consumer, or during a first attempt that then succeeded, the subscription completed anyway — a consumer ran after shutdown, holding the process open and consuming messages. It now rejects with KafkaBroker was disconnected while subscribing and releases the consumer. The same held for a consumer whose group JOIN was still in flight (about 3 s on a broker with Kafka’s default group.initial.rebalance.delay.ms): kafkajs’s stop() is a no-op until the join completes, so disconnecting closed the connections under it, which kafkajs treats as a retriable crash and restarts — the consumer rejoined after app.stop(). Releasing a consumer now waits up to 10 s for its join to settle and then disconnects it; a join still pending at 10 s is disconnected the moment it settles, after app.stop() has returned, because disconnecting under it neither stops a join that later succeeds nor returns before kafkajs’s pending JoinGroup is answered. A record delivered to a released consumer is left uncommitted for the group to redeliver. Found by the M101b security audit; both predate M101b.

  • A Kafka consumer stopped while kafkajs was restarting it no longer rejoins after app.stop(). After a broker restart or a connection reset, kafkajs restarts a crashed consumer behind its own timer. A stop that landed once that restart had begun found nothing to stop — kafkajs’s stop() drops a runner that is still joining — so the restarted consumer joined the group after shutdown, consumed and committed messages with the stopped application’s handler, and held the process open (measured on Kafka 4.0). The broker now declines kafkajs’s restart and restarts the consumer itself — after the crash’s retryTime, else KafkaOptions.retry.initialRetryTime, else 300 ms, as kafkajs reads them — so a stop cancels a pending restart and waits for one in progress. Found by the M101b security audit; predates M101b.

  • A Kafka application exits after app.stop(). KafkaBroker.disconnect() and a subscription’s unsubscribe() called the kafkajs consumer’s stop(), which halts fetching but leaves the consumer’s cluster connection open — so every Kafka application with a subscription hung after a clean app.stop() until it was killed, and each RPC request’s reply inbox leaked a connection when it closed. Both now call disconnect(), which stops the consumer and closes its connection. Found while verifying M101b; it predates M101b.

  • A MessagingPlugin whose declared subscription rejects no longer leaks its broker (M101b). register() connected the broker, subscribed the declared entries, and only then registered the close hook — so when a declared subscription rejected start(), the kernel’s failed-start cleanup had no hook to run, the connected broker was never closed, and the process could not exit (measured against a real Kafka broker). M101b makes that rejection a documented outcome, so the hook is now registered directly after connect(). A Kafka subscribe retry waiting out its backoff is likewise cancelled by disconnect() instead of holding a timer for up to maxRetryTime.

  • NATS request-reply works against a real server (M101b, V8-6). The reply inbox subscribes with the dotted queue rr.inbox.<uuid>, which was used verbatim as the JetStream consumer name, and the nats client refuses . in one before anything reaches the server — so every request() failed with invalid durable name - durable name cannot contain '.', as did any dotted user queue. A queue is now encoded (. → _2e, and likewise *, >, /, \ and whitespace); a queue with none of those characters is unchanged, so no existing consumer is renamed. A queue whose encoding collides with another’s, or that is reused across two topics, rejects with NatsConsumerNameCollisionError instead of silently sharing one consumer’s deliveries.

  • 62 @since tags named a release that does not ship their symbol. The MongoDB, DynamoDB and cursor-paging surfaces of @setu-ts/database-plugin (61 tags) carried @since 0.1.0 while first shipping in 0.2.0, and the kernel router’s ROUTE_ENTRY first shipped in 0.3.0. Each is corrected to the first published version whose file contains the symbol. They survived because the @since gate skipped every 0.1.0 tag — that line shipped only as 0.1.0-alpha.* — and now checks such a tag against the line’s last prerelease instead.

  • The Cloudflare example cached a failed start for the isolate’s whole life (apps/cloudflare, PR #426). createWorkerHandler memoised the start promise even when it rejected, so one transient failure at cold start left that isolate answering errors to every later request. It now forgets a failed start and retries on the next request, the rule the generated Workers entry has followed since M70l. Concurrent requests still share one start, and none reaches fetch on an application whose start failed.

  • Constructor injection in a full-stack app was undocumented (@setu-ts/full-stack-starter, @setu-ts/rest-starter, @setu-ts/testing docs, PR #426). Nothing said how a React Router loader reaches an injected service; the full-stack-starter README now shows resolving it from CAPABILITIES.DI_CONTAINER in populateLoadContext and carrying it on a context key, and says that a DI mistake starts cleanly and fails every server-rendered page. Both starter READMEs say what the di arm turns on, and the testing README points a container-provided service at overrideProvider rather than overrideCapability.

  • The root README’s quick examples did not run as written (README, PR #426). The first example registered LoggerPlugin without telling the reader to install it, so following the documented install failed to resolve the import; it now says to add the package. The second read JWT_SECRET before any plugin had registered; it now loads configuration with loadConfig and hands the same snapshot to ConfigPlugin({ instance }). The feature table no longer claims event sourcing, which the framework does not ship, and now lists Cosmos DB and Bigtable.

  • The CLI guide described a CLI several milestones old (docs/cli.md, PR #426). It said generate controller and generate module need decorator-plugin (neither has since M70h), listed fourteen schematics where there are sixteen (ws-route and sse were missing), quoted a refusal message the CLI no longer prints, and left the M83 @Module file and MODULES barrel out of the class-based module tree. It also miscounted the interactive questions, gave the wrong bun start command, and showed the workspace discovery map without its <MEMBER>_HOST override. Each corrected claim was checked against a project the CLI scaffolded.

  • The NestJS migration guide mapped several NestJS features to hand-written code the framework replaces (docs/migration-nestjs.md, PR #426). Guards now map to AuthPlugin with requireAuth()/requireRole(), in place of a hand-written global middleware that would also have refused /health. Exception filters map to errorHandler({ respond }), WebSocket gateways to @Gateway, and the testing module to createTestApp({ app: createApp(), overrides }). The guide also says that a decorated @Injectable lives in the DI container, which overrideCapability does not reach. The decorator example now passes DiPlugin({ autoRegister: true }), without which injecting a framework capability fails at startup. The guide no longer says Setu-TS has no @Module.

  • The examples guide described applications that are not in apps/ (docs/examples.md, PR #426). Its code blocks were written as illustrations rather than taken from the apps, and had drifted. They showed routes the apps do not serve (/items, /users, /my-route), a health endpoint minimal does not have, D1 and Cache API use the Cloudflare example does not make, and WebSocket rooms in an SSE-only realtime example. They also named a build task where compiled-binary has compile. The guide also did not say that cqrs and microservices print a result and exit, or which port each app uses. Each deep dive now gives the commands it was checked with against the running app, and links to that app’s source.

  • The Fastify migration guide’s testing example threw at startup, and several sections described behaviour the framework does not have (docs/migration-fastify.md, PR #426). createTestApp() with no plugins throws, because the kernel requires a runtime plugin. The example now passes RuntimePlugin(), and the guide points at app.inject(), which works as Fastify’s does. The shutdown hooks’ comments were reversed (onStopping runs while the application still serves; onShutdown runs after the socket closes). The encapsulation section claimed capability tokens scope a service, but every registered service is application-wide. Validation and error handling now show validateBody(...) and errorHandler() in place of hand-written equivalents, and query parameters come from ctx.query.

  • The React Router development guide’s loop failed for the CLI’s full-stack skeleton on Deno (docs/react-router-dev.md, PR #426). Re-verified on Deno 2.9.6, Vite 8.3.1 and react-router 8.4.0: the build thunk, the /__vite/ proxy and editing a route without a restart all still work. A project from setu new --template full-stack imports @setu-ts/react-router-plugin in its route modules, though, and Vite’s development SSR runner cannot resolve that JSR import, so every page answered 500. The guide’s loop now passes viteDevExternals (see Added), and points such a project at its own deno task dev. It also pins Vite 8 as the CLI does, and says isbot is needed only with React Router’s default server entry.

  • The telemetry fan-out guide’s validation command failed as printed (docs/telemetry-collector-fanout.md, PR #426). The collector config reads five credentials from the environment, and otelcol-contrib validate rejects it while they are empty, so the command exited 1 on a clean checkout. The guide now sets placeholders in the command and says validation contacts no vendor. It also says the config validates on 0.156.0 and on the 0.115.1 that the Compose stack pins. A scope note still sent readers to Milestone 39 for Compose. It now links the telemetry profile that shipped there.

  • The MVC guide’s redirect example could never take its error branch (docs/mvc.md, PR #426). It decided acceptance with ctx.request.method === 'POST' on a POST route, so it always redirected. It now validates the submitted title, re-renders the form with 422 when it is too short, and redirects with 303 otherwise; both branches were driven through a running app. The guide’s other claims were checked the same way and hold.

  • The custom-plugin guide’s two test examples failed as printed (docs/custom-plugins.md, PR #426). The integration test registered its plugin after createTestApp() had started the application, which throws; the unit test asserted a service its plugin never registered. Both now pass when run, and every example was checked against the source or a running app. Step 1 used CAPABILITIES without importing it, the service-registration block registered one token twice and paired override with multi (which ignores it), the subtle example called it optional and imported an HMAC key without a hash, and the sample README’s outer fence closed early, so its Usage and Options sections rendered as part of the guide.

  • The programmatic API reference resolved capabilities before start() (docs/programmatic-api.md, PR #426). Both streaming examples and the router.post example read a service at module scope, where the plugin providing it has not registered yet, so each threw No service registered. They now resolve inside the handler, and the disconnect example no longer closes a stream the client has already cancelled. The middleware.add section showed only a plugin registration; it now adds a middleware. register() takes no options, start()’s hostname defaults to the runtime’s own, and the transcribed IResponse and IRequestContext gain html() and raw.

  • The plugin catalog described packages that have since changed (docs/plugins.md, PR #426). The kernel listed three exports it does not have (createApplication is its only value), the DI token was CAPABILITIES.CONTAINER rather than DI_CONTAINER, the database entry listed neither the MongoDB, DynamoDB, Cosmos DB nor Bigtable arms nor their drivers, telemetry omitted @opentelemetry/context-async-hooks, audit claimed a Cloudflare KV store it does not ship, multi-tenancy claimed physical schema and database isolation, LaunchDarkly was called Node-only, and the CLI count was 14 schematics rather than 16. Every listed export and token is now checked against its package’s barrel and CAPABILITIES.

  • The decorators guide’s validation examples answered 500 on every request, and it said role decorators are not enforced (docs/decorators.md, PR #426). @ValidateBody and @ValidateQuery were given plain { type, required } objects, which are not schemas the validation plugin can run, so every request to those routes failed. They now use Zod schemas. @Roles and @Permissions have been enforced since M89a (401, 403, or 501 with no authorization service), while the guide described them as metadata only. The DiPlugin() examples now pass autoRegister: true; without it, injecting CAPABILITIES.CACHE fails at startup. Also corrected: the path @Version produces (/v1/api/users, not /v1/users), and the MetadataStore example, which iterated a new, empty store instead of metadataStore.

  • The plugin architecture guide’s replacement and testing examples failed at startup (docs/plugin-architecture.md, PR #426). “Plugin Replacement” registered a custom logger beside LoggerPlugin, which the kernel refuses because a capability has one provider. The testing example registered a plugin after createTestApp had started the application, which throws. The middleware priority table now lists every first-party middleware with its real priority; it had omitted the error handler, locale resolution and request logging. The shutdown hook comments now say when each hook runs relative to serving, and the guide no longer says routing and middleware are plugins.

0.8.0 — 2026-10-03

Added

  • Diagnostics session renewal (M98o). DiagnosticsPluginOptions.maxSessionLifetimeMs opts a launch into renewing its existing authenticated session for up to 12 hours of awake monotonic time. Protocol v1 gains signed GET /v1/renew and an optional status renewal member; the native client gains required session() and renew() methods returning the new DiagnosticsSessionLifetime. Renewal keeps the same key, session ID, instance binding and sequence space, remains behind replay/rate/instance gates, and the expiry timer re-arms only for opted-in sessions. The CLI’s generated devtool entry selects an 8-hour cap. These required client methods widen the previously unpublished IDiagnosticsClient interface. The client also now cancels an unread non-200 response body, which it never parses, instead of leaving the connection’s body open until garbage collection.

  • SAML 2.0 service provider (M100f). A saml arm of AuthPluginOptions.signIn.providers (the published SamlProvider type) makes the application a SAML 2.0 SP for an enterprise IdP, landing in the same signed-in session as every other method with methods: ['fed'], so signIn.mfa and requireMfa() apply unchanged. Each provider registers GET <basePath>/<name>/login (an AuthnRequest over the HTTP-Redirect binding), POST <basePath>/<name>/acs (the assertion consumer service, HTTP-POST binding) and GET <basePath>/<name>/metadata (the SP descriptor). XML signature verification is delegated to @node-saml/node-saml@^5, imported lazily and awaited in register() — or injected through module (SamlModule) — so a missing package fails at startup with the new SamlRuntimeLoadError, which names the specifier and Cloudflare Workers’ nodejs_compat flag. Assertions must be signed (a response-only signature is not enough; unsigned, encrypted and signature-wrapped assertions are refused); every security option handed to the library is set explicitly, because node-saml 5.1.0 defaults validateInResponseTo to 'never'. Two checks node-saml 5.1.0 does NOT make on an authentication response are made by the plugin from the verified assertion: its Issuer must equal idp.entityId (the library reads idpIssuer only for logout messages), and every SubjectConfirmationData must name acsUrl as its Recipient and the consumed request as its InResponseTo — the signed binding, since the response envelope’s own InResponseTo is unsigned when only the assertion is. The response’s InResponseTo must name a pending request this server issued; pending requests (SamlPendingRequest) live in an ISamlRequestStore (MemorySamlRequestStore by default, single replica only) that is also the library’s cacheProvider, are consumed exactly once, and are bound to the browser that started the login by a __Host-setu-saml cookie (SameSite=None; Secure; HttpOnly; Path=/; Max-Age=600) — because the IdP’s cross-site POST does not carry the Lax session cookie, and without the binding a posted foreign response would sign the victim in as the attacker. Assertion ids are claimed once (claimAssertionId), so two concurrent posts of one captured response cannot both sign in. The checked Issuer and the nameID handed to toPrincipal are read from the assertion’s own elements, never from attribute-overlaid profile fields. MemorySamlRequestStore caps its pending requests (MemorySamlRequestStoreOptions.maxPendingRequests, default DEFAULT_MAX_PENDING_SAML_REQUESTS = 10,000, oldest evicted first), so an unauthenticated login flood cannot grow memory without bound. IdP-initiated login is refused. Refusals answer 401 (or failureRedirect?error=) with one of two fixed codes, assertion-invalid and state-invalid; the library’s message reaches only the debug log. toPrincipal receives a frozen SamlProfile. The ACS signs in on a NEW session, since the previous session’s cookie is not sent with the IdP’s POST. Encrypted assertions, single logout, the Artifact binding and signed AuthnRequests are not offered. Proven against the real node-saml on every run and against a real Keycloak 26.4 SAML client (test/e2e/keycloak-saml-real.test.ts).

  • Passkeys / WebAuthn (M100e). AuthPluginOptions.signIn.passkeys (the published PasskeyOptions type: rpId / rpName / origins / store / resolvePrincipal / userVerification) registers the four ceremony routes — POST <basePath>/passkeys/{register,login}/{options,verify} — and lets a passkey assertion with user verification count as the second factor for signIn.mfa’s step-up model. The ceremonies are WebAuthn Level 2 with attestation conveyance none, accepting ES256, RS256 and EdDSA credentials. The verifier is zero-dependency: a bounded CBOR/COSE decoder over runtime.subtle (definite lengths only, depth 4, 4 KiB, every unsupported major type refused). @simplewebauthn/server was rejected as a runtime dependency because importing it installs a global Reflect.getMetadata polyfill in every application that enables passkeys; it appears only in the package’s differential test as a test oracle. Challenges are held in the session with a 5-minute expiry AND claimed once in the credential store (IPasskeyStore.claimChallenge), so a replayed assertion against an older cookie copy is refused even though a synced passkey’s counter is always 0. updateCounter MUST be implemented by custom stores as an atomic compare-and-advance, and save MUST be an atomic compare-and-set (it stores a NEW credential only when its id is absent AND the principal is below maxPerPrincipal, both checked in the same step as the insert, and answers 'saved' / 'duplicate' / 'limit' — a blind write lets two concurrent registrations of the same credential id both succeed and lets the later record silently replace the earlier one, principalId included, and a count read before a separate write lets a burst overshoot the cap); the shipped MemoryPasskeyStore does both and purges expired claims in an amortized sweep (when the claim map doubles) so its map stays bounded without a per-claim scan. Stored credentials (StoredPasskey) record attestation: 'unverified' and a backedUp flag that is display only. Under the default userVerification: 'required' a UV-unset assertion is refused everywhere; under 'preferred'/'discouraged' it is refused for a username-less sign-in and accepted only as the second factor after a first one. A passkey-only signIn (providers: [], no mfa) is accepted, and the passkeys option is validated when AuthPlugin(...) is called; the recorded method is always pop (RFC 8176), never hwk/swk — with attestation unverified the plugin cannot know how the key is protected. Any attestation fmt is accepted with its statement unread. Attestation verification against trust roots remains out of scope. Registration is gated: a principal who already holds a passkey must have proved a second factor (otp/pop) in the session (403 second-factor-required). The first passkey is trusted on first use only without signIn.mfa; with it, and no PasskeyOptions.mayRegister, the first passkey needs a proven second factor too, because the principal may hold a TOTP factor the plugin cannot see. A set mayRegister (context type PasskeyRegistrationContext) decides the first passkey and can refuse more (403 registration-refused). One principal holds at most 16 credentials (409 credential-limit), enforced atomically by IPasskeyStore.save(credential, { maxPerPrincipal }), which answers a PasskeySaveResult ('saved' / 'duplicate' / 'limit') configured by PasskeySaveOptions. Only defined transports values are stored, and an EC2 key must be a point on P-256.

  • Multi-factor authentication with TOTP (M100d). AuthPluginOptions.signIn.mfa adds a TOTP second factor to any sign-in flow that records its principal through IAuthSessionService. TotpService (app-instantiated, like PasswordHasher) computes RFC 6238 codes (HMAC-SHA1, 30-second step, 6 digits, ±1 step window) from a base32 secret and verifies them against an ITotpStore; the shipped MemoryTotpStore is single-process. signIn.mfa.required(principal, methods) returns true to require a second factor; when it does, signIn stores a PendingSignIn record (under a private session key, NOT the signed-in key) and returns second-factor-required instead of signing the session in. TotpService.completeSignIn(ctx, code) and completeSignInWithRecoveryCode(ctx, code) complete a pending sign-in, each appending otp so the principal ends up recorded with methods: ['pwd', 'otp'] (or ['fed', 'otp']) and the session id rotated. signIn.mfa.pendingTtlMs (default 300 000 ms) is the single owner of how long a pending record survives; TotpService has no TTL option of its own, and it must be a positive integer (NaN, 0 and negatives are refused when AuthPlugin(...) is called, since NaN would never expire). pending(ctx) does not report an expired record, so completing one spends no code. A provider callback whose sign-in is held pending redirects to signIn.mfa.challengePath (else returnTo) and keeps its RP-initiated-logout facts through the promotion. Per-account lockout: five failed attempts within 15 minutes lock the principal out of verification, recovery codes included; the attempt is reserved before the credential is checked, a refused attempt is not counted (so continued guessing cannot extend the lock), and a successful verification clears the count. Replay protection: the store’s claimStep is monotonic, so a captured code cannot be replayed. An enrolment never confirmed is not a factor — no verification path accepts its code — and starting a new enrolment leaves the confirmed factor in place until the new secret is confirmed, keeping the step counter monotonic across the swap. Replacing a confirmed factor (confirmEnrolment with a re-enrolment pending), regenerating recovery codes, and disabling a confirmed factor each require proof of the CURRENT factor — a code from it or an unused recovery code — and answer proof-required without it, so a caller able to name a principal cannot take over or remove its second factor; a first enrolment is trust-on-first-use and needs none. The routes calling these take the principal from the session, never the request, as the README example shows. Recovery codes: confirmation mints the first set (confirmEnrolment returns it, so codes never exist without a proven factor) and generateRecoveryCodes(principalId, proof) replaces it; each set is 10 codes of 16 base32 characters (80 bits), stored as SHA-256 digests, consumed atomically on use, and accepted only in the canonical shape (trimmed, upper-cased, exactly 16 characters of A-Z2-7). The new requireMfa() guard answers 401 for an anonymous request and 403 second-factor-required for a principal whose claims.amr lacks otp or pop; a non-array amr counts as absent rather than being coerced, so a string merely containing a factor name cannot satisfy it. @setu-ts/common gains PendingSignIn and the second-factor-required arm of AuthorizationFailure. New @setu-ts/auth-plugin exports: TotpService, MemoryTotpStore, requireMfa, and the types MfaOptions, ITotpStore, TotpVerifyResult, RecoveryVerifyResult, TotpCompleteSignInResult, TotpServiceOptions, TotpEnrolment, ReserveAttemptResult, TotpProofResult, ConfirmEnrolmentResult, RecoveryCodesResult, DisableResult. ITotpStore writes enrolment state only through two atomic operations — stageSecret (never touches the claimed step) and confirmSecret (a compare-and-set on the secret awaiting confirmation) — so a concurrent enrolment can neither roll back a claimed step and reopen a code’s replay, nor have its secret confirmed by another user’s code. signIn.providers may be empty when signIn.mfa is set, for a password-only sign-in. MemoryTotpStore sweeps lockout entries whose attempts have all left the window, so its size tracks the attempt rate rather than every principal id ever presented. The internal promotePending is not exported. With signIn.mfa unset nothing changes.

  • Sign-in with an outside provider (M100c). AuthPluginOptions.signIn makes the application an OAuth 2.0 / OpenID Connect relying party over the authorization-code flow: per provider, GET /auth/<name>/login and GET /auth/<name>/callback, plus one POST /auth/logout. Providers are the exported OidcProvider (discovery; the ID token verified by the M100b verifier with the client id as audience, nonce and azp) or OAuth2Provider (a userinfo endpoint, for providers such as GitHub that issue no ID token). PKCE S256 is sent for every provider; state, nonce and the verifier are bound to the user’s own session; RFC 9207 iss is checked when sent; returnTo is kept only as a same-origin path; and callback refusals answer with one of four fixed codes in the configured error format. Every configured or discovered URL must be https (or http on a loopback host) written in printable ASCII, so a non-conforming discovered endpoint fails the login with 503 instead of a malformed Location. Provider tokens reach onTokens and are stored nowhere else. @setu-ts/common gains CAPABILITIES.AUTH_SESSION and IAuthSessionService (signIn/current/signOut, with SignInOptions, SignInOutcome and AuthMethod): the one owner of “this session is signed in as this principal”. A password login records its principal through it too, and the new auth-session strategy authenticates later requests with claims.amr from the recorded methods and no hand-written middleware. signIn.refreshPrincipal re-reads the principal per request. IAuthHttp gains post for the token exchange and a headers option on get for the userinfo read, and the issuers strategy now refuses a Keycloak ID token (typ: "ID") presented as a bearer. Requires SessionPlugin. New @setu-ts/auth-plugin types: SignInConfig, SignInProvider (OidcProvider | OAuth2Provider, sharing SignInProviderBase), TokenEndpointAuth, ProviderTokens and RefreshPrincipal. Verified against a real Keycloak 26.4 realm. With signIn unset nothing changes.

  • Tokens from an outside issuer (M100b). AuthPluginOptions.issuers accepts access tokens an outside identity provider issued (Auth0, Entra ID, Google, Keycloak, Cognito), verified against its published key set through runtime.subtle with zero npm dependencies. Each exported TrustedIssuer names an exact issuer, a required audience, a key source (IssuerKeySource: { jwksUri } or { discovery: true }), an optional algorithm allowlist (IssuerAlgorithm: RS256, PS256, ES256, ES384, EdDSA) and a required toPrincipal. none and HS* are refused before any key lookup, and keys are filtered by type, curve, use, algorithm, operations and kid. Key sets are cached on the monotonic clock, refetched at most once per cooldown for an unknown kid, kept through fetch failures for a bounded time, and bounded per fetch by time, bytes and key count. A new auth health indicator reports up or degraded — never down — from cached state only. http (the exported IAuthHttp) replaces the default fetch seam. The chain is now jwt → issuers → api-key → session → caller-supplied; with issuers unset nothing changes. Verified against a real Keycloak 26.4 realm, including key rotation through the admin API.

  • Authentication composition (M100a). AuthPluginOptions.jwt is optional, so API-key, session, and caller-strategy applications no longer invent JWT key material. AuthPlugin registers passive authentication globally at priority 300 by default; middleware can change the priority, exclude paths, or disable registration for route-level composition through the exported AuthMiddlewareOption. Empty passive chains now throw the exported AuthPluginConfigurationError. Applications with a hand-added global authMiddleware() should remove it; duplicates remain correct but authenticate twice.

  • Storage operation counters (M98m): opt-in, minimized storage observations through the diagnostics connector. StoragePlugin accepts diagnostics: { enabled: true, alias } (the new exported StorageDiagnosticsOptions, validated when StoragePlugin(...) is called, never echoing a value); the instance’s own StorageService then counts each public put/get/delete/ exists/getSignedUrl/getStream call with explicit succeeded/failed outcomes. getSignedUrl mints a URL rather than transferring bytes, so its record carries lastDurationMs: null and lastBytes: null; lastBytes is the byte length of the last settled buffer for the buffered operations put and get and null for the non-buffered operations (a get that finds nothing throws and settles as a failure). getStream counts the open, not the drain. Object paths, stored bytes, content types, signed URLs (the synthetic memory:// URL encodes the path) and error text are never captured. Every StoragePlugin instance registers an IStorageDiagnosticsSource under the new multi-provider CAPABILITIES.STORAGE_DIAGNOSTICS (disabled without the option, with nothing attached to the service). New public surface on @setu-ts/common: CAPABILITIES.STORAGE_DIAGNOSTICS, IStorageDiagnosticsSource, StorageDiagnosticsSnapshot, StorageDiagnosticsRecord, StorageDiagnosticsResponse, StorageDiagnosticsOperation. New connector surface: GET /v1/storage (authenticated like every operation; a snapshot operation — no query — exactly like /v1/cache; positional s<N> source ids; more than 16 sources refuses startup with a fixed configuration error; a throwing or invalid source answered by that source’s own value-free collection-failed snapshot; duplicate aliases or an over-budget body collapse to the fixed collection-failed response with no sources) and the REQUIRED IDiagnosticsClient.storage(); the status manifest now reports storage: true. Enabled calls read the monotonic clock twice (every call is timed, so age, duration and bytes describe one settlement) and settle through one derived promise that re-rejects with the original reason, so an unhandled provider rejection stays unhandled. Results, errors, rejection reasons, unhandled- rejection reporting and relative ordering are unchanged either way; with diagnostics on, the returned promise is a derived one rather than the provider’s own. Breaking for implementors: storage() is a REQUIRED member of IDiagnosticsClient, so a hand-written client must add it (answering unsupported is a valid implementation).

  • Outbound HTTP attempt observations (M98n): opt-in, minimized fetch-attempt counters through the diagnostics connector. @setu-ts/sdk exports createObservedFetch({ alias, fetch?, timing? }), which returns { fetch, plugin }: pass fetch to ClientOptions.fetch, SseClientOptions.fetch, or call it directly, and register plugin in exactly one application. The wrapper calls the wrapped fetch exactly once with the caller’s own arguments (never read) and receiver (except the helper object itself, which is forwarded as undefined — the platform fetch throws Illegal invocation on workerd otherwise), returns the identical value or rethrows the identical value — a synchronous throw stays synchronous — and records only that an attempt started, whether it resolved or failed, the response’s status class (2xx–5xx, other) and its time to headers. No URL, host, header, cookie, body, signal or error is ever read. plugin registers a frozen snapshot-only IOutboundHttpDiagnosticsSource under the new multi-provider CAPABILITIES.OUTBOUND_HTTP_DIAGNOSTICS and closes the helper on shutdown; it refuses a second application. (A re-registration by the SAME application reopens the source instead of refusing it, but a retried start() is not a working recovery today: the kernel refuses the retry itself once RuntimePlugin re-registers runtime.) A record never expires while an attempt is in flight, so a hung upstream stays visible as started - count. New public surface on @setu-ts/common: CAPABILITIES.OUTBOUND_HTTP_DIAGNOSTICS, IOutboundHttpDiagnosticsSource, OutboundHttpDiagnosticsSnapshot, OutboundHttpDiagnosticsRecord, OutboundHttpDiagnosticsResponse and OutboundHttpStatusClass; on @setu-ts/sdk: createObservedFetch, ObservedFetch, ObservedFetchOptions. New connector surface: GET /v1/outbound-http (authenticated like every operation; a snapshot operation with no query; more than 16 sources refuses startup; every counting invariant checked by the connector and the client), the status manifest’s outboundHttp key now true, and IDiagnosticsClient.outboundHttp(), which answers a frozen typed unsupported response without a request when the negotiated manifest lacks the inspector. Breaking for implementors: outboundHttp() is a REQUIRED member of IDiagnosticsClient. The SDK’s common imports stay type-only: its collector is a deliberate local copy, and a test checks the module graph. The helper needs crypto.getRandomValues (Node ≥ 19).

  • Realtime lifecycle observations (M98l): opt-in, minimized WebSocket, SSE and backplane observations through the diagnostics connector. WebSocketPlugin, SsePlugin and RealtimeBackplanePlugin accept a diagnostics: { enabled: true, alias } option (the new RealtimeDiagnosticsOptions, declared once in @setu-ts/common and re-exported by all three; validated when the plugin factory is called, never echoing a value). A WebSocket source counts open (a handshake that fails after the upgrade was accepted is a failed open), close (a close after a transport error or with code 1006 is failed) and every frame send writes or refuses — once, at the connection, so a room broadcast or heartbeat is not counted twice. An SSE source counts open, close and every enqueued frame, and reports closes caused by the 1 MiB backlog guard in backpressureCloses (a number only on the SSE close record, 0 when none, null everywhere else). A backplane source counts backplane-publish (the transport’s publish() resolving — never peer delivery — with its duration) and backplane-receive (after the transport’s own shape and origin filters), across the memory, redis and messaging transports; a 'custom' transport is never observed. WebSocket and SSE sources also report current openConnections and groups gauges, read from the plugin’s OWN service on each authenticated read, which never expire with the records, so an idle connection stays visible. No frame, message, close reason, connection id, room or channel name, header, query, principal, Last-Event-ID or backplane origin is ever captured. Every instance registers a frozen, snapshot-only IRealtimeDiagnosticsSource under the new multi-provider CAPABILITIES.REALTIME_DIAGNOSTICS (disabled without the option). Observation never changes a result: a refused send throws the same error, a rejected publish rejects with the same reason, and a failing clock or gauge reader latches the source collection-failed. New public surface on @setu-ts/common: CAPABILITIES.REALTIME_DIAGNOSTICS, IRealtimeDiagnosticsSource, RealtimeDiagnosticsSnapshot, RealtimeDiagnosticsRecord, RealtimeDiagnosticsGauges, RealtimeDiagnosticsResponse, RealtimeSourceKind, RealtimeObservationOperation, RealtimeGaugeState, and — because the three owning plugins need the identical collector and may not import one another — the one shared collector: compileRealtimeDiagnosticsAlias, createRealtimeObservationCollector, IRealtimeObservationCollector, RealtimeObservationCollectorInit, RealtimeGaugeReading and RealtimeDiagnosticsOptions. New connector surface: GET /v1/realtime (authenticated like every operation; a snapshot operation with no query; positional s<N> source ids; more than 16 sources refuses startup; a throwing or invalid source answered by a value-free snapshot of kind unknown; duplicate aliases or an over-budget body collapse to a fixed collection-failed response with no sources; any kind/operation, kind/gauge or backpressure combination outside the contract refused by both the connector and the client), the status manifest’s realtime key now true, and IDiagnosticsClient.realtime() that answers a frozen typed unsupported response without a request when the negotiated manifest lacks the inspector. Breaking for implementors: realtime() is a REQUIRED member of IDiagnosticsClient, so a hand-written client must add it (answering unsupported is a valid implementation).

  • Scheduler execution observations (M98k): opt-in, minimized fire and attempt observations through the diagnostics connector. SchedulerPlugin accepts diagnostics: { enabled: true, alias, jobs } (the new exported SchedulerDiagnosticsOptions, validated when SchedulerPlugin(...) is called; jobs is REQUIRED and maps exact job names to approved aliases, so an empty map approves nothing); the instance’s own SchedulerService and executor then record each approved job’s local timer fires — contended (slot or overlap lock held elsewhere), lock-failed (a lock operation rejected), dispatched — and each handler attempt (started, succeeded, failed, retryAttempts; a behaviors entry that declines a dispatch invokes no handler and records no attempt) with monotonic lastDurationMs and the wall-clock lastLatenessMs a fire started late (max(0, actualStart - intendedFire), never an absolute schedule). A skipped local fire is never reported as a globally missed execution: no missed counter exists. Job names, cron expressions, payloads, job ids, lock keys and tokens, and thrown values never reach the collector. Every SchedulerPlugin instance registers an ISchedulerDiagnosticsSource under the new multi-provider CAPABILITIES.SCHEDULER_DIAGNOSTICS (disabled without the option, with nothing attached to the service and no clock read on any fire). New public surface on @setu-ts/common: CAPABILITIES.SCHEDULER_DIAGNOSTICS, ISchedulerDiagnosticsSource, SchedulerDiagnosticsSnapshot, SchedulerDiagnosticsRecord, SchedulerDiagnosticsResponse, SchedulerDiagnosticsOperation. New connector surface: GET /v1/scheduler (at most 16 sources, more refuses startup) and the REQUIRED IDiagnosticsClient.scheduler(); the status manifest now reports scheduler: true. Unobserved services run the pre-M98k path — one field read, no clock.

  • Event dispatch observations (M98j): opt-in, minimized event-dispatch observations through the diagnostics connector. EventsPlugin accepts a diagnostics option (EventsDiagnosticsOptions, exported from @setu-ts/events-plugin) that attaches an internal observer to the EXISTING dispatch path without changing it: a handler rejection still reaches errorHandler and never rejects publish, a thrown errorHandler propagates exactly as before (sync mode: publish rejects; async mode: the dispatch promise rejects unhandled and whenIdle() rejects), async mode still resolves before handlers settle, and a failing clock latches the source collection-failed without ever rejecting a publish or reaching errorHandler. Only event types whose EXACT raw name appears in the configured events map are observed, each replaced by its approved alias before anything is retained; event payloads, event ids, aggregate ids, and unapproved type names never reach collector state. A record aggregates per (alias, operation) over a 64-slot bounded table with 60-second retention (re-checked on every update and read, never a timer), a 30-second stale threshold, saturating counters, clamped integer durations, and a saturating dropped overflow counter (each alias can occupy two slots, so more than 32 active aliases can overflow). started is counted when a boundary begins and count when it settles, so started - count shows work still in flight. Both the publish entry (including no-subscriber publications) and each existing handler’s await are instrumented; a handler rejection counts handler: failed and a thrown errorHandler fails the publish without changing propagation. The plugin ALWAYS registers one IEventDiagnosticsSource under the new CAPABILITIES.EVENTS_DIAGNOSTICS token WITHOUT claiming it in provides (it is an optional dependency, not a contract): disabled without the option, no-data before the first approved publication, stale when every record has aged past the threshold, ready once a fresh record exists, and collection-failed — latched by the source until it is recreated when its clock fails, or substituted by the connector for a source whose DTO fails the exact validator; close detaches the observer FIRST, then clears the bus, and discards any observation arriving after the collector closed. New public surface on @setu-ts/common: CAPABILITIES.EVENTS_DIAGNOSTICS, IEventDiagnosticsSource, EventDiagnosticsSnapshot, EventDiagnosticsRecord, EventDiagnosticsResponse, EventDiagnosticsState, EventObservationOperation, EventDiagnosticsCoverage. New connector surface: GET /v1/event (authenticated like every operation; a snapshot operation — no query — distinct from the paged kernel-event /v1/events; positional s<N> source ids; more than 16 sources refuses startup with a fixed configuration error; an over-budget body collapses to the fixed collection-failed response with no sources; a hostile/throwing source answered by that source’s own value-free collection-failed snapshot, duplicate aliases answered by a fixed collection-failed response with NO sources, collection-failed/unsupported/no-data/ stale/disabled answered as typed 200 bodies, never error text), the status manifest’s events key now true, and IDiagnosticsClient.events() that answers a frozen typed unsupported response without a request when the negotiated manifest lacks the inspector. Breaking for implementors: events() is a REQUIRED member of IDiagnosticsClient, so a hand-written client must add it (answering unsupported is a valid implementation).

  • Cache operation counters (M98i): opt-in, minimized cache observations through the diagnostics connector. CachePlugin accepts diagnostics: { enabled: true, alias } (the new exported CacheDiagnosticsOptions, validated when CachePlugin(...) is called); the instance’s own CacheService then counts each backend get/set/delete/has/clear call — getOrSet counted as its internal calls — with explicit succeeded/failed outcomes and hits/misses, present/absent, removed/notRemoved detail counters. No eviction is ever inferred; keys, prefixes, values, URLs, factory results and errors are never captured. Every CachePlugin instance registers an ICacheDiagnosticsSource under the new multi-provider CAPABILITIES.CACHE_DIAGNOSTICS (disabled without the option, with nothing attached to the service). New public surface on @setu-ts/common: CAPABILITIES.CACHE_DIAGNOSTICS, ICacheDiagnosticsSource, CacheDiagnosticsSnapshot, CacheDiagnosticsRecord, CacheDiagnosticsResponse, CacheDiagnosticsOperation. New connector surface: GET /v1/cache and the REQUIRED IDiagnosticsClient.cache(); the status manifest now reports cache: true, and the connector refuses to start with more than 16 cache sources. Enabled calls read the monotonic clock once (twice on a timed one-in-eight sample, which lastDurationMs reports) and settle through one derived promise that re-rejects with the original reason, so an unhandled backend rejection stays unhandled. Measured against a real Redis 7: within run-to-run noise with one call in flight, and about 5% of throughput at 50 concurrent calls; against an in-memory MemoryStore, whose own call is about 100 ns, enabled calls take about twice as long. Disabled calls are unchanged. Results, errors, rejection reasons, unhandled-rejection reporting and relative ordering are unchanged either way; with diagnostics on, the returned promise is a derived one rather than the backend’s own.

  • Distributed tracing observations (M98g): opt-in, minimized completed-span observations through the diagnostics connector. TelemetryPlugin accepts a diagnostics option (TraceDiagnosticsOptions) that appends an internal span processor AFTER the exporter processor in the same provider constructor — the exporter path is unchanged (measured: the exporter still receives every span), the processor never exports, never throws into OTel, and observes only finished SAMPLED spans whose exact raw name appears in the configured operations map, each replaced by its approved alias before anything is retained. A record carries the service and operation aliases, W3C-validated trace/span/parent identifiers (all-zero rejected; parentSpanId present exactly when the parent is locally meaningful), at most eight validated link identifier pairs, kind, outcome (ok/error/unset), monotonic durationMs/ageMs and parentVisibility (observed / remote-or-unobserved / root / unknown — an identifier relationship only, never a fabricated edge). Span names, attributes, events, resource labels, tracestate, baggage, exceptions and status messages never reach collector state, and kind/status values outside the fixed mapping drop the record — kinds follow the @opentelemetry/api SpanKind enum (INTERNAL = 0 … CONSUMER = 4), and a span created with no kind arrives as 0. The plugin ALWAYS registers one ITraceDiagnosticsSource under the new CAPABILITIES.TRACE_DIAGNOSTICS token (claimed in provides): disabled without the option, unsupported with the coverage reason (custom-provider / noop-no-provider) when the stack cannot supply completed spans, no-data/ready when it can, and a collection-failed answer when the connector’s exact validator refuses a source DTO. The batch reports coverage, instrumentation coverage (only families whose registry outcome said enabled), the configured sampler description, M98a’s cursor contract over a 1,024-record ring with exact per-batch lost, and a saturating droppedSpans counter. New public surface on @setu-ts/common: CAPABILITIES.TRACE_DIAGNOSTICS, ITraceDiagnosticsSource, TraceDiagnosticsBatch, TraceObservation, TraceLinkRelationship, TraceSamplerDescription, TraceSourceState, TraceCoverage, TraceInstrumentationKind, TraceOutcome, TraceParentVisibility. New connector surface: GET /v1/traces?after=N&limit=N (authenticated like every operation; a cursor beyond the source’s sequence refused invalid-request, as /v1/queues refuses one beyond its merge sequence; collection-failed/unsupported answered as typed 200 batches, never error text), the status manifest’s traces key now true, and IDiagnosticsClient.traces(after, limit?) as a required member that answers a frozen typed unsupported batch without a request when the negotiated manifest lacks the inspector. Cross-application correlation joins EQUAL trace ids across independently authenticated sessions only; identifiers grant no discovery or connection authority, and no global timeline is implied — ageMs is arrival age at one process.

  • Authorization decision explanations (M98h): opt-in, minimized per-decision explanations through the diagnostics connector. AuthPlugin accepts an authorizationDiagnostics option (AuthorizationDiagnosticsOptions, exported from @setu-ts/auth-plugin) that attaches an internal observer to the first-party RbacService — the boolean IAuthorizationService surface and every guard’s status, body and short-circuit behaviour are unchanged. The observer records, for each hasAnyRole/hasAllPermissions/single check, only the approved role and permission aliases (raw names never retained), the per-step outcome and the fixed reason vocabulary, and the evaluation count actually performed — a compound that short-circuits reports the steps it really took, never a fabricated full sweep. The plugin always registers one IAuthorizationDiagnosticsSource under the new CAPABILITIES.AUTHORIZATION_DIAGNOSTICS token: disabled without the option, no-data/ready while it observes the first-party RBAC service, unsupported once latched — coverage rbac-not-configured when the option is passed without rbac, provider-identity-unavailable when the registry lacks the identity predicate, custom-provider when the capability resolves to a replacement the observer cannot see — and a collection-failed answer when the connector’s exact validator refuses a source DTO. The source re-verifies its provider at read time through the new optional IServiceRegistry.isCurrent?(token, instance) identity predicate — non-resolving, never returning a service — so a replacement registered after startup latches the source to custom-provider rather than guessing at decisions it did not observe; a registry without isCurrent conservatively disables explanations. New public surface on @setu-ts/common: CAPABILITIES.AUTHORIZATION_DIAGNOSTICS, IAuthorizationDiagnosticsSource, AuthorizationDiagnosticsBatch, AuthorizationDecisionObservation, AuthorizationDecisionStep, AuthorizationDecisionOperation, AuthorizationDecisionReason, AuthorizationSourceState, and AuthorizationCoverage. New connector surface: GET /v1/authorization?after=N&limit=N (authenticated like every operation; a cursor beyond the source’s sequence refused invalid-request; collection-failed/unsupported answered as typed 200 batches, never error text), the status manifest’s authorization key now true, and IDiagnosticsClient.authorization(after, limit?) as a required member that answers a frozen typed unsupported batch without a request when the negotiated manifest lacks the inspector. Principal identifiers, role and permission values, request paths and error text never reach collector state; a decision whose requested rule lacks an approved alias is dropped (droppedUnapproved), while an unapproved granting role is only omitted from viaRoleAlias; a compound’s alias list is de-duplicated, so a request repeating a rule never exceeds 128 aliases; the option is validated when AuthPlugin(...) is called, with or without rbac; explanations are session-local and grant no discovery or connection authority.

  • Configuration provenance (M98e): value-free provenance for approved keys, served through the diagnostics connector. ConfigPlugin and loadConfig accept a diagnostics option (ConfigDiagnosticsOptions, exported from @setu-ts/config-plugin) that records — during the one load already performed, never a second pass — where each approved key’s final value was observed to come from: the source category and approved source aliases, evidenced precedence displacement, ${NAME} expansion references, and a schema effect derived only from input/output property presence. No configuration value, value hash, value length, raw key name, or file path is ever retained or served; unapproved keys are never observed at all, and an unapproved file path contributes only its category. loadConfig(runtime, { diagnostics }) builds the record with the snapshot, and ConfigPlugin({ instance, diagnostics }) adopts that exact instance’s record — keeping each entry’s real environment/file origin — or reports an opaque injected instance as unknown with no presence flag and without a single read of it. Provenance adds no read to any configuration object: configured sections perform exactly the same IConfig.get calls with diagnostics disabled and enabled. The plugin always registers the source under the new eager CAPABILITIES.CONFIG_DIAGNOSTICS token (common 0.8.0: IConfigDiagnosticsSource, ConfigProvenanceEntry, ConfigProvenanceOrigin, ConfigSchemaEffect, ConfigDiagnosticsSnapshot), answering disabled without the option; the Diagnostics Connector serves it at GET /v1/config and its status manifest now reports configuration: true; the native client reads it through client.configuration(): Promise<ConfigDiagnosticsSnapshot>, which — like health() — answers a typed unsupported without sending the request when the negotiated manifest (or a legacy M98b status body) reports the inspector unsupported. The configuration and health wire validators — shared by the connector and the native client — now refuse a C0/C1 control character in any alias, as the queue validator already did, so a replacement in-process source cannot forge a consumer’s terminal output; and the connector re-checks the instance binding on the projected copy it signs, so a source whose instanceId reads differently twice is refused rather than signed. The projection copies every source-supplied list itself — reading each item once, never through a source’s own map or toJSON, and never past its budget — so the validator checks exactly the bytes that are signed.

  • Queue observations (M98f): opt-in, minimized attempt, outcome and depth observations through the diagnostics connector. QueuePlugin accepts a diagnostics option (QueueDiagnosticsOptions / QueueDepthDiagnosticsOptions, exported from @setu-ts/queue-plugin) that observes each dispatched attempt of an exactly-allowlisted job name and retains only the approved queue alias, a session-local j<N> job alias, the attempt number, monotonic duration and age, the processor outcome (completed / retryable-error / terminal-error) and the settlement — recorded only AFTER the adapter’s settlement call returned: acknowledged / requeued / dead-lettered on an adapter that confirms it, failed when the call rejected, unknown on RabbitMQ and SQS, whose settlement calls cannot be confirmed. The queue metrics keep their existing timing — recorded BEFORE the settlement call is awaited — so a queue_jobs_total outcome names the settlement requested, never one the backend confirmed. No payload, header, raw job id, claim token, credential or error text is captured. Optional depth counting (depths) runs its own bounded, non-overlapping cycles — never on a diagnostic read — and reports unavailable rather than zero on adapters that cannot count; each depth carries its scope (process-local / shared-backend). Every QueuePlugin instance registers one source under the new CAPABILITIES.QUEUE_DIAGNOSTICS token as a MULTI provider (never in provides, so named instances cannot collide); an unconfigured instance registers an inert disabled source and dispatch is unchanged. New public surface on @setu-ts/common: CAPABILITIES.QUEUE_DIAGNOSTICS, IQueueDiagnosticsSource, QueueDiagnosticsSourceBatch, QueueSourceAttemptObservation, QueueSourceDepthObservation, QueueDiagnosticsBatch, QueueDiagnosticsSourceStatus, QueueAttemptObservation, QueueDepthObservation, and the vocabularies QueueProcessorOutcome, QueueSettlementState, QueueDepthScope, QueueDepthCycleCoverage, QueueDepthCoverage, QueueSourceFailure, QueueDiagnosticsFailure and QueueSourceState.

  • Diagnostics connector — GET /v1/queues inspector (M98f). The connector serves GET /v1/queues?after=<N>&limit=<N> and its status manifest now reports queues: true; the client reads it through the new IDiagnosticsClient.queues(after, limit?), which answers a frozen typed unsupported batch — without sending the request — when the negotiated manifest has queues: false. The connector reads every queue source registered at bootstrap (at most 16), keeps one cursor per source, drains new attempts into a bounded 1,024-event merge ring on each authenticated read, and pages that ring under the M98a cursor contract. A source ring that wrapped between two reads is reported on that source’s status lost, never folded into the batch lost. Each source’s batch is validated key-by-key before merging (a failing source answers collection-failed), the merged frame is validated before signing, and depths are trimmed to keep it within 256 KiB (truncatedDepths). IDiagnosticsClient.queues is a new required member of an interface in a package that has not yet been published.

  • cli — --style is its own axis on setu new and setu generate app (M99e). --style functional|class-based selects the decorator-and-DI composition independently of --template: functional (the default) installs neither DecoratorPlugin nor DiPlugin, and class-based installs both together. It is accepted on the styleable templates rest and microservice and refused with no template to apply to, with an unknown value, and on full-stack, which composes through a starter and has no controller or ingress seam to register decorated classes through. --template class-based becomes a byte-identical alias of --template rest --style class-based: scaffolding it logs one line naming the canonical spelling, the interactive template prompt omits the duplicate, and --help shows (alias of …). The class-based microservice host composes the microservice recipe with the decorator and DI pair, and its generated CQRS and event ingress classes register through DecoratorPlugin({ ingress }) — the functional src/cqrs and src/events barrels are not emitted, so nothing is registered twice. docs/migration-nestjs.md gains Scaffolding and Microservices sections naming the decorator route for @MessagePattern, @EventPattern, @nestjs/cqrs, @nestjs/bull and @nestjs/schedule.

  • Health observations (M98d): an opt-in, minimized view of health-check outcomes through the M98b diagnostics connector. HealthPlugin accepts a diagnostics option (HealthDiagnosticsOptions / HealthDiagnosticsScheduledOptions, exported from @setu-ts/health-plugin) that retains only the LATEST minimized outcome per explicitly allowlisted indicator alias — never a history, and never an indicator’s data, error text, or absolute time. diagnostics.enabled is the LITERAL true (an absent option is the disabled path; enabled: false, or any other value, is refused when HealthPlugin(...) is called — checked at runtime, not only by the type); diagnostics.indicators is the exact registered-name to display-alias allowlist (at most 64 entries, unique aliases, 1–64 UTF-8 bytes, no control characters); diagnostics.staleAfterMs (default 30,000) turns the snapshot’s state stale once any retained observation is older than that monotonic age — an observation itself carries only its ageMs; and the optional diagnostics.scheduled block performs bounded scheduled checks (a subset of the approved names, at most 16; intervalMs 1,000–300,000; timeoutMs 1–30,000 as a reporting bound, not a cancellation; concurrency 1–4). Each cycle covers every scheduled indicator not still in flight, from a rotating start; a timed-out callback that has not settled keeps its one concurrency slot and is never replaced early, so a hung indicator cannot accumulate work; while fewer than concurrency callbacks are hung the others keep refreshing, and once every slot is held by a hung callback no scheduled check starts until one settles (each stalled alias keeps its last observation with a growing ageMs, or stays never-observed, and the snapshot turns stale). Every option is validated when HealthPlugin(...) is called, with fixed messages that never echo a value. An indicator result whose status is not up/degraded/down is observed as failed and the value is never retained; an approved name no indicator carries stays never-observed, with a count-only warning at bootstrap. The plugin registers a read-only source under the new CAPABILITIES.HEALTH_DIAGNOSTICS token and, when scheduled, starts the bounded scheduler from onBootstrap and tears it down from onClose. The /health, /live, and /ready endpoints are unchanged: observation is a side channel over the already-produced result, with one callback per normal check. New public surface on @setu-ts/common: CAPABILITIES.HEALTH_DIAGNOSTICS, IHealthDiagnosticsSource, HealthDiagnosticsSnapshot, HealthDiagnosticsObservation, HealthObservationState, and DiagnosticsInspectorState.

  • Diagnostics connector — GET /v1/health inspector and status-body inspector manifest (M98d). The M98b connector now serves a first inspector operation, GET /v1/health, and the status body gains an inspectors manifest (health: true; configuration, queues, traces, authorization, cache, events, scheduler, realtime, storage, and outboundHttp reserved and false until their own operations ship). The client reads the snapshot through IDiagnosticsClient.health(): Promise<HealthDiagnosticsSnapshot>; a client paired against a legacy M98b three-field status body resolves the manifest to all-false and answers a typed unsupported without sending the request. The connector resolves the optional health source under CAPABILITIES.HEALTH_DIAGNOSTICS once, at registration: an absent source answers unsupported, a registered-but-disabled source answers disabled, and a source that throws answers a value-free collection-failed snapshot — as does a source whose DTO fails the exact validator (unknown keys are dropped by the field-by-field copy; an unknown enum, a non-finite or negative measurement, an oversized alias or a malformed shape is never signed) — none of which runs an indicator or changes the application’s readiness. The answer is projected field-by-field, signed over the exact body bytes, and bounded by the same 256 KiB response ceiling. docs/diagnostics-protocol.md documents the operation and the manifest.

  • secrets-plugin — endpoint on the AWS and GCP providers (M99d). AwsKmsProviderOptions.endpoint and GcpSecretManagerProviderOptions.endpoint point the lazily loaded client at a non-default endpoint — LocalStack or a private endpoint for AWS, a private or regional host for GCP; both are ignored when a client is injected. The option is also available through the plugin — SecretsPlugin({ provider: 'aws-kms' | 'gcp', options: { endpoint } }) — with createProvider forwarding it to the provider. The option is TRANSLATED per SDK: AWS’s config object takes endpoint, while google-gax’s ClientOptions declares apiEndpoint (and has no endpoint member — its ClientStubOptions index signature would let a verbatim endpoint type-check and be silently dropped, so the adapter passes apiEndpoint and the unit test asserts the exact key per provider). This makes X28-1’s question — does get on an absent secret return null? — answerable against a real LocalStack for the first time. The GCP value is host or host:port with no URL scheme, split into the SDK’s apiEndpoint and port because google-gax appends :<port> (default 443) itself — a forwarded localhost:8085 would resolve to localhost:8085:443 — and a scheme, path or out-of-range port is refused at connect(). It is TLS-only: a plaintext emulator is not reachable through it, so inject a client for that. Azure (vaultUrl) and Vault (address) already took an endpoint and are unchanged.

  • decorator-plugin — cross-family misregistration warning (M99d). A class registered as a controller — through DecoratorPlugin({ controllers }), a @Module’s controllers, or autoDiscover — that carries non-HTTP ingress decorators (or one in ingress that carries HTTP route decorators and is registered as a controller by none of those) now gets a register() warning naming the ignored family and the option that would register it. Previously the ignored half was silent: controllers-only served the routes 200 and never fired the processor; ingress-only fired the processor and answered the routes 404. A class in both lists is the correct composition and warns about neither; nothing is auto-registered.

  • cli — devtool scaffolding (M98c). setu new --devtool, setu generate app <name> --devtool, and setu devtool enable [member] opt a project into the M98b local diagnostics connector through one shared planner: they emit a main.dev.ts the production graph never imports, add dev and check tasks, and record a WorkspaceMember.devtoolPort that allocatePort walks beside port, so no generated port collides and ports --reallocate moves both addresses together. The emitted config factory takes the devtool composition as its SECOND parameter on every target — setu commands passes its inert discovery env first, so a first-parameter composition would throw. The entry reads SETU_DEVTOOL_SESSION_ID / SETU_DEVTOOL_SESSION_KEY from the environment, refuses to start without a valid pair, and never mints one; in a workspace the launcher also sets SETU_DEVTOOL_MEMBER, and the generated scripts/dev.ts forwards all three to that member’s child alone while blanking them for every sibling, under a root dev task whose --allow-env grant is scoped to exactly those three names. All three are published CLI surface: renaming one is a breaking change to every scaffolded project. setu devtool enable MERGES into an existing deno.json (your tasks survive, a differing task refuses by name, a second run is a no-op) and refuses by name — with the exact signature to write — on a factory scaffolded before this letter, whose zero-parameter shape would otherwise discard the composition in silence. It refuses a workspace whose scripts/dev.ts predates the devtool for the same reason: nothing regenerates that file, so it would still spawn every member’s start task and pass no per-child environment — the refusal names the remedy, and a workspace missing the runner entirely gets it written. A project type-checks, boots, and answers the reviewed createDiagnosticsClient through the new end-to-end gate.

  • Local diagnostics connector (M98b): an authenticated loopback connection between a native devtool client and M98a’s kernel diagnostics. New package @setu-ts/diagnostics-plugin with DiagnosticsPlugin(options) / IDiagnosticsPlugin.revoke() / DiagnosticsPluginOptions and the native helper createDiagnosticsClient(options) / IDiagnosticsClient / DiagnosticsClientOptions. New public surface on @setu-ts/common: CAPABILITIES.LOCAL_DIAGNOSTICS_LISTENER, ILocalDiagnosticsListenerFactory, ILocalDiagnosticsListener, and LocalDiagnosticsListenerOptions. The RuntimePlugin provides the factory: it binds exactly one 127.0.0.1 listener (port 1024–65535, zero-body policy, duplicate-singleton-header refusal before framework mapping) on Deno and rejects every listen on other platforms before any bind; its close hook releases an open listener on shutdown AND failed-startup paths. Activation is explicit-only (enabled: true, port, per-launch session ID/key; no environment fallback) and refuses startup without M98a’s diagnostics. enabled is the LITERAL true rather than a boolean, because there is no disabled mode: enabled: false has never activated anything, it is refused, so enabled: !isProduction would not yield an inert connector but an application that throws at composition and never boots. The literal makes that a compile error; keep the connector out of production by deciding whether to construct it. The wire protocol is signed with HMAC-SHA-256 over canonical bytes on both ends (docs/diagnostics-protocol.md), with strict monotonic sequences, instance binding, monotonic expiry, bounded polling (3 GET operations, 256 KiB bodies, 128 events per read), value-free fixed errors, and flood isolation (anonymous refusal budget 5/s burst 10; per-session budget 20/s burst 40; 8 handler slots with 7 for unpaired lanes). Every refusal drops supplied input; the connector’s projection copies M98a’s exact field allowlist, so a hostile provider result cannot leak a canary. The connector announces its own bind through the application’s logger — Setu devtool: local diagnostics connector listening on http://127.0.0.1:<port> — replacing the runtime’s bare Listening on … banner, which in an application that also binds a port reads as a second, unexplained application listener. LocalDiagnosticsListenerOptions.onListen carries it; omitted, the runtime’s own banner stands, so a bind is never silent.

  • Kernel diagnostics (M98a): an optional, read-only view of application composition and kernel execution. createApplication({ diagnostics: {} }) enables collection and exposes IApplication.diagnostics — a pull-only IDiagnosticsSource with snapshot() and read(after, limit?). New public surface on @setu-ts/common: IDiagnosticsSource, DiagnosticsSnapshot, DiagnosticsNode, DiagnosticsEdge, DiagnosticsEvent, DiagnosticsBatch, and their vocabulary types DiagnosticsNodeKind, DiagnosticsEdgeKind, DiagnosticsSnapshotState, DiagnosticsFailureCode, DiagnosticsEventKind, DiagnosticsEventOutcome, and DiagnosticsEventStage. New public surface on @setu-ts/kernel: KernelDiagnosticsOptions, KernelDiagnosticsLabelOptions, and the ApplicationOptions.diagnostics option. An omitted option allocates nothing: no collector, mirror, ring, or timer, and the property is absent. Labels are OFF by default and gated by exact allowlists; route patterns, plugin names, capability tokens, and middleware names leave the process only when explicitly approved. A plugin’s version is the one projected field that is NOT allowlist-gated — it is emitted whenever it passes a bounded major.minor.patch semver grammar, so enabling diagnostics discloses the version of every registered plugin, including the framework’s own. Snapshots are bounded (1,024 nodes / 4,096 edges / 256 KiB of compact JSON) and event batches are bounded (1,024 events, 1,024 bytes per event) with drop and loss counters. Startup failure and final shutdown clear retained metadata while preserving the original error. The topology sinks are installed at CONSTRUCTION, so a route or capability registered between createApplication() and start() is projected: a CLI-scaffolded project registers its routes exactly there — through the generated registerGeneratedRoutes(app.router, …) setup call and the hello-world route — and installing the sinks at startup instead would show no routes at all for the default template while the application served them. A composition-time registration carries no owns edge, because no plugin’s register() was running: the application registered it.

  • deno task check:docs now refuses an export added to a published barrel that no Unreleased changelog entry names. Four releases in a row shipped, or nearly shipped, public surface that no entry announced — alpha.10 lost PR #195, v0.4.0 lost ResponseSnapshotInit, v0.5.0 lost respondWithAuthorizationFailure, and v0.7.0 lost thirteen ingress decorators on @setu-ts/decorator-plugin. Every one was caught by a human reading the merged PR list at cut time, which docs/releasing.md carries as a manual step; this is that step, run on every pull request instead of once per release.

    It compares the README ## Exports tables between the last release tag and the working tree, which is what makes it cheap: those tables are derived from the barrel by deno task docs:exports and already gated for drift, so the committed table is the barrel’s contents in a form two git revisions can be diffed in — no TypeScript parser, no deno doc run per package, no network. It reuses parseExportsTable, so this gate and the drift check cannot disagree about what an export is.

    It deliberately does NOT require an entry to name its PR, which was the blocker recorded when the gate was first proposed: entries are prose and carry no stable identifier. Naming the SYMBOL is cheaper for the author and a better check. The mention must sit in a code span — a first cut matched anywhere in the section and was satisfied by the section’s own ### Added HEADING, which is a false PASS, and every Keep-a-Changelog heading is a plausible identifier. An unresolvable base tag fails closed with the remedy in the message, because a shallow checkout fetches no tags and would otherwise compare nothing silently; both workflows now check out full history.

    Two review findings, both the gate’s own failure mode pointed at itself: git ls-files had its exit status discarded, so a missing binary or a cwd outside the repository returned an empty list that read downstream as “no packages to check” — demonstrated by running it from /tmp, where it answered 0 READMEs and passed. It throws now, and a run that compared NOTHING is reported as a failure rather than a pass, in the shape script-coverage already uses for its target set.

    Validated against the three releases that actually lost an export, before it was written: replaying each reports exactly the symbols their manual checks found, and replaying the current tree reports none. What it does not catch is a release-worthy change that adds no export — PR #195 was dependency ranges, a workflow and a release artifact — so reading the merged PR list stays in the runbook.

Changed

  • AuthPlugin’s register() returns a promise when a saml provider is configured (M100f), because the SAML library load is awaited there. Every other configuration still registers synchronously. SignInProvider gains the SamlProvider arm, so code switching exhaustively over provider.kind must handle 'saml'; an unknown kind is now refused naming 'oidc', 'oauth2' or 'saml'.

  • common — IAuthSessionService gains a required pending() member and SignInOutcome widens (M100d, breaking). IAuthSessionService now requires pending(ctx): PendingSignIn | null, which reads the pending second-factor record (if any) for the session. SignInOutcome widens from { status: 'signed-in' } to { status: 'signed-in' } | { status: 'second-factor-required' }, so a sign-in that requires a second factor no longer returns signed-in. Applications that implement IAuthSessionService by hand must add the pending() member; applications that consume the outcome must handle the new second-factor-required arm. @setu-ts/common exports the new PendingSignIn interface. With signIn.mfa unset the auth-plugin’s own AuthSessionService never returns second-factor-required, so existing consumers see no behavioral change.

  • auth-plugin — AuthPlugin now registers authMiddleware() globally by default (M100a). Every request runs the passive strategy chain at priority 300, so ctx.request.user is populated on routes that previously saw no principal, and a backend-backed strategy (apiKey.validate, a store-strategy session) now performs its lookup for every request that carries its credential, including health probes and metrics scrapes that do; a request without one costs no lookup. middleware: false restores the previous composition exactly: nothing is registered globally and route-level authMiddleware() copies keep working. middleware: { exclude } skips selected paths instead (no path is excluded by default). A hand-added global authMiddleware() should be removed; left in place it stays correct but runs every strategy twice. See docs/upgrading.md.

  • kernel — retrying a start() that failed after plugin registration began now throws by name. start() rolls its started state back on failure, and its comment promised the failed start could be “corrected and retried”. Once a plugin had run register(), that retry re-ran every plugin against state that survived the rollback — the registry (sealed, too, if the failure came after bootstrap), lifecycle hooks, routes and middleware — so in the ordinary case it failed with a misleading Capability 'runtime' is already registered (or Cannot register capability 'runtime' after runBootstrap() has completed). It now fails with Cannot retry start() after plugins have registered … Create a new application instead., before anything runs again. This also refuses a retry that used to succeed: when the first plugin to run threw before writing any state — for example a runtime provider rejecting its options — nothing was left behind and the retry worked. It is refused now because the kernel cannot tell a plugin that wrote nothing from one that opened a connection its onClose did not release. Failures during plugin resolution (no runtime provider, an unsatisfied dependency, a cycle) stay retryable, as before. Migration: code that catches a failed start() and calls start() again on the same application must create a new application instead (call your createApp() again).

  • realtime-backplane-plugin — a Redis publish on a failed connection is reported, not lost. A connection that stays open while the server answers nothing — a paused or partitioned host that sends no reset — triggers no ioredis reconnect, so no retry budget applied and publish() never settled: the WebSocket and SSE consumers never logged the dropped frame, the promises accumulated, and an observed publish (M98l) was never recorded. Measured against a paused real Redis 7: still pending after 20 s. The two connections the lazy path builds now carry an ioredis commandTimeout, from the new RedisBackplaneOptions.commandTimeoutMs (default DEFAULT_REDIS_COMMAND_TIMEOUT_MS, 15 s; 0 disables; a value outside 0–2147483647 throws RangeError at construction, since a longer delay overflows the runtime timer to 1 ms and every command then times out at once). The default sits above ioredis’s ~11 s retry budget, so a dropped connection still buffers and rejects as documented. An injected client/subscriber pair is unaffected and keeps its own timeout. IRedisModule.create gains an optional second options argument carrying it; existing callers and modules still type-check. Behaviour change: RedisBackplane.publish() before connect() or after close() now REJECTS (“the redis transport is not connected”) instead of resolving without sending, so a frame sent nowhere reads as a failure — it previously counted as a successful publication when observed. close() during an outage now releases both connections. It stopped at the first rejected step — an UNSUBSCRIBE or QUIT that timed out — so both connections kept reconnecting after the application stopped; measured against a real Redis, both reconnected (ready) once the server returned. Every step now runs, and a connection whose QUIT fails is force-closed through the new optional IRedisBackplaneClient.disconnect?() — which is what stops reconnection while the server is unreachable, since ioredis discards a QUIT that never reached it and would otherwise reconnect and re-subscribe once the server returns — and the first failure is still rethrown after everything is released. Against a silent server close() can take up to about three command timeouts (≈45 s at the default) — lower commandTimeoutMs if that exceeds a shutdown grace period.

  • cli — setu new --template full-stack now demonstrates React Router route middleware, and explains both middleware layers. A generated full-stack project has two: kernel middleware (setu generate middleware), which runs for every request, and React Router’s route middleware export, which runs only for the SSR routes it is attached to. The scaffold carried no example of the second, so where a route middleware goes and how it hands a loader a value had to be learned from React Router’s documentation. It now emits app/middleware/require-user.server.ts and app/models/user.ts, a currentUserContext key and getCurrentUser accessor, and a README section comparing the two layers. Generated behaviour changes: /products in a newly scaffolded project now redirects a visitor who is not signed in to /login (existing projects are unaffected). A kernel middleware generated into a project that installs react-router-plugin names the route-level alternative in its JSDoc, and every generated middleware’s priority comment now lists tenant resolution (40) and request logging (100), which it omitted, and explains short-circuiting before await next() rather than after it, where returning early no longer stops anything.

  • cli — the --di refusal names the --style axis (M99e). The retired --di flag’s message now directs the caller to --style class-based (with --template rest or microservice) rather than only to --template class-based, and the broker refusal’s “use --template microservice” advice gains “(add --style class-based for decorators)”.

  • cli — every newly scaffolded project declares @setu-ts/kernel and a two-parameter createApp (M98c). The config factory’s devtool parameter names KernelDiagnosticsOptions, so the kernel is now pinned on every target — including starter-composed templates, which referenced nothing from it before. A package import naming only types renders in the import type { … } form. Every generated Deno workspace’s root dev task also carries the scoped --allow-env=SETU_DEVTOOL_SESSION_ID,SETU_DEVTOOL_SESSION_KEY,SETU_DEVTOOL_MEMBER grant its runner reads, whether or not the devtool is enabled. Existing projects are unaffected: nothing regenerates a scaffolded file, and --allow-net is unchanged and still unscoped.

  • storage-plugin — eleven provider methods now REJECT instead of throwing synchronously (BREAKING for callers that caught the throw). Ten guard with a private #assertConnected() that throws — S3Provider get/delete/exists/getSignedUrl/getStream, GcsProvider put/getStream, and AzureBlobProvider delete/exists/getSignedUrl. Each was typed Promise<...> but not async, so the throw escaped synchronously — provider.get('k').catch(handleIt) never ran and the error was uncaught, while put() on the same class rejected. This is the recurring defect class fixed in M52b, M52c, M70j, M79 and PR #355; all are now async, so the same precondition arrives as a rejection, matching the committed IStorage contract and each provider’s own async siblings. AzureBlobProvider.getSignedUrl’s second refusal (no account key to sign with) moves with it. The eleventh is LocalStorageProvider.getSignedUrl, whose refusal is permanent (local storage cannot presign) rather than a lifecycle precondition — which is why it was nearly missed. It is the same defect in the same shape, and it was reachable through the PUBLISHED contract rather than only the provider class: StorageService.getSignedUrl is a bare passthrough, so the throw escaped out of the IStorage an application resolves from CAPABILITIES.STORAGE. Leaving it would also have made local the only one of six getSignedUrl implementations that throws — cloudflare-plugin’s R2Storage already answers this exact cannot-presign case with Promise.reject. Migration: no source change for callers using await or .catch(), but only the await ones see no difference — a .catch() handler was previously BYPASSED by the synchronous throw and now actually runs, which is the point of the fix. A caller that wrapped one of the eleven calls in a synchronous try/catch without awaiting stops catching; move the catch onto the promise (await … in an async function, or .catch(...)). See docs/upgrading.md. StorageService.delete/exists/getSignedUrl were bare return this.#provider.x() passthroughs and are now async, so a THIRD-PARTY provider’s synchronous throw can no longer escape IStorage either. That path is reachable from the published surface: StorageService is barrel-exported, and structural typing means an application needs no StorageProvider import to pass its own provider. Nothing changes for the five built-in providers, which all return promises.

Fixed

  • setu add printed deno install for every project. A Node or Bun project was told to run a command its toolchain does not use. It now prints the project’s own install command — npm install for Node and Cloudflare Workers, bun install for Bun, and deno install --min-dep-age 0 for Deno — and, when it writes an npm-compat package.json entry into a Node, Bun or Workers project whose .npmrc does not route the @jsr scope, prints the registry line the install needs. setu add still never installs for you. A package.json with no start script (one the CLI did not write) is now read as Node, or Bun beside a bun.lock / bun.lockb, unless a deno.json sits beside it — it was read as Deno. This also changes setu generate in such a project: it emits the Node or Bun test harness rather than a Deno one whose @std/* imports the project could not resolve.

  • setu add at a workspace root wrote the pin into the root and exited 0. Framework packages are pinned per member, and setu generate reads the member’s manifest for plugin gating, so the package looked installed while no member could use it. A workspace root — recognised by setu.workspace.json, a deno.json workspace key, or a package.json workspaces key — is now refused with exit 2, writing nothing and naming --dir <member directory>. A script that relied on the old exit 0 at a root will now fail.

  • mail-plugin now loads nodemailer 10, closing a high-severity advisory. SmtpProvider lazily imported npm:nodemailer@^9, and every 9.x release is inside GHSA-v53p-9fqp-m79j (quadratic backtracking in the address parser’s free-text fallback, a remote denial of service, fixed in 10.0.6). The lazy import is now npm:nodemailer@^10. nodemailer 10’s only breaking change is requiring Node.js 20 or newer; the transport API the plugin uses (createTransport, sendMail, verify, close) is unchanged, which was verified against the real 10.0.12 module. An application that injects its own transport is unaffected.

  • cache-plugin, messaging-plugin, queue-plugin, realtime-backplane-plugin, scheduler-plugin, auth-plugin — a Redis outage no longer prints to the console. Every ioredis client the framework builds had no 'error' listener, so ioredis fell back to console.error and printed [ioredis] Unhandled error event: … ECONNREFUSED on every reconnect attempt — dozens of lines per outage, outside the logger, its structure, its redaction and its sink. @setu-ts/common adds createConnectionErrorReporter and attachConnectionErrorReporter: the first error of an outage is logged at warn, identical repeats at debug with a count, the recovery at info, and a failing logger never throws into the driver (types ConnectionErrorReporter, ConnectionErrorReporterOptions and ConnectionErrorLogger). The five plugins attach a reporter over the application logger (read at call time) to the clients they BUILD, through a new optional connectionErrorReporter option on the Redis store, broker, adapter, lock and backplane options; an injected client gets no listener, since it belongs to the caller. RedisRateLimitStore is built by the application, so it takes the reporter as an option and keeps the console fallback without one. With no logger registered the events are dropped; health reporting is unchanged. All additions are optional, so no caller changes.

  • diagnostics-plugin — the connector refuses URL aliases of a target and answers an out-of-range events cursor as a caller error. Found by the M98b committed-tree security audit. The handler parsed the target from the NORMALIZED URL, so /v1/./snapshot, /v1/x/../snapshot, /v1/snapshot? and a target carrying a fragment were each served as the canonical operation, contradicting the protocol specification’s refusal of non-canonical forms; the raw request target must now equal the canonical one. Integrity was never affected — the MAC covers the canonical target, so no request could be retargeted without the key. Separately, GET /v1/events with a cursor beyond the reader’s sequence let the reader’s RangeError escape and answered 503 unavailable; it now answers 400 invalid-request, as /v1/queues and /v1/traces already did. No released version carries either defect.

  • kernel, queue-plugin, telemetry-plugin — a stopped diagnostics reader no longer echoes an invented cursor. After shutdown, IApplication.diagnostics.read(), the queue diagnostics source and the trace diagnostics source accepted ANY non-negative safe integer as after and returned it verbatim as next — read(500) on a reader that had issued one event answered next: 500 — contradicting their own contracts, which refuse a cursor beyond the current sequence. Shutdown never rewinds a sequence counter, so every cursor a reader could have been issued is still at or below it: a closed reader now applies the same refusal it applies while running, and still answers an empty closed: true batch for every cursor it did issue. No released version carries the defect; all three readers are new in this release.

  • diagnostics-plugin — the native client binds every post-pairing response to the paired instance. Pairing compared the status body’s instanceId with its authenticated header, but later exchanges only verified the MAC — and the x-setu-instance header is an INPUT to that MAC, so a peer holding the session key could sign a snapshot under a different identity (header B / body A, header A / body B, or both) and snapshot() and read() accepted all three. The shared exchange now refuses any signed response whose header differs from the instance the request presented, which covers every operation including the four inspector reads, and snapshot() / read() require the body instanceId to equal the paired instance (a null body identity, valid for the in-process reader before the runtime assigns one, is refused on a paired network session). A refusal is the existing fixed connection error and, like any post-pairing verification failure, not terminal. Legacy/current status negotiation and terminal initial pairing are unchanged. The client has not yet been published, so no released version carries the defect.

  • diagnostics-plugin — the native client validates the full core DTO and freezes what it returns. The core snapshot and batch validators checked little more than primitive types: any string passed as state, a node or edge record could carry any fields, and an event needed only a numeric sequence and string operationId/stage. They now enforce the M98a contract — only defined keys, fixed vocabularies, per-kind node fields, prefixed unique node ids, bounded control-free labels, edges between present nodes, canonical operation ids, non-negative (or null) timings, validated trace identifiers, consecutive sequences, and the 1,024/4,096/128 bounds — and read() now checks the cursor contract it sent, as queues() and traces() already did. snapshot() and read() also returned the parsed JSON unfrozen while their documentation promised frozen data; both now deep-freeze it. An in-process instanceId: null remains valid; a middleware priority and an event statusCode stay unranged finite numbers because the application sets both; a refusal is the fixed connection error.

  • kernel — diagnostics omit a non-finite middleware priority or response status. Both were recorded verbatim, so priority: NaN (what Number(env.X) yields for an unset variable) or status(NaN) put NaN in a field common types as number, and the connector serialized it as null. A non-finite value is now omitted from the node or event; every finite value is still recorded as the application set it.

  • kernel — a retried start no longer reuses diagnostics event sequence numbers. A failed start cleared the event ring and restarted numbering at 1, so after the kernel-supported correction (unregister + start()) a reader’s cursor from the failed attempt was refused as “beyond the current sequence” or silently skipped the retry’s first events. The discarded records are now treated like an eviction: numbering continues, and a reader’s lost counts them; the DiagnosticsBatch.lost JSDoc now says so.

  • telemetry-plugin — exported spans now carry the span kind and status OpenTelemetry defines. TelemetryService mapped the framework’s SpanKind onto the OTLP WIRE numbering rather than the @opentelemetry/api enum a span holds in memory, so every server span was exported as CLIENT, client as PRODUCER, producer as CONSUMER, and consumer as an out-of-range kind. And ISpan.setStatus('ok' | 'error') handed OTel the bare string, while OTel’s Span.setStatus reads status.code — so every span’s status was exported as {}: an errored request or a thrown handler never reached Datadog, New Relic or Application Insights as an error. Both are corrected (kinds 0–4, status { code: 0 | 1 | 2 }), verified against the real locked SDK. Behavior change for dashboards: server spans that were filed under CLIENT now appear as SERVER, and error rates computed from span status become non-zero where requests actually failed. The same defect made the M98g diagnostic processor drop every span that set a status — every HTTP server span.

  • queue-plugin — a job whose thrown value cannot be described is no longer left stuck. With a logger registered, QueueService converted a non-Error thrown by a processor with new Error(String(error)) outside its reporting guard, so a value whose string conversion throws — a payload-derived { toString: 1 }, a revoked Proxy — made the failure report itself throw. That escaped the job runner before the requeue or dead-letter call, and the job stayed in its processing state for the life of the process. Describing the value now happens inside the guard, falling back to a fixed message, and the queue metrics collector’s report path is guarded the same way. Found by the M98f committed-tree security audit; no configuration change is needed.

  • cli — the --runtime documentation read as a lock-in, and one of its claims was stale. The scaffolding example setu new my-app --runtime node # deno | node | bun | cloudflare-workers suggested a project is tied to the runtime it was created for. It is not: on deno, node and bun every template emits byte-identical main.ts, setu.config.ts and src/, and RuntimePlugin detects the platform at startup, so only the manifest and start command differ. docs/cli.md now says so and gives the verified steps for moving between runtimes — including that a move to Deno must replace package.json rather than sit beside it (setu generate reads package.json first), with full-stack as the one template that keeps it for the Vite build — and the Workers exception. Separately, the CLI README’s option table said --runtime defaults to deno on setu generate; it is detected from the project’s manifests. Wording only; no behaviour, API or export changed.

  • cli — --transport-url could inject code into a generated workspace member. The broker URL fallback was wrapped in quotes without escaping when rendered into each member’s setu.config.ts, so a value carrying a quote closed the string literal and the rest ran as code whenever the member started — redis://h:1'+(globalThis.PWNED='yes')+' did exactly that. The value is also persisted in setu.workspace.json, so every later setu generate app repeated it. It is now rendered as an escaped string literal that evaluates to exactly the value given, for both the flag and a hand-edited manifest; an ordinary URL renders byte-identically, so no existing generated file changes. A member name read back from that manifest reached the generated discovery module the same way, as a raw object key, and is rendered the same way now. Present since the workspace transport arrived; found by the M99e security audit and fixed there at the maintainer’s direction.

  • cli — a name that is not safe to generate from is refused before anything is written (M99e). Every name-taking verb (new, generate app, generate <schematic>, generate library, adopt) joins the derived kebab into a path, and setu new ../sibling, setu new .. and setu generate app ../sibling wrote the scaffold into an ancestor directory. Punctuation also passed through into generated source: setu g service a:b emitted class A:bService, and a quote (x'y) closed the @Injectable token literal early — source injection from the command line — while setu new 'x"y' --runtime cloudflare-workers broke the emitted wrangler.toml. A verb that generates source now requires every derived form (Pascal, camel, SCREAMING) to be a TypeScript identifier: letters (Unicode included, so café still works), digits after the first character, and the separators -, _ and space. setu new takes a portable project name — it starts with a letter or digit and holds only letters, digits, . and - — so 3d-shop, 2048 and my.app keep working. A Windows device name (con, nul, com1, … with or without an extension) and a name ending in . are refused everywhere, since Windows cannot create either. Any name over 255 bytes, and a name whose planned file name (<name>.controller.ts, a library’s test/<name>.test.ts) exceeds 255 bytes, is refused too, where it used to fail mid-write with an uncaught File name too long. Every refusal is a usage error (exit 2) with no writes, and echoes the name with its control characters escaped, so the message stays one line. No argument can forge an output line any more: an option value carrying a control character is refused before any command runs, every line the CLI writes has its control characters other than the line feed and tab escaped (bidirectional format characters included, so a quoted value cannot be reordered as displayed), and a positional quoted back (a name, a schematic, an unknown command, an add package, a devtool enable member) is escaped where it is quoted. A suggested command copies a value only when it names a real template, style or runtime, so an unknown value is never planted in a command you are told to run. --transport constructor is now an unknown transport rather than an alias of a built-in.

  • health-plugin — an unrecognized indicator status could hide another indicator’s down, so /health and /ready answered 200 over a failing dependency. The aggregate took the worst status through a rank table, and a status outside up/degraded/down has no rank, so every comparison against it was false and the fold carried it forward: an indicator returning such a value after a down one replaced the down, and a later degraded then won — measured, /health answered 200 degraded with a check down, so a readiness probe passed over a dead dependency. The value was also published verbatim onto /health, and a null result made the endpoint reject. An indicator result is now read through one rule, shared with the M98d observation collector: a result that is not an object, or whose status is outside the three, is reported as { status: 'down', data: { reason: 'invalid-result' } } with its value dropped, and a throwing getter is a failed check (reason: 'error'). A data value that is not an object is omitted. Behaviour change: an indicator that returned an unrecognized status now fails /health and /ready with 503; return 'up', 'degraded' or 'down'. The defect predates M98d — it was on main since M20 — and is fixed on the M98d branch at the maintainer’s direction, where the security audit found it.

  • cloudflare-plugin — the R2 documentation said getSignedUrl “throws” where it rejects. The behaviour was always correct (R2Storage.getSignedUrl returns Promise.reject), but four published sites — the class JSDoc, the method JSDoc, the README caveat and PUBLIC_API.md — read as an instruction to catch it synchronously, which never works. Wording only; no behaviour, API or export changed.

  • kernel — inject() no longer MUTATES a caller-supplied Headers instance (M99c). The per-shape content-type default wrote onto the caller’s own object when InjectRequest.headers was a Headers rather than a record, so one instance reused across two injected requests — an ordinary way to hoist a shared auth header in a test — carried the first request’s content-type into the second: !headers.has('content-type') then declined to set the right default, and a URLSearchParams body following a JSON body arrived as JSON. inject() now defaults onto a copy, which also contains a handler that mutates ctx.request.headers. This is the aliasing hazard the byte copy in the same release guards against, one field over; it predates the Blob default below, which only widened its reach, and is fixed here at the maintainer’s direction rather than on a separate branch. Nothing restores the old behaviour, because nothing could depend on it: a caller wanting the header set passes it explicitly, which still wins.

  • kernel — inject() defaults a Blob body’s content type from blob.type, so an injected multipart Blob no longer 500s where the same Blob through fetch() answers 200 (M99c). A Blob CARRIES its type — the caller has said what the bytes are — and the platform’s new Request(url, { body: blob }) sets the header from it, so the injected entry point now says the same thing: a non-empty blob.type becomes the content-type default, a typeless Blob contributes nothing, and an explicit headers['content-type'] still wins. Uint8Array and ArrayBuffer keep NO default: bytes alone genuinely say nothing about their encoding, so the 0.7.0 entry’s stated reasoning now names only those two shapes, which is the correction to it this fix ships alongside.

  • sdk — two correct naming rules shipped together no longer collide with each other (M99c). X11-6 made @setu-ts/openapi-plugin name a reused schema after its first use (getOrdersResponse200 → component GetOrdersResponse200); X11-9 made the generated client hoist an inline multi-line response body under the SAME derivation. One schema that is both reused (so it becomes that component) and rendered inline (so its body is hoisted) claimed the identical name twice, and generation aborted with Duplicate generated name 'GetOrdersResponse200' — the 0.6.0 SDK throws identically, so this is not a regression, only invisible until a document carried the trigger. Neither entry above is rewritten: each rule is individually correct, and the interaction is what changed. The component name is published surface of the document, so the HOISTED ALIAS yields — it asks for its preferred name first (byte-identical output for every document that generates today), takes …Response<status>Body when that is claimed, then an allocated numeric suffix; and because an alias names an anonymous inline schema the document never named, the same preference-and-fallback now resolves all four hoist sites (request body, parameter, success response, error body) instead of moving the abort.

  • storage-plugin — cancelling a download no longer crashes the process (S3, GCS, Azure). S3Provider.getStream returned the SDK’s node stream through Deno’s node:stream-to-web adapter, which enqueues on 'data' without checking whether the web controller was already closed: a reader.cancel() that raced origin teardown (a connection dropped while bytes were still flowing) enqueued into the closed controller and threw an uncaught TypeError: The stream controller cannot close or enqueue out of the tick queue, killing the process. Node-shaped bodies are now wrapped in a pull-driven adapter (src/providers/provider-stream.ts) that guards every controller touch and destroys the node stream on cancel; the measured read-ahead shape is unchanged. GcsProvider and AzureBlobProvider carried the same hazard in a deterministic form — no cancel hook at all, so a cancelled download kept flowing and the next chunk hit the closed controller — and now route through that module’s EAGER adapters, a different one from the pull-driven S3 wrapper above, which release the upstream stream on cancel (they remain eager, never demand-driven). GcsProvider picks its adapter by probing the stream’s shape rather than assuming one, so an injected client whose createReadStream() only emits 'data'/'end'/'error' — all IGcsClient has ever promised, since bucket() returns unknown — keeps streaming, now with the cancel hook it never had; a value carrying neither shape is refused by name instead of failing with a bare TypeError. The S3 wrapper also keeps a lifetime 'error' listener on the node stream: its per-read listener is detached between pulls, which is exactly where a slow consumer sits once the web queue is full, so an origin failure in that window would otherwise emit an unhandled 'error' and escape as the same uncaught, process-killing throw — the error is now recorded and surfaced to the consumer on the next read. A real-backend regression test drives the firing shape — three back-to-back reads, then concurrent origin teardown and cancel — against MinIO.

  • logger-plugin — default redaction now covers normalized authorization headers. The shipped default is lowercase and default matching is case-insensitive across both console and Pino transports; a caller-supplied redact list remains case-sensitive. This prevents Fetch’s normalized bearer-token header from reaching structured logs (M99a).

  • messaging-plugin — a known Service Bus data-plane outage no longer ages back to healthy. A network publish failure remains reachability evidence until a successful publish or a positive management probe contradicts it; dataPlaneEvidenceMs now bounds positive evidence only (M99a).

  • cli — a generated workspace image now verifies its lockfile at build time. The build step is deno cache main.ts && deno install && deno install --frozen, not deno cache main.ts alone. Deno records a jsr package’s npm edge list nondeterministically on a cold cache: four --no-cache builds of one unchanged workspace left @setu-ts/messaging-plugin missing its npm:amqplib and npm:ioredis edges twice and complete twice, while both packages were recorded in the lockfile’s package section every time. Runtime --frozen does not write the lockfile, so against an incomplete one it refuses, and every container died at registration reporting a stale lockfile — an image that built green and never served. deno install completes the edge lists the frozen check compares against — the same resolver writes and verifies them — and deno install --frozen proves it, so a still-missing edge fails the build once instead of every container at startup. Regenerate an existing workspace’s managed Dockerfile with setu generate app <member> to pick it up.

  • check:deploy --generated now sees this defect on every run rather than intermittently. It built from whatever lockfile the host’s own install happened to write, so a Dockerfile regressed to deno cache main.ts alone — the exact pre-fix form — passed it. The gate now strips every framework npm: edge from the scaffolded workspace’s lockfile before building the image, leaving every resolved version pin intact, so the build has to establish lock completeness itself; that same regressed Dockerfile now fails. A strip that removes nothing fails the gate rather than passing quietly.

  • cli — generated workspace Dockerfiles now run with deno run --frozen. The runtime uses the lockfile that its build cached and still cannot modify it on a generated read-only root, so a lazy broker driver cannot re-resolve an uncached npm transitive dependency at startup (M99b).

  • cli — an interrupted scaffold compensates for caught write failures. It restores files it overwrote, removes files and empty directories it created, and reports any incomplete recovery; adopt now reports an entry rewrite failure instead of presenting it as a port-literal mismatch. Reported by u/kantorcodes1 (M99b).

0.7.0 — 2026-09-18

Added

  • decorator-plugin, cli — decorators for the six non-HTTP ingress categories (M97a). Queue, scheduler, domain events, messaging, WebSocket and CQRS get the class-based registration surface HTTP has had since M9: @Processor, @Cron/@Every, @OnEvent, @Subscribe, @Gateway (with @OnOpen/@OnMessage/@OnClose) and @CommandHandler/@QueryHandler, plus @UseIngressBehaviors and @UsePipelineBehaviors for the per-target chains. Six categories, seven provider tokens, because CqrsPlugin provides COMMAND_BUS and QUERY_BUS separately and the registration pass resolves both buses directly.

    A decorated target registers imperatively against the capability RESOLVED from its token, which is why this is an ergonomic surface rather than a second pipeline: the behaviour-wrapped service is what each plugin already registers under its token, so a decorated processor, job or subscription inherits the M86 ingress-behaviour and M90i tracing stack unchanged. No common change, no new capability token, and no plugin imports another — the ingress map lives on the concrete MetadataStore, and no ingress plugin depends on METADATA_STORE, so no cycle is introduced.

    Four things are refused at register() rather than at the first message, each naming the class, the method and the alternative: a decorated target whose backing plugin is not registered (naming both the plugin and the token), two primary ingress decorators on one method, @UsePipelineBehaviors anywhere but a command or query handler, and @UseGuards on a non-HTTP ingress target — guards are an HTTP-pipeline concept and do not carry over, so the refusal points at @UseIngressBehaviors/@UsePipelineBehaviors instead.

    setu generate emits the decorated form from the existing command-handler, query-handler, event-handler, job and ws-route schematics when the project has decorator-plugin, and setu add knows the backing plugins by short name.

  • config-plugin — typed configuration sections (M97c). defineConfigSection({ prefix, keys, schema }) declares related flat keys, and getConfigSection(config, section) returns the exact schema output validated and cached at startup. Sections run after whole-snapshot validationSchema coercion and are validated for both ordinary loading and an injected instance. The explicit prefix-stripped keys list keeps IConfig unchanged while allowing an arbitrary injected implementation to be validated through its existing named-read contract. A section validation error identifies only its declared prefix; it never carries the schema diagnostic or a configuration value.

  • decorator-plugin, common, openapi-plugin — response shaping for decorated handlers (M97b). @HttpCode(status), @ResponseHeader(name, value) (repeatable, distinct names) and @Redirect(url, status?) let a decorated handler state its success status and its response headers in its declaration instead of accepting @Params(Ctx()) purely to say one fixed thing. @Params(Ctx()) is neither replaced nor deprecated: it remains the way to compute a status or header per request, and the only way to write a multi-valued header. The declared shaping is written to the response builder BEFORE the handler runs, so a returned HandlerResult (ctx.response.status(202).json(...)) still wins and @Render composes with a declared status unchanged. @Redirect does NOT short-circuit — a decorator cannot decline to call the method, so the handler still runs and a plain return is still serialised alongside Location.

    Every argument is refused at register() rather than per request, naming the controller, the method and the value: a status outside [200, 599] (or [300, 399] for @Redirect), a header pair the runtime rejects, the same header name twice, Location alongside @Redirect, and one handler carrying both @HttpCode and @Redirect. The alternative in each case is a failure nothing can answer — a RangeError thrown inside the HTTP adapter after the middleware pipeline has finished, or a TypeError while response headers are written, answering 500 on every request to that route.

    @setu-ts/common gains RESPONSE_METADATA, RouteResponseMetadata, withResponseMetadata and responseMetadataOf — a Symbol.for-keyed brand on the ROUTE HANDLER, the SECURITY_METADATA / VALIDATION_METADATA precedent, carried on the handler rather than a middleware because a success status is a property of the handler and a decorated route may carry no middleware at all. @setu-ts/openapi-plugin gains deriveResponseStatus (default true), which reads that brand and documents the operation under the declared status instead of the assumed 200; a declared schema.response still wins. Defaulting it on is safe because the brand is new surface: an application that changes nothing gets a byte-identical document, asserted rather than assumed. @ResponseHeader is deliberately NOT derived — an OpenAPI response-header entry needs a schema and a description the declaration does not carry. Adopting @HttpCode moves a regenerated client’s success type for that operation from 200 to the declared status; see docs/upgrading.md.

    Also: the OpenAPI status-description table gains the five redirect statuses (301, 302, 303, 307, 308), which previously fell through to the bare fallback 'Response'.

  • deno task check:docs now refuses a markdown table row whose code spans will not render as written. Two rules, the same defect at two stages of its life, both caused by a | the author did not escape — a pipe ends a table cell even inside backticks, and GFM then DROPS the cells past the header’s width. unescaped-pipe catches a span still holding a raw pipe, i.e. at the moment it is written; unterminated-span catches a span left open, which is what the damage looks like afterwards, once the dropped cells have taken the closing backtick with them. Both real instances were found by a reviewer and a hand-written sweep rather than by any gate: PUBLIC_API.md’s RenderDecorator row had been rendering with its entire return union missing since M92, and packages/static-plugin/README.md wrote a default value with its backticks transposed. The checker walks spans by matching the opening backtick run’s length rather than counting backticks, because counting is unsound — a double-backtick span wrapping a literal backtick holds an odd number of them and would be misreported. A row counts only when its block carries a delimiter row, without which the SseMessage.data entry further down this file is a day-one false positive: deno fmt wrapped its inline union type mid-line, leaving a continuation line that begins with a pipe and is not a table.

  • common, logger, telemetry, audit — one synchronous redaction seam for framework egress (M96). @setu-ts/common now exports createRedactionService, IRedactionService, policy and redactor vocabulary, an erase redactor, and a suffix-mask factory. The logger, telemetry, and audit plugins accept the same policy or service through redaction; policies are compiled once, never mutate caller-owned records, and apply before records leave those components. Telemetry now omits query strings and fragments from http.url by default; queryParameters: 'redact' retains only policy-transformed query values, and fails closed to omission with a warning when no policy is supplied. Audit redacts before, after, and metadata before deep-freezing stored entries.

  • Public contribution intake is now maintainer-triaged and protected from untrusted automation. GitHub Discussions, focused issue forms, a contribution guide, code of conduct, security policy, and pull-request template direct beta feedback, questions, compatibility reports, and proposed work into an explicit maintainership flow. Fork pull requests are proposals only: GitHub requires maintainer approval before any outside contributor workflow begins, and an approved fork does not run the repository’s validation jobs, services, website build, or automatic CodeRabbit review; a minimal no-checkout policy gate prevents skipped jobs from satisfying merge protection, and an accepted contribution is reproduced and validated in a trusted repository branch.

  • A documented example’s BEHAVIOUR is now checked, not only its types. The fence compilers prove an example type-checks; the v0.6.0 view defect type-checked perfectly and escaped nothing, so every gate was green while the README taught an XSS hole. scripts/check-example-behaviour.ts closes that class by RUNNING the documented components: every component a fence defines in a view-rendering document is rendered through the framework’s own renderComponent with <script>alert(1)</script> substituted into its props, and the gate fails if the payload survives unescaped. It derives its work from the documents rather than from an author remembering to write an assertion, so a component documented tomorrow is covered the day it is written; it runs inside check:docs. A component whose own comment labels it UNSAFE or DO NOT USE is checked in the OTHER direction — it must still be unsafe — so a warning that stopped being true fails rather than quietly misinforming. raw() is honoured as the documented opt-out. The first cut of this gate passed against a reintroduction of the exact v0.6.0 defect: it deduplicated component names per document, and a README routinely defines the same component twice — once functional, once class-based — so the class-based one, which is where the defect lived, was skipped. Scope is per fence, and the case is pinned.

  • The behaviour gate substitutes a second payload — javascript:alert(1) — into every prop and asserts on the RENDERED OUTPUT (M95d, X46-1). Escaping neutralises <script>alert(1)</script> because that payload is made of HTML metacharacters; a URL scheme needs none, so it survived both authoring arms verbatim and executed on click in real Chrome. The gate now fails when the scheme reaches a rendered href, src or action — decided on the output, not the prop name, so it cannot drift as components change. A component whose own source routes a value through raw() — or spreads {...props} into an element — is reported unchecked and FAILS: the probe stubs that path out, so it could not have delivered either payload, and a clean render there would mean nothing. The only way past is an explicit // UNCHECKED-EXEMPT: <reason> comment on the component; the three legitimate sites in the corpus are labelled in their documents, so a fourth arrival is a failing gate rather than a silent gap.

  • @since tags are now gated against the version that shipped the SYMBOL, at symbol level (scripts/check-since-tags.ts, composed into check:docs as check:since-tags; M95d, X50-2). For every @since X.Y.Z the gate resolves the declaration the tag sits on, fetches that file at that version from the jsr.io registry, and fails when the symbol is absent — a file-level check would pass any tag on a long-lived file, so the symbol is the unit. Scan roots derive from the root deno.json workspace list, so the three starters are covered where a packages/*/src glob would silently skip them. A version ahead of the registry is skipped, not failed, so a release branch cannot be blocked; a registry outage is reported on stderr and the run exits 0 with verified N printed — exit 77 would fail check:docs’ && chain.

    Reported in code review before merge: the gate originally SKIPPED any version absent from the registry, under a message asserting it was “ahead of the registry” — two different claims. A version that was never released and never will be (@since 0.6.1, on a line that went 0.6.0 → 0.7.0) is absent forever, so it was skipped forever; four such tags were live on main and the gate could not have reported one of them. It now skips only a version that is newer than everything published, or one whose release LINE did ship (@since 0.1.0, a line released only as 0.1.0-alpha.*), and reports the rest. The test covering that branch used @since 9.0.0, which is genuinely ahead and therefore passes under either rule — it could not tell them apart, which is why the hole shipped.

    Two more from automated review, both fail-open. Only 404 now means “absent”: the gate read ANY non-OK registry response as absence, so a 429 — realistic, since a run issues roughly 200 requests — raised a file-absent finding and exited 1, failing the whole check:docs chain on a transient fault, the opposite of the outage contract. And the behaviour gate matched its UNCHECKED-EXEMPT marker with a bare substring test, so the marker alone exempted a component, as did a comment merely mentioning it. The marker must now OPEN its comment line and carry a reason: requiring the documented MARKER: <reason> form was the first fix and was NOT enough, since // Never write UNCHECKED-EXEMPT: without review carries both the colon and a reason and is still a mention — a second review pass caught that on the fix itself. It matters because the exemption is the only way past an unchecked verdict.

Changed

  • deno.lock re-resolved to the current satisfying versions of every declared range. No manifest range changes, so this moves only what the lockfile pins: @hono/hono 4.13.0 → 4.13.8, the seven AWS SDK v3 clients 3.1103.0/3.1121.0 → 3.1134.0, zod 4.5.4 → 4.6.5, react-router 8.3.0 → 8.4.0, @bufbuild/protobuf 2.13.0 → 2.15.0, @connectrpc/connect 2.1.2 → 2.2.0, the three OpenTelemetry packages 2.9.0/2.10.0 → 2.11.0, @launchdarkly/node-server-sdk 9.13.0 → 9.13.6, @google-cloud/pubsub 6.0.0 → 6.1.0, @google-cloud/storage 7.21.0 → 7.22.0, @azure/identity 4.13.1 → 4.13.3, @azure/cosmos 4.10.0 → 4.10.1, and ws 8.21.2 → 8.21.3. Five stale entries naming ranges no manifest or source declares any more are dropped with them. The declared dependency-compatibility ranges are unaffected — zod stays inside >=4.4.0 <5, and the Drizzle (0.45.2) and Prisma v7 baselines do not move. deno task audit:ci reports the same 0 low, 4 moderate, 0 high, 0 critical baseline before and after, so no advisory drove this.

  • BREAKING — logger-plugin now redacts common secret-shaped fields by default. Set LoggerPlugin({ redact: [] }) to restore the prior no-default-redaction behaviour. The legacy redact list remains supported for both console and Pino transports and takes precedence over a policy where both name a path.

  • Multipart Content-Disposition parsing admits the unquoted parameter form and drops a part with no usable name (behaviour changes, both with migration notes). Two changes decided together (M95c §3.4) because either alone is a regression. (a) The parser now accepts the unquoted token form for name and filename — value to the next ; or end of header, then trimmed, the parameter NAME matched case-insensitively — where the previous quoted-only, case-sensitive regexes silently LOST the name of a part sent as name=x (the platform delivers it) and demoted an upload sent with an unquoted filename=a.txt to a plain text field, so getUploadedFile() found nothing. (b) A part whose Content-Disposition carries no name parameter at all — including one whose header cannot parse — is DROPPED, matching the platform, where the previous code promoted it to a real field literally named unknown that collided with a legitimate field of that name; the sentinel is removed outright, so a nameless part and a field named unknown are distinguishable again. An EMPTY value in either spelling — name="" or name= — is a defined name and is KEPT, as an empty-named field / empty filename: the web standard’s file-versus-text discriminator is the PRESENCE of filename, not its truthiness. Migration: an application reading a form field named unknown was reading parts no correct client sends (the platform discards them); read the part’s real name now, or accept that malformed parts are dropped. An unquoted NAME=x uppercase part is now delivered where Deno’s own Response.formData() drops it — the runtimes disagree on this row, and delivering is the side that loses no data; the full normative table is pinned by packages/common/test/unit/form/multipart-platform-parity.test.ts.

  • csrfTokenField(ctx) now renders the plugin’s configured csrf.fieldName by default, not always '_csrf'. The csrfFormMiddleware publishes its resolved configuration into ctx.state under the new exported CSRF_CONFIG_STATE_KEY — BEFORE its ignoreMethods short-circuit, since the GET that renders the form is itself an ignored method — and the helper reads it, so the README’s own recipe (render with the helper bare, post the form) no longer 403s on every post when fieldName is customized. An explicit csrfTokenField(ctx, { fieldName }) argument is an override; with no published config and no argument — a request the middleware never saw, such as a React Router action — the shared '_csrf' default is unchanged. What is published is a frozen, request-local PublishedCsrfConfig ({ fieldName }), never the middleware’s own ResolvedCsrfConfig: that object is resolved once at registration and shared by every request, so publishing it put the verifier’s configuration in reach of any handler holding the context — measured, one ctx.state.get(CSRF_CONFIG_STATE_KEY).ignoreMethods.add('POST') turned a 403 into a 200 for every LATER request in the process, each with a fresh state map. Migration: none needed; if you passed the configured name manually to work around the mismatch, the workaround is now optional and may be deleted.

  • BREAKING (for JavaScript callers and unknown-typed call sites) — inject() carries the body shapes a request actually has and refuses every other by name. InjectRequest.body widens from an anything-goes unknown (documented “will be stringified if not a string”, implemented as JSON.stringify of whatever arrived) to string | Uint8Array | ArrayBuffer | Blob | URLSearchParams | Record<string, unknown>: the byte-ish shapes pass through verbatim with NO content-type default (only the caller knows whether bytes are multipart, JSON, or an image), a URLSearchParams is serialised with its own toString() and defaults application/x-www-form-urlencoded, and a plain object (JSON) and a bare string keep the application/json default. A byte body is COPIED rather than aliased, so a handler that mutates what ctx.request.bytes() returned cannot corrupt the Uint8Array/ArrayBuffer the test passed in, and a fixture reused across two injected requests carries none of the first request’s mutation. Previously a Uint8Array arrived as {"0":97,…}, an ArrayBuffer/Blob/URLSearchParams each arrived as the two bytes {}, and the same release named inject() a producer of IRequest.formData?() — so an injected multipart upload parsed as an empty form. Migration: TypeScript callers are compile-checked. A JavaScript caller passing an array, Date, class instance or number must convert first: arrays and plain data to a plain object, Date to a string or number, binary data to Uint8Array. Per-shape bytes and content-type defaults are pinned by packages/kernel/test/unit/inject-body-shapes.test.ts.

  • BREAKING (for out-of-repo adapter implementors) — @setu-ts/common + @setu-ts/database-plugin: IDatabaseAdapter gains the optional isHealthy?(): Promise<boolean> liveness member, and the database indicator stops reporting up for a backend it cannot vouch for. X51-1 (High): the indicator read the lifecycle adapter.isReady(), so a stopped MongoDB reported up, /ready answered 200, and a rolling deploy rolled forward over a pod answering 500 to every request that touches data. The payload now carries reachable when the adapter probes: true/up, false/down, and a probe that exists but does not answer inside the 2-second bound reports 'unknown'/degraded — never up. An adapter with NO probe is the deliberate no-change row: reachable omitted, up, /ready 200 — mapping a missing probe to degraded would fail /ready for every healthy Cosmos/Bigtable/DynamoDB application on upgrade. The lifecycle read still gates the probe, so an adapter whose own isHealthy?() does not re-derive isReady() cannot report up for a connection it has already dropped; MemoryAdapter reports its own lifecycle rather than an unconditional true for the same reason. Shipped probes: MongoDB (db.command({ ping: 1 }) via the new optional IMongoDatabase.command? facade member), Prisma and Drizzle (SELECT 1; Drizzle omits it for instances without execute()), memory (true). DatabaseService exposes hasReachabilityProbe and a bounded reachability() (the IMessageBroker.reachability() twin); isHealthy() keeps its published lifecycle signature. Migration: none for callers; a hand-written adapter that can probe should declare isHealthy?(), one that cannot should omit it. Verified against a real MongoDB container through a docker stop/start outage gate.

  • @setu-ts/messaging-plugin: broker reachability reads the plane the application uses, and every arm’s probe is bounded. The Service Bus indicator said up while every publish threw: the management probe resolves unknown against an unreachable management plane in both states, and unknown maps to up. The broker now records the outcome of real publishes — the DATA plane — in an evidence window that reachability() consults first, narrowed twice (only transport.send rejections count, and only status-less ones: a deleted topic or quota error is an application fact, never an outage); ServiceBusOptions.dataPlaneEvidenceMs (default 5000) bounds the window, and is refused at construction if it is not a positive integer — NaN (what Number(env.X) yields for an unset variable) would otherwise freeze the window so recorded evidence never ages out, and 0 would disable it, reinstating the defect. The RabbitMQ probe is a real round trip (a throwaway channel open/close) replacing the fault-flag read a HUNG broker never trips, cached 5 seconds and bounded 2 seconds inside the broker so every caller shares one round trip per TTL — realtime-backplane-plugin’s 'messaging' transport calls broker.isHealthy() directly and keeps no cache of its own, so an uncached probe there meant one AMQP channel open per health poll and a paused broker left that indicator to hit HealthPlugin’s deadline and report down. RabbitMqBroker.reachability() now resolves boolean | undefined (the committed IMessageBroker tri-state) and its isHealthy() reports “not known down” for an unanswerable probe, matching ServiceBusBroker exactly. The indicator bounds ALL seven arms’ probes (5-second TTL, 2-second bound): a probe that cannot answer now settles reachable: 'unknown' instead of holding /health open forever. Residual posture, documented rather than implied: an idle Service Bus deployment with an unreachable management plane stays reachable: 'unknown' until its first publish. Verified against the real Service Bus emulator (docker stop/start 2×2 gate) and real RabbitMQ 4 (docker pause hung arm).

  • The Setu-TS website now identifies each page to social and search crawlers. The shared layout publishes the page’s canonical URL as og:url and SoftwareApplication JSON-LD describing the framework, its supported runtimes, and its free offering.

  • The root README no longer foregrounds the obsolete Hono Enterprise migration notice. The historical release record remains in this changelog for users maintaining legacy installations.

Fixed

  • The example-behaviour gate judges escaping on the value type production renders, and looks for a javascript: scheme in an ATTRIBUTE rather than anywhere in the output (#333). Two fail-safe false positives, both properties of how the probe delivered its payload rather than of any documented component.

    hono escapes an attribute value only when it is typeof === 'string', and the probe delivered a Proxy — so a SAFE component rendering a prop into title= came back unescaped and was reported as a defect, while a real string is escaped correctly. The probe now sweeps several delivery modes, each putting a real string where the component finally reads, and a mode that does not fit a component simply contributes nothing instead of failing it. What makes the sweep sound is that a non-leaf level stringifies to a SENTINEL, never to the payload: a mode that puts an OBJECT where the component reads therefore cannot look like an unescaped payload. Disabling that one line reports two of the corpus’s own safe components as UNESCAPED.

    Separately, the scheme check ran one regex over the whole render, so body text reading href=javascript:alert(1) matched although no attribute existed — a false positive on a document merely DESCRIBING the hazard, which docs/mvc.md does. It now walks tag regions quote-aware and inspects only href, src and action values, matching an attribute name by suffix so xlink:href is covered. The probe imports that scanner rather than carrying a second copy, so it and the gate’s own tests cannot disagree.

    The sweep would have opened a false PASS on its own — a component reading past every mode has each payload resolve to undefined, renders nothing, and scores as escaped — so a REACH probe decides whether the component touches its props at all, and the two disagreeing is reported as UNCHECKED. That probe is a SEPARATE object which deliberately does not carry the sentinel: every level stringifies to the payload, which is what lets it answer a read at any depth, and is only safe because its output is never judged for escaping. Sharing one object between the two roles made the guard inert for exactly the components it exists to catch, which a negative control found.

    Reach also improved: a nested props.note.link in an href is now caught, where before it was not delivered at all.

    One consequence of the sweep needed guarding, found in review: escaped is now !raw, so a component NO mode delivered to scores as escaped because nothing appeared in its output at all. A counter-example — a component its own comment labels UNSAFE, checked in the other direction — was therefore told it “ESCAPES its input” and invited to drop a warning that is still true, which is the worst advice this gate can give. That verdict now requires delivery; when nothing was delivered the UNCHECKED finding is the honest report, and it has already fired.

  • The weekly Dependency drift workflow reported a test failure that was never drift, and gated a graph it had not resolved. Three defects, one shape — the job’s report did not describe what the job had done. Found while acting on the drift issue it filed on 2026-09-14.

    It started none of the twelve backends ci.yml’s deno job starts, while running that job’s full suite. GitHub sets CI=true in every workflow, so test/apps-gate.test.ts’s REDIS_URL reachability assertion — the M53 pin that stops a dropped container becoming a silent skip — failed on every scheduled run. That single assertion was the ENTIRE test: failure reported on 2026-09-07 and again on 2026-09-14; nothing about the fresh dependency graph was wrong. The invisible half cost more: six guarded suites (70 steps) skipped there, and those are exactly the suites a drifted ioredis, amqplib, kafkajs, mongodb or AWS SDK would break, so the one job whose purpose is noticing that a new dependency broke something could not see it. The job now declares the same env, services and backend startup steps, derived from ci.yml by the parity test in test/unit/release-notes.test.ts so a backend added to the PR job is required here too. The assertion was right and the workflow was wrong: it is unchanged.

    Its source listing matched .ts only, while packages/ carries three .tsx files — one of which reaches @hono/hono/jsx through its manifest’s jsxImportSource. So the lock the check and test gates were handed was resolved from a smaller graph than those gates then walk. test/dependency-drift-gate.test.ts now derives the listing from the tracked tree rather than pinning it as a literal, because the literal was the defect.

    And resolving from sources alone produced a lockfile deno ci refuses to install — measured: it demanded @std/async, @std/data-structures and @std/fs, which no .ts file imports by name — so the “fresh resolution” every gate ran against was one that could never be committed. The same gap mis-attributed drift: @prisma/client is declared in packages/database-plugin/deno.json and imported by no source, so dropping it took drizzle-orm’s optional peer from 7.10.0 down to 7.8.0, reported for weeks as two rows of upstream drift that no upstream release had caused. The job now resolves the workspace manifests before the source graph, and reports a manifest failure separately — the per-file retry below it exists to absorb a transient batch failure, and an unresolvable manifest range would have sailed through it with every individual file caching fine.

  • Documentation — the last three sites asserting the false Deno compiler-option mechanism. docs/decorators.md, the PUBLIC_API.md decorator note, and a packages/cli test comment still stated that declaring any compilerOptions key in a deno.json replaces Deno’s default set. M90h corrected six sites and these three survived the sweep. The mechanism is false, re-measured here on Deno 2.9.6 rather than taken from the earlier record: a manifest declaring only experimentalDecorators still reports TS7006 for an implicit any, so strict remains in force, while the control declaring strict: false beside it type-checks cleanly — which is what proves the probe discriminates rather than always failing. All three now state what is true: the decorator surface needs no compiler option, and declaring one leaves Deno’s other defaults alone, so an existing compilerOptions block needs no edit. The stated reason matters on its own, because a reader who believed the old one would avoid declaring an option their project needs.

  • @setu-ts/decorator-plugin: the unresolvable-parameter warning no longer tells a developer to write source that does not compile. Since M76 retired parameter decorators, Ctx() returns a ParamSource and @Ctx() in method position fails with TS1241 Unable to resolve signature of method decorator; the valid form is @Params(Ctx()). The hint logged for a decorated parameter that no resolver can satisfy named the invalid spelling, and so did four JSDoc blocks across decorators/security.ts, resolvers/parameter-resolver.ts and three test-prose sites. All eight are corrected. The two not.toContain('@Ctx() ctx') assertions in the CLI schematic tests are deliberately left as they are: that string is the legacy parameter form they exist to guard against reappearing.

  • @setu-ts/static-plugin: the cacheControl callback’s documented input matches what it receives, and the published option says so. The callback has always been handed the FULL leading-slash request path INCLUDING urlPrefix (/assets/app-A9acsx54.js), deliberately — a cache policy is about the URL the client caches under. Three doc sites described it as the “root-relative path” and named the parameter relativePath, and the README’s own worked example branched on path === '/', which a directory request can never deliver because the index is resolved first. The behaviour is unchanged (stripping the prefix would silently change what every existing callback matches, AI_GUIDELINES §9.4); the parameter is renamed requestPath everywhere it is declared — including the barrel-exported StaticPluginOptions, which is the spelling a consumer’s editor shows — the three doc sites are corrected, and the dead example branch is replaced. The exact string delivered for each mount shape is pinned by packages/static-plugin/test/unit/cache-control-path.test.ts, including the one configuration that does deliver a bare '/' (a root pointing at a file rather than a directory, outside what root documents), so the guarantee reads as scoped rather than absolute.

  • The Mongo injection seam admits the real mongodb driver. X47-1: PUBLIC_API.md presented IMongoClient as the seam a real MongoClient is supplied through and claimed the driver implements its structural shapes — false, so the documented arm needed the cast this repository forbids internally. The facade’s connect() widens to Promise<unknown> (the driver returns Promise<this>; the adapter binds nothing), and the compile-time fixture the claim now cites — packages/database-plugin/test/types/mongo-seam.assert.ts, a static import of the real driver assigned to IMongoClient with NO cast — caught two more divergences the single named fix missed: ClientSession.startTransaction(options?): void (the session member is (options?: unknown) => unknown) and the Sort union on find/findOne (sort?: unknown). Source-compatible for existing structural implementations; the cast at mongo-client-seam.test.ts that laundered the drift is gone with it.

  • ConsoleLogger no longer writes '[Redacted]' into the application’s own objects. A nested redact path corrupted the caller’s data: #redactFields shallow-clones the metadata record, so logger.info('login', { auth: user }) under redact: ['auth.token'] walked into user — still the application’s own object — and assigned the placeholder to its token. The damage outlived the log call and was silent: a later unredacted log of the same object emitted '[Redacted]', and anything persisting it afterwards stored that literal string. Only paths of two or more segments were affected; a top-level path was already safe, because the shallow clone stood between the walk and the caller. The path is now walked READ-ONLY and nothing is copied until the leaf is known to exist; the traversed objects are then replaced top-down by copies the logger owns, so the caller’s metadata is untouched at any depth. Deferring the copy is a correctness requirement rather than an optimization — copying on the way down spreads every traversed value into a plain record, so a Date beneath a path that matches nothing emits as {} and a class instance loses the toJSON that produced its output, changing a value the configuration never named. An entry whose configured paths match nothing is therefore byte-identical to the same entry logged with no redaction configured, asserted against that control. The one behaviour that does change is a path which ACTUALLY redacts through a non-plain object: the emitted copy is a plain object, so a class instance’s own enumerable properties are emitted rather than its toJSON output. A copy is the only way to write the leaf without corrupting the caller, and the previous behaviour there was to assign onto the caller’s own instance. The existing nested-path tests could not have caught this — each built its metadata inline, so nothing held a reference to read back; the regression cases keep the object a caller would have kept, and four of the five fail without the fix. A path through an ARRAY still redacts nothing and that is unchanged and now documented rather than silent: pino resolves an array element with bracket notation ('users[*].token') and would equally not match the dotted form, so descending would make one option behave differently per transport. Unifying the two syntaxes belongs to the redaction seam milestone, not to this fix.

  • ConsoleLogger no longer throws out of a log call. LogMetadata is Readonly<Record<string, unknown>> and carries no JSON constraint, so every value JSON.stringify refuses is legal metadata by the contract — common’s JsonValue documents that limit for SSE precisely because it chose a narrow type, and the logger did not. Measured, four inputs took the caller down on both the JSON and the pretty path: a circular structure, a bigint, a throwing toJSON, and a throwing getter. Pino survives all four, so the default logger was also the only one that could not. That is worst where logging is most needed — a catch block — because the TypeError replaces the error being reported, and the handler that would have recovered never runs. An internal safeStringify now neutralizes cycles ('[Circular]', the token pino emits, so the two transports describe one shape identically) and renders a bigint as its decimal string; metadata that throws while being read cannot be handled per value, since the throw happens inside JSON.stringify’s own property read, so the entry is emitted without its metadata and carrying '[unserializable metadata]' rather than being lost — the level, time and message are usually the half an operator wants and are always serializable. A log call therefore emits exactly one line and never throws. Cycle detection tracks the ancestor chain, not every object already seen, and that is the correctness of the fix rather than a refinement: a WeakSet of seen objects reports the second appearance of a legitimately shared object as circular, so { x: user, y: user } would serialize as {"x":{…},"y":"[Circular]"} and silently drop a field the caller supplied — pino renders both, and swapping the algorithm fails exactly those guards. The bigint rendering is quoted where pino emits unquoted digits: pino’s form is exact in the emitted bytes but loses precision the moment a consumer calls JSON.parse, and this is reachable in ordinary use, since drivers return BigInt for int8 columns. No public API change — the helper is internal, matching normalizeMetadata beside it, and src/index.ts is untouched.

  • A scaffolded workspace’s generated deployment now starts under its own security posture. A setu new --workspace microservice, built with its own generated docker/Dockerfile and deployed under its own generated Kubernetes manifest (readOnlyRootFilesystem: true), crash-looped before serving anything: the first time MessagingPlugin registered, Deno tried to add its driver edges (npm:amqplib, npm:ioredis) to deno.lock and died on the read-only root — error: Failed writing lockfile … Read-only file system (os error 30). The generated Deno CMD now runs with --no-lock: every package the graph reaches is already in the build-time module cache (the Dockerfile’s deno cache main.ts step), so the lockfile has no job left inside an image and the start resolves offline. No warm list is emitted — measured on a fresh scaffold, the build-time cache already holds every package the runtime reaches, so a derived specifier list would name packages that are already present. An existing workspace picks the fix up on its next setu generate app (the file is regenerated, no migration step). docs/deployment.md documents the finding, the deno install --frozen diagnostic the runtime error should have given you, and the rule that the image’s module cache is the member’s only dependency source at runtime; and check:deploy gains a --generated mode that scaffolds a workspace, builds its generated Dockerfile — previously built by no gate — and passes only when the member serves /health under --read-only --network none. Measured to discriminate: without the flag the new mode reproduces the crash; with it the member serves.

  • MVC form guidance now matches IRequest.formData?(). The guide no longer says forms lack an accessor: all built-in request producers provide its memoized parser for URL-encoded and multipart bodies. Because custom IRequest implementations may omit the optional member, the copyable readForm helper now falls back to the same parseFormBody(await request.bytes(), request.headers.get('content-type')) path rather than silently returning an empty form. The guide gate executes both fallback encodings and the accessor-present path.

  • @setu-ts/view-plugin, @setu-ts/decorator-plugin — the documented class-based view example did not escape. packages/view-plugin/README.md (the page jsr.io renders) and docs/migration-nestjs.md both wrote the component as a PLAIN template literal — (props) => `<ul>${props.users.map((u) => `<li>${u}</li>`).join('')}</ul>` — which is a string, so the engine returns it unchanged and nothing escapes it. Measured against the published 0.6.0 artifact with a hostile name: JSX and hono’s html tag both emit &lt;script&gt;, while the plain literal emits <script>alert(1)</script> verbatim. The view-plugin README contradicted itself 58 lines apart, telling the reader to “write views with JSX or the html tag” beneath an example that did neither, and the two remaining sites (docs/mvc.md, packages/decorator-plugin/README.md) used html — so no two of the four agreed. No package source changes; escaping always belonged to the rendering runtime and always worked.

  • All four view examples are now JSX, which is what ViewPlugin() selects with no options: 'hono-jsx' is the default arm and ViewEngine does not branch on the arm at all — one class serves both authoring modes — so every example was showing the NON-default mode and its extra html import for no reason. The 'hono-html' arm keeps a worked example as the documented alternative for a project that would rather not add a JSX toolchain.

  • The fence gate could not have caught it, and now can. Both fence compilers wrote every fence to .ts, and Deno decides whether to PARSE JSX from the extension — so a real JSX fence dies with SyntaxError: Expected ',', got '.' before type-checking starts. It held only because every tsx fence in the corpus contained no JSX (an html tagged template parses as ordinary TypeScript). One shared fenceExtension(lang) now decides, so the guide and package-README compilers cannot disagree. Verified to discriminate: breaking the props bag inside a JSX example fails the gate with TS1241, which is @Render rejecting a handler whose return does not match its component.

  • messaging-plugin — the v0.6.0 release notes claimed a safety net that does not exist; both copies of the claim are corrected in place. The 0.6.0 section above (and the probe’s own JSDoc) said a namespace that is genuinely gone “still reports down regardless: the data client stops being ready, and the indicator checks isReady() before it consults the probe.” Measured against the emulator docs/messaging-emulators.md documents: isReady() is LIFECYCLE state — set when connect() succeeds, cleared only by disconnect() — with no liveness input, so a broker dead for minutes answers isReady() === true in 0 ms; nothing gated the probe, and a stopped namespace reported up with /ready answering 200 while every publish threw. This milestone ships what actually catches a dead data plane — the broker records recent data-plane outcomes and reachability() consults that evidence before the management probe (M95b) — and the false sentence is struck from the published section and the source.

  • Twelve @since tags named versions that do not ship what they claim; all corrected against the registry (M95d, X50-2 plus the gate’s first full run). Six in packages/common/src/form/ named 0.5.0, a release whose tarball does not contain the module — the module is new in 0.6.0, and the miss is PR #286’s named cause repeated: the sweep’s branch was cut before the module landed. The gate’s first full run then surfaced six more, each verified present at its corrected version before the edit: UnsupportedFormEncodingError (common, 0.5.0 → 0.6.0), ProbeTiming and resolveProbeTiming (common, 0.4.0 → 0.5.0), UnsupportedMigrationError (database-plugin, 0.4.0 → 0.5.0), csrfTokenField (session-plugin, 0.5.0 → 0.6.0), and contentEncodingFor (static-plugin, 0.4.0 → 0.5.0). The corrected run verifies 736 tags.

    Seven more were found by running the finished gate against merged main — nineteen in all — and are corrected here: three @since 0.6.0 tags on config-plugin’s typed-config-section module, a file 0.6.0 does not contain, and four naming 0.6.1 across kernel and session-plugin — a version that was never released. All move to 0.7.0. That is PR #286’s named systematic cause repeating, which is the argument the gate was built on.

  • The documentation now states what the code does at the three places a reader following it ended up wrong (M95d, X46-1 / X49-1 / X49-2). docs/mvc.md §Escaping states the boundary — escaping protects HTML structure, not URL schemes, a difference BETWEEN rendering runtimes (React neutralises the payload, hono’s runtime passes it through) — with the application-side remedy (validate the scheme before it reaches href/src/action) and a cross-reference from the inline-<script> section noting that 'unsafe-inline' is exactly the CSP that does not block a javascript: URL; and a new §Error pages carries the one-sentence rule (respond covers what errorHandler catches; every responder terminal is outside it) beside the list of what that leaves serving raw Problem Details into a browser. packages/exceptions/README.md gains the worked respond example whose ctx.response.status(error.statusCode) line is the whole fix — renderView takes no status, so the natural spelling served 200 for every branded error — packages/view-plugin/README.md states the URL-scheme boundary where a reader of the package lands, and PUBLIC_API.md’s respond contract note carries the one-line status fix.

0.6.0 — 2026-09-13

Added

  • @setu-ts/common — one form-body abstraction. IRequest.formData?() resolves a FormBody for both application/x-www-form-urlencoded and multipart/form-data requests — one read-only get/getAll/entries view with the web FormData’s SEMANTICS (ordered getAll for repeated names, entries() in wire order, file-versus-text discriminated on filename !== undefined, filename="" still a file) over a framework-owned value whose bytes stay synchronous: the web File’s async, copying access would add a second full copy per file part on the upload path this exists to make cheaper, so it is deliberately not adopted (and the shape is named FormBody, never FormData, so it cannot shadow the global). The parse is parseFormBody(body, contentType) — the ONE parse all three producers share, the X37-1 precedent — fed by formEncodingOf, the ONE case-folding content-type classifier that deletes the two private includes() checks the upload middleware and the CSRF verifier had let drift. A non-form content-type throws the new UnsupportedFormEncodingError, self-branded 415 Unsupported Media Type in the MalformedRequestBodyError pattern; an unparseable multipart body yields an EMPTY form — the promoted parser’s released behaviour, now stated as the accessor’s documented limit. The member is OPTIONAL (signal?/fs?/raw? precedent) and provided by all three in-repo producers (runtime’s FrameworkRequest, the kernel’s inject(), @setu-ts/testing’s MockRequest), each memoizing the in-flight promise exactly as json() does; a request without the accessor falls back to the same parseFormBody, which is the fallback, not a second implementation. The storage-plugin’s private multipart parser moves byte-identically into common (parseMultipart/ParsedPart stay internal — parseFormBody is the only public entry), and both first-party consumers now read the shared accessor: createUploadMiddleware keeps every bound and refusal where it was, and extractToken (see the session-plugin entry).

  • @setu-ts/common — three multipart/form-parsing defects fixed while promoting the parser. All three were pre-existing in the @setu-ts/storage-plugin parser this release moves into common, and all three failed silently; promoting it is what made them reachable from the CSRF verifier and from every IRequest.formData() caller, so they are fixed where the code now lives. (1) Boundary detection now requires the full delimiter context — a line break, --<boundary>, then a line break or the closing --, per RFC 2046 §5.1.1. Matching the raw byte sequence anywhere truncated any value containing it: the field value prefix--AaB03xsuffix came back as pref, silently corrupting a CSRF token or uploaded file rather than refusing it. (2) Part header field names are matched case-insensitively, as RFC 7578 defines them. The parser compared the exact strings Content-Disposition and Content-Type, so a client sending content-disposition — legal, and what several HTTP libraries emit — lost its field name, filename discriminator and MIME type: the part arrived under the name unknown, so a CSRF token was never found and an upload was never delivered. (3) formEncodingOf matches the media-type token exactly and reads boundary as a real parameter, through a new shared parseContentType that parseMultipart reads too — so the classifier can no longer promise a parse the parser refuses. Substring matching admitted three non-forms into form parsing: application/x-www-form-urlencoded-v2 (a suffixed type), text/plain; note="application/x-www-form-urlencoded" (the type inside an unrelated quoted parameter), and multipart/form-data; xboundary=q (boundary= matching a different parameter name). A quoted boundary containing ; now parses correctly, ONLY a double quote delimits a quoted value (an apostrophe is an ordinary token character, so boundary='abc' delimits with --'abc'), and the CLOSING delimiter is validated to its end of line, so file data containing --<boundary>-- no longer truncates the part. Each behaviour was checked against the platform’s own Response.formData().

  • @setu-ts/runtime — IRequest.formData() reads the request’s PUBLIC headers. It read the native request’s headers, so a middleware rewriting content-type was ignored on the served path while the kernel’s inject() and @setu-ts/testing’s MockRequest — which read their public headers — honoured it. The three producers of one shared parse now agree. Framing headers (content-length, transfer-encoding) are still read natively, which is correct: framing is a property of the wire, not something a middleware restates.

  • @setu-ts/session-plugin — CSRF tokens can arrive in a multipart/form-data FIELD. The verifier previously read only the configured header for multipart posts, because parsing the body would have meant importing the storage plugin’s parser. With the parser promoted into common, extractToken reads BOTH form encodings through the request’s shared formData accessor (or the same parseFormBody fallback) after the configured header, so a browser form posting a file alongside its token verifies where it previously could only 403. The header still wins and a client that sends it triggers no body parse; a token submitted as a multipart FILE part is REFUSED as absent (a client chooses freely whether a part carries a filename, so a non-string value must never reach the timing-safe comparison); a non-form body still reports the ordinary mismatch rather than the accessor’s 415; and the urlencoded path is byte-identical to before.

  • @setu-ts/session-plugin — csrfTokenField(ctx, options?) renders the session’s existing synchronizer token as a complete hidden form input, so server-rendered forms no longer hand-write its markup. It mints through getCsrfToken, escapes a configured field name, and keeps the existing default '_csrf'; in an escaping Hono template, application code wraps its trusted generated markup with raw(). The helper does not change CSRF policy: SessionPlugin({ csrf: {} }) still globally verifies unsafe methods. (That entry originally added that a multipart form still had to carry its token in the configured header “until the separate M94b form-body work ships”; M94b ships in this same release, so a multipart FIELD now verifies — see its session-plugin entry above.)

  • @setu-ts/messaging-plugin — versioned integration-event contracts over the existing messaging capability. defineIntegrationEvent<T>({ type, version, topic, parse }) declares a contract and refuses a topic that does not end with the exact .v${version} suffix, making the versioned-topic rollout policy mechanical (the raw broker.publish/subscribe surface is unchanged and remains the route for a pre-existing unversioned topic). publishIntegrationEvent wraps the caller’s payload in a portable envelope — id, type, version, ISO-8601 occurredAt, data, optional correlationId/causationId/aggregateId/aggregateVersion — as payload data, never transport headers, so every broker arm carries it with no adapter change; it never runs parse on publish, so a producer CAN publish a payload its own consumers reject. onIntegrationEvent produces the existing SubscriptionDefinition: it validates the envelope structurally (ignoring unknown extra fields), checks type/version exactly, runs parse, rebuilds the envelope with the parsed value so envelope.data === payload, and only then calls the application handler; a refusal throws IntegrationEventRejectedError discriminated by reason (malformed/type-mismatch/version-mismatch/parse, the parser’s own error carried as cause). causedBy(envelope) extracts the chain-root rule for correlation propagation. A payloadless event publishes null, never undefined, which JSON drops (refused at the producer, naming the remedy); a non-finite aggregateVersion is refused for the same reason; and the consumer type-checks every optional causal field and requires occurredAt to be a real ISO-8601 instant, so the envelope’s declared types hold for application code and for causedBy. Separately, NatsBroker now reports a rejected handler before nak() — it redelivers, so that branch was the one retry loop in the plugin with no diagnostic at all. No IMessageBroker method changes, no new capability token, and no dependency on @setu-ts/events-plugin; ingress behaviours observe the raw envelope, before the wrapper, by design.

  • @setu-ts/exceptions — application-owned caught-error responses. errorHandler({ respond(error, ctx) { ... } }) can now return an application response — including an HTML view through ctx.response — after the framework has normalized, disclosure-masked, and status-sanitized the caught error. The hook may be asynchronous; returning or resolving to undefined preserves the existing formatter-led JSON or Problem Details output exactly. Logging retains the unmasked diagnostic and records the selected response’s status. The hook deliberately does not cover M70f responder terminals such as unmatched-path 404, malformed-request 400, or drain 503.

  • @setu-ts/events-plugin — aggregate-local createDomainEvents() and IDomainEvents. A framework-independent recorder for facts raised by an aggregate during an operation. It preserves insertion order, returns isolated snapshots, and never publishes; application code owns persistence, dispatch, and acknowledgement policy.

  • @setu-ts/view-plugin (new package) — server-rendered HTML as a capability. Registers an IViewEngine under the new CAPABILITIES.VIEW token; the view is named BY REFERENCE (@Render(UserList), never 'users/index'), so there is no view resolver and no filesystem lookup, making the capability Workers-portable by construction. Two zero-new-dependency arms ('hono-jsx' default, 'hono-html' tagged template) plus a 'custom' arm for any engine that adapts its templates to Component<P> functions. Two entry points, one implementation: @Render(Component) (and its exported RenderDecorator type) in @setu-ts/decorator-plugin (type-checks the handler’s return against the component’s props; CAPABILITIES.VIEW joins its optionalDependencies; a rendered route with no provider fails at register() naming both remedies) and the free renderView(ctx, component, props). Rendered output is a buffered primitive string; a tree holding a pending <Suspense> boundary is refused with UnresolvedSuspenseError rather than served as its fallback forever (streaming resolution deferred to a follow-up milestone). Escaping stays in the rendering runtime; raw() is re-exported as the documented opt-out. A @Render handler may return a HandlerResult from ctx.response instead of the props bag — a redirect, most usefully — so POST-redirect-GET is expressible on a rendered route; HandlerResult is branded, so a props bag of the wrong shape is still a compile error. A top-level return follows the rendering runtime’s own rules — null, false, true and '' render as the empty string so (p) => p.show && <Banner /> behaves as it does nested, while undefined is refused as a probable missing return. Adds CAPABILITIES.VIEW, IViewEngine and Component<P> to @setu-ts/common, a docs/mvc.md guide, a Views section to docs/migration-nestjs.md, and the 48th member to the release list (first publish needs release:create-packages + release:link-repos; see docs/releasing.md).

  • @setu-ts/testing — createTestApp gains a composition-root arm: pass app (an already-constructed, not-yet-started application — a scaffolded project’s createApp() from setu.config.ts, or a starter factory’s return value) instead of plugins, with optional without and overrides, and the test starts from the application the project actually ships. Everything that root registered is present — its middleware, error handling, health indicators and route ordering, except what without explicitly removes — so a test observes production’s composition rather than a second one assembled by hand. TestAppOptions is now a union of the two exported arms, TestAppFromPlugins and TestAppFromApp, so supplying both plugins and app is a compile error; the plugins arm is otherwise unchanged. Closes the @setu-ts/testing watch-item X11 left open (M91).

  • @setu-ts/testing — overrideCapability(token, service) builds the replacement plugin AI_GUIDELINES §3.4 describes, applying the three constraints that make one work: it declares no provides (a second declaration of a live token is refused before any plugin runs), registers with { override: true }, and orders itself through an optionalDependencies edge on the token plus PLUGIN_PRIORITY.HIGHEST — after the provider, whatever its band, and before an ordinary consumer. It throws during start() when nothing provides the token — without that, a mistyped token registers the double under a nonsense name, leaves the real service serving, and the test passes against the real dependency. It likewise refuses a multi-provider capability, where getAll returns the single and multi registrations concatenated so an override would ADD a provider while every real one kept running — detected generically by a provider count rather than a list of known tokens, so a capability an application registers itself with { multi: true } is refused too. The override is ordered AFTER the provider and BEFORE ordinary consumers — an optionalDependencies edge on the token plus an early priority — so it reaches a consumer that resolves the capability during its own register() (NotificationPlugin does) as well as one that resolves it per request, and it replaces a provider in any priority band. It does not undo the provider’s eager side effects, and it requires the provider to declare the token in provides; both bounds and the without-plus-createMockPlugin alternative are stated in the README and PUBLIC_API.md. createMockPlugin is unchanged and still the right tool for providing a capability an application lacks; it cannot replace one, because its provides declaration collides with the real plugin’s (M91).

  • common — createCachedProbe is generic over its outcome type, with a fallback recorded on timeout or rejection (default false, so every existing caller is unchanged). A probe that reads a proxy for the thing it reports on — Service Bus’s administration endpoint standing in for its data plane — can now say “could not determine” instead of “down”. fallback stays optional for a boolean probe and is required once the outcome type is wider, since false is the only value the helper can name on its own: omitting it for, say, a 'up' | 'down' probe would have resolved false under a signature promising it could not.

  • common — causeMessage(value) is published: an Error’s own message, or String(value) otherwise. It was a private helper with a byte-identical copy in @setu-ts/scheduler-plugin, because the two arms of one jobs option have to produce the same sentence and §2.2 forbids the import that would let one read the other’s. One implementation now, reachable by both.

  • common — IMailer.isHealthy?() reports the mail transport’s reachability: true, false, or undefined when the question cannot be asked. OPTIONAL, so no implementor breaks. MailService implements it by delegating to the configured provider’s own probe. It exists on the capability because a HOLDER of an IMailer could not previously ask at all — the probe lived on mail-plugin’s internal provider port, and AI_GUIDELINES §2.2 forbids another plugin importing that package to reach it. The probe is cached and bounded at that capability boundary rather than in each caller: one aggregate health check asks the question once from the mail indicator and once per configured notification-plugin email alias, so a cache per caller would still be one transport call per caller. MailServiceOptions.probeTiming carries the runtime’s clock and timers; without it the delegation is bare, since there is no clock this package may lawfully read.

  • audit-plugin, notification-plugin — optional isHealthy?() on the IAuditStorage and NotificationChannel ports, so a third-party backend or channel can report reachability.

  • common — optional IGrpcService.refuses?(request), answered from the request HEADERS and consulted by the kernel BEFORE it reads the body. Optional, so an implementor that omits it keeps the previous behaviour exactly.

Changed

  • @setu-ts/exceptions: a 415 is served with its canonical title. STATUS_TITLES gained a 415: 'Unsupported Media Type' row. It had none, because 415 is the first status the framework brands with withHttpStatusHint and never produces through a factory (M94b’s form accessor), and the Problem Details formatters derive title from statusTitle(status) — so a client reading a 415 under errorHandler({ format: 'rfc9457' }) got "title": "Error" while the SAME error under 'default' read "message": "Unsupported Media Type": the two configured formats disagreeing about one error. Every test asserted the title on the brand (the error object) and none read the served body, which is why nothing saw it; status-title-served.test.ts now drives each branded status through errorHandler in both formats, and the root status-title-coverage.test.ts reads the branded statuses out of first-party source so a package branding a NEW status fails the gate instead of serving a meaningless title. An application that produced its own 415 — by new HttpError(415, …) or its own hint — also stops reading 'Error' under the Problem Details formats.
  • BREAKING — @setu-ts/storage-plugin: a multipart part with no filename under the upload field name is no longer an upload. It used to be reported as an UploadedFile whose filename fell back to the field name; since the upload middleware reads forms through the new shared accessor (M94b), a no-filename part is a plain form value — the web standard’s answer — and only parts that declared a filename are delivered, filename="" (the empty file input) included. Migration: a browser file input always sends a filename, even an empty one, so ordinary uploads are unaffected; a non-browser client posting a plain value under the file field should send that value under a non-upload field name (read it from formData()), or add a filename to the part if it genuinely is a file. Relatedly, a multipart/form-data content-type carrying no boundary= now passes through the middleware unparsed instead of being answered 400 as a malformed body: the shared classifier classifies it as not-a-form (a body that could never be parsed), and a caller that wants the fields gets the accessor’s 415. Everything else is unchanged: the Content-Length check, the bytes() read, the maxBodyBytes cap, maxFiles, maxSize and allowedMimeTypes refusals and their statuses all stay where they were. The promoted parser (internal — parseMultipart stays unexported) now also matches the boundary PARAMETER NAME case-insensitively, as RFC 9110 parameter names are: a Multipart/Form-Data; Boundary=… content-type parses where it previously bypassed the middleware or answered 400, disclosed here because the move that promoted the parser otherwise froze its behavior.
  • BREAKING — @setu-ts/kernel: IKernelApplication gains a required unregister(name: string): boolean. It removes a pending plugin before start(), returning true when at least one carried that name and throwing once the application has started. It removes every pending plugin with the name, not the first: two may share one, since the kernel refuses duplicates at start() rather than at register(), so dropping only the first would leave the other running while the caller was told the name was gone — and would turn that loud startup failure into a silently-running plugin. Plugins do not run until start(), so removing one means its register() — and any eager side effect inside it, such as DatabasePlugin’s adapter.connect() — never happens at all. That is the one thing overriding a capability cannot do: an override replaces what consumers resolve, after the real plugin has already run. Migration: callers are unaffected, and createApplication already returns an implementation. Only a hand-written stand-in for IKernelApplication breaks; give it unregister(name) { return false; } if it holds no plugins of its own, or splice its own pending list. Removing a plugin another declares in dependencies is not refused by unregister — IKernelApplication also gains a required hasPlugin(name): boolean — a pure read used to validate a whole exclusion set before removing any of it, so a misspelled entry leaves the application untouched rather than partially excluded. start() reports it, naming the dependent plugin and the unsatisfied capability — not the removed plugin’s own name when the two differ (M91).

Fixed

  • messaging-plugin — the Service Bus health probe reported a live broker as down. It reads the namespace’s administration endpoint to report on its data plane, and every failure — including its own two-second bound — resolved false, so /health said down and /ready answered 503 for a broker that was publishing successfully. Measured against the emulator docs/messaging-emulators.md documents, whose administration endpoint has no TLS listener. Reachability is now tri-state, and the question each outcome answers is does this response establish a fact about the data plane. A 404/410 does — the namespace is not there — and still reports down. A 401/403 proves the namespace answered, so it still counts as reachable. Everything else reports on the management request rather than the namespace and now leaves the replica in rotation with reachable: 'unknown': a network-layer failure with no status at all, and a management-plane status that establishes nothing — a 429, which Azure documents as temporary throttling or a conflicting management operation, or a 5xx. Those last two previously answered down, which is the same defect as the network-failure case reached through a status code instead of a socket error. (This entry originally continued: “A namespace that is genuinely gone still reports down regardless: the data client stops being ready, and the indicator checks isReady() before it consults the probe.” That sentence was measurably false and is corrected in place: isReady() is LIFECYCLE state — set when connect() succeeds, cleared only by disconnect() — with no liveness input, so it answers true in 0 ms for a broker dead for minutes. A genuinely-gone namespace is caught by the data-plane evidence window (M95b), not by isReady().) IServiceBusTransport.isHealthy? widens to Promise<boolean | undefined>; an implementation resolving a plain boolean satisfies it unchanged.

  • audit-plugin — the audit health indicator reported storage.isReady() alone, which is a hardcoded true on three of the four shipped backends, so the indicator was in practice a literal up: a database whose connection had gone and a file path whose volume had been unmounted both read healthy while every audited record was being lost (H-70c-1). It now reports reachability too — the database backend by selecting a sentinel key that matches no row (never an insert, which would write a fabricated record into the trail), the file backend by the last append’s outcome and a stat of the target directory — cached 5 s and bounded 2 s. This is a behaviour change: an application whose audit sink is unreachable now fails /ready where it previously passed, which is the point. The payload gains { storage, reachable }, where reachable is true, false, or 'unknown'.

  • audit-plugin — FileAuditStorage’s reachability probe reported a sink it cannot write to as healthy. It treated any successful stat() of the configured file’s parent as the answer, so a regular file at that path — /var/audit when the sink is /var/audit/trail.log — passed while ensureDir() and every append under it failed. It now reads StatResult.isDirectory.

  • notification-plugin — the notification health indicator hardcoded status: 'up' beside a live channel list, so a configured channel whose transport was down was invisible to /ready (H-70c-4). The payload now carries per-channel reachable, and one channel that was contacted and did not answer takes the indicator down. The email channel delegates to IMailer.isHealthy; the send-only transports report 'unknown', because a probe may not deliver a notification to a real person and none of them offers a side-effect-free alternative this plugin can reach — 'unknown' never reads as healthy, where the hardcoded up did. Channel probes run concurrently: each may consume its full bound, so awaiting them one after another let a handful of stalled channels exceed the health service’s own per-indicator deadline and replace every channel’s outcome with a generic timeout. This is a behaviour change for an application whose mail transport is unreachable.

  • worker-pool-plugin — the worker-pool health indicator hardcoded status: 'up' beside a live available field that could read false, so a pool that could not execute a single task — every run() rejecting with WorkerPoolUnavailableError — reported healthy (H-70c-5). The status now derives from available, reporting degraded with a reason when no worker host is present. degraded rather than down because M45 registers this plugin on Cloudflare Workers deliberately; degraded keeps /ready at 200 there while still surfacing in the payload.

  • mail-plugin — the mail health indicator read the provider’s probe directly, past the service. It now reads it through MailService.isHealthy(), so the capability and the indicator answer through one implementation and cannot drift. No payload change.

  • database-plugin — a { operator: 'eq', value: null } filter matched nothing on the Drizzle adapter. It rendered col = ? bound to NULL, and SQL evaluates NULL = NULL to UNKNOWN, so findAll, findOne, count and findPage silently returned no rows where the memory adapter, Prisma, MongoDB and D1 all return the rows whose column is NULL. The plain-column arm now emits IS NULL, which the adapter’s own in-with-null arm and its JSON-path arm were already doing. No API change; a query that returned nothing now returns the NULL rows, which is what every other adapter already returned.

  • graphql-plugin, storage-plugin — a REFUSED request body was re-reported with a different, misleading status. Since M90a a body read can reject rather than return — RuntimePlugin({ maxBodyBytes }) bounds it and rejects with a 413-hinted RequestBodyTooLargeError — and three sites wrapped that read in a try whose catch answered something else: the GraphQL HTTP and SSE transports answered 400 INVALID_JSON, and the upload middleware answered 400 "Failed to parse multipart body" (V5-1). Each named the wrong cause — the body was never parsed, so it was never invalid — and the wrong remedy, since a client told its payload is malformed re-sends the same oversized one. All three now read the thrower’s own status hint: the GraphQL transports answer 413 with extensions.code REQUEST_BODY_TOO_LARGE, and the upload middleware serves the hint through respondWithError, so the refusal comes out in the application’s configured error format. A genuinely malformed body still answers 400 with its existing code — including the shared MalformedRequestBodyError, which M90f made status-hinted at 400, so honouring every hint blindly would have replaced the published INVALID_JSON code on the commonest failure there is.

  • cli — a generated CQRS or event handler could fail the project’s own deno fmt --check, and whether it did depended on how long the artifact’s name happened to be. The three schematics hand-wrapped their implements clause UNCONDITIONALLY, so setu generate query-handler find-order emitted a wrap the formatter immediately rejoined (92 characters, under the emitted lineWidth: 100) while place-order at 103 was correct (V5-3) — M63’s D6 and M70h’s X2-4 in a third place. Every such line is now rendered from its own measured length by one renderDeclarationHeader/renderMethodSignature pair, so the output no longer depends on the name. Driving a REAL deno fmt --check over the emitted files across five name lengths found three more overflows of the same class that the row did not name — the extends CqrsQuery<…> interface header, the handle(…) signature, and a barrel registration entry — all fixed here, because a scaffold that fails its own format check for a long name is the same defect whichever line does it. Sweeping the name one character at a time against a real formatter then found two more class-header forms and a fourth line kind: the formatter has FIVE header forms, and the fourth returns the implements keyword to the declaration line while the type ARGUMENTS split — the opposite of what the first three suggest, and picked on whether the declaration, keyword and type head still fit rather than on the declaration’s own length. The export const a handler names itself after wraps too. Generated handlers are proven clean by that sweep for every artifact name up to 67 characters, which is a measured ceiling rather than a round number: at 68 an interface’s own readonly type: typeof …; property is the next line the formatter rewrites, and the test names where the bound is so raising it is a deliberate act.

  • kernel, grpc-plugin — a native gRPC client hung instead of reaching the refusal. The kernel buffered the whole request body before dispatching, and a client-streaming or bidirectional call holds its request stream OPEN, so that read never resolved and the caller received no frames at all. grpcurl opens a bidirectional reflection stream before anything else, so it hung on every request — which is why M70i’s Trailers-Only UNIMPLEMENTED refusal and its root base path were both correct and neither helped (V5-5). A native application/grpc request is refused whatever its body contains, so the refusal is now decided from the headers and answered before the read. Probed at the HTTP/2 frame level: a dispatch-map path with the stream open produced no frames, the same path with END_STREAM produced the correct refusal, and a non-map path answered immediately — the last of those being the tell, since claims() rejects it before the body read. Connect and gRPC-Web are untouched: the content-type set is an exact match, never a prefix, so application/grpc-web+proto is not caught. The grpc-plugin README’s claim that the bidi hang was “the transport reason the bidi bullet above gives, not because of this refusal” is corrected — it was the buffering, and the README now says what the buffering still costs a client that holds its stream open past its last message. A DRAINING service still answers 503 rather than the header-only refusal: after close() the only paths it claims are the ones it served, precisely so their drain answer survives, and UNIMPLEMENTED would tell a client to stop asking where 503 tells it to retry.

  • kernel — an RPC response’s repeated headers were collapsed when copied onto the framework response. The copy used IResponse.header, which delegates to Headers.set, so a service answering two Set-Cookie values — the one header Headers.entries() yields once per value instead of comma-joining — kept only the last. It appends now.

  • scheduler-plugin — a declared job that could not be scheduled named neither the plugin nor the entry. SchedulerPlugin({ jobs: [...] }) correctly refuses to boot on an invalid cron expression, but reported only Invalid cron expression: not a cron — so an application declaring several jobs was told an expression was bad and left to find which one, while the plugin held both the declared index and the job’s own name (X23-1). Both arms of jobs now fail identically, through one labelled path: Failed to schedule SchedulerPlugin({ jobs })[1] (name 'bad-expr'): …, with the original refusal retained as cause. The index is the entry’s position in the DECLARED array, not among entries of its own kind, so a factory ahead of the failing entry cannot make it name a working one. This is the convention M86 already stated for a throwing factory; an instance entry took a different path and got none of it. Also corrected a stale comment claiming instance registration is DEFERRED into onInit — M86’s own code review reverted that and gated delivery instead.

Documentation

  • storage-plugin — documented that S3Provider.getStream is demand-driven, that the other four providers are NOT, and that process RSS does not measure any of it. X45-1 read a slow-client RSS rise as the S3 stream pulling ahead of its consumer; counted at the socket instead, read-ahead is 2-3 MiB and CONSTANT across 20 s and 20 MiB delivered (3.1 MiB at a 1 MiB/s consumer, 1.9-2.5 MiB at 2 MiB/s), a stalled consumer stops the wire, and cancelling releases the upstream connection. The scoping is the part worth reading: GcsProvider and AzureBlobProvider stream natively but drain their SDK stream eagerly with no pull and no cancel, and MemoryProvider and LocalStorageProvider have no native getStream, so StorageService reads the object whole and emits it as one chunk — on all four, a large object is memory-resident however slowly the client reads. A per-provider table now says so rather than leaving the S3 measurement to read as a general guarantee. The RSS reading does not discriminate — forcing GC mid-transfer returns heapUsed and external to baseline while RSS stays up, and a generated-ReadableStream control is not like-for-like because it never pays the AWS SDK’s ~29 MiB import. The finding’s own figures say the same: a 1 GiB object plateaued at 132 MB and then fell to 113 MB. The suggested pull() adapter measures 3.2 MiB against 3.1 MiB, so it was not adopted. No code change; the gap was that no gate drove a large real object through a slow reader, now closed by a guarded suite measuring wire bytes through a byte-counting relay (verified to fail at 47.9 MiB against a 16 MiB cap when getStream is replaced with an eager unbounded pump).

  • graphql-plugin — documented that subscriptions.websocket.onConnect and a pipeline guard on the socket path cannot both authenticate a subscription. connectionParams is the protocol’s auth channel and onConnect reads it, but connection_init is a message on an already-open socket, while since M70a a guard runs on the UPGRADE — so a guard covering the /graphql prefix answers GET /graphql/ws with 401 and onConnect never runs. Following the README’s onConnect example and PUBLIC_API.md’s “Authenticating a subscription” bullet at the same time could not work, and neither site mentioned the interaction M70a created (X6-8). Both now name the two working compositions: scope the guard to the HTTP endpoint, or authenticate on the upgrade with a cookie through auth-plugin’s session strategy. No code change.

  • session-plugin — documented what SameSite does not separate. cookie.sameSite defaults to 'lax' and is evaluated against the SITE (scheme plus registrable domain), so a port is not part of it: two ports on one host are same-site and the session cookie rides a subresource request between them. Paired with ignoreMethods’ correct GET/HEAD/OPTIONS exemption, one assumption carries the weight — that GET handlers have no side effects — and a developer reading “SameSite=Lax by default” reasonably concludes another port cannot reach their session. Measured: the identical attack suite lands from 127.0.0.1:5301 and is blocked from localhost:5301, changing nothing but the origin (X33-1). It also says what does NOT close the gap: cookies are not scoped by port at all (RFC 6265 §8.5), so no cookie prefix isolates one — __Host- is a SUBDOMAIN boundary and worth setting for that, but a service on another port of the same host receives the cookie whatever attributes it carries. No code change; both defences are behaving as designed.

0.5.0 — 2026-09-09

Added

  • @setu-ts/common, @setu-ts/database-plugin, @setu-ts/messaging-plugin — operator error diagnostics survive wrapping and string logger sinks (M90j). serializeError now carries a bounded, scalar-only driver classifier allowlist and AggregateError members; database adapter wrappers retain their original cause; and broker string loggers render the complete bounded diagnostic rather than JavaScript’s lossy default error interpolation.

  • Documentation — the Deno compiler-option mechanism, corrected wherever it was asserted. Six sites — the 0.1.0-alpha.8 CHANGELOG entry, three packages/cli/src JSDoc blocks, the M63 entry in CLAUDE.md, and the ROADMAP record — stated that declaring any compilerOptions key in a deno.json REPLACES Deno’s entire default set, and blamed the full-stack template’s 79 TS2686 errors on an unrelated experimentalDecorators entry displacing the react-jsx default. That mechanism is false: measured on Deno 2.9.6, declaring one option leaves every other default in force, and the default JSX transform is the CLASSIC one — so a React template must declare jsx itself, and the missing jsx was the whole cause. The fix those sites describe is unaffected and was always correct; only the stated reason was wrong, which matters because a reader who believed it would avoid declaring a compiler option they need. The 0.1.0-alpha.8 section is edited in place, with the superseded sentence quoted rather than deleted. No generated output changes — the three CLI edits are comments only.

  • Documentation — docs/upgrading.md, and four published claims corrected (M90h / X22-4, X26-1, X26-2, X33-2, X20-3). A new upgrade guide answers “what must I change in my project”, version by version, where the CHANGELOG answers what changed in the framework; docs/releasing.md gains the release step that maintains it, and docs/README.md indexes it. No package’s src/ changes. The auth README’s rate-limit example no longer claims // per IP — the default key is the literal 'anonymous' for every unauthenticated caller unless ipSecurityMiddleware is registered with trustProxy — and a new test pins the resolved key rather than the comment. The session README gains the safe-request-then-mutation CSRF sequence, because its ## Session fixation and ## Form CSRF sections each compile and answer 403 when composed; a new integration test drives the sequence and asserts the successful path first. The secrets README and PUBLIC_API.md gain a per-provider set()/rotate() table — Vault and Azure create on write, AWS and GCP require the secret to pre-exist, env refuses — each cell marked verified-against-a-real-backend or not. Two published sections are corrected in place: the 0.2.0 entry now announces the required IRepository.findPage (announced nowhere when it shipped, while its sibling findOne was announced twice), backed by a hand-written implementor committed as a compile-time tripwire, and the 0.1.0-alpha.10 decorator entry now says that a reader’s OWN manifest must drop experimentalDecorators. A third published section is corrected below, by a separate change.

  • @setu-ts/common, @setu-ts/database-plugin — explicit transaction isolation (M90g / X24-2, X38-3). TransactionOptions.isolation carries one of the four portable isolation names — the new TransactionIsolationLevel union, exported from @setu-ts/common and re-exported from @setu-ts/database-plugin — through IDatabaseService.transaction() to the adapter. An adapter declares what it honours through the new ITransactionIsolationSupport (common), and a level it does not honour is refused with the new exported UnsupportedIsolationLevelError (database-plugin), answered 501, rather than silently downgraded. What an adapter honours is connector-dependent, not per-adapter: Prisma honours all four on PostgreSQL, MySQL and SQL Server, only serializable on CockroachDB and SQLite, and none on MongoDB or a connector it could not resolve; a Drizzle bridge honours all four only when the application wraps it with the new exported withIsolationSupport(), which is the declaration that its bridge forwards options; and Memory serializes process-local transactions. PUBLIC_API.md carries the per-adapter table. MongoDB snapshot isolation is not labelled as portable serializable, so MongoDB refuses every requested portable level by name rather than silently using its default.

  • @setu-ts/session-plugin — documented snapshot-commit concurrency limit (M90g / X22-6). Cookie and store sessions each commit their complete payload snapshot, so overlapping requests that update different keys remain last-writer-wins. The README and public API now name this explicitly, with short-lived sessions and an application-level serialized or optimistic write path as remedies.

  • @setu-ts/cache-plugin — in-process cache-miss coalescing (M90g / X24-1). CacheService.getOrSet(key, factory, ttlSeconds?) shares one cold read per key and resolved store, stores its successful result with the explicit or configured default TTL, and clears a failed load so every waiter can retry its own factory. cacheMiddleware uses the same per-process mechanism and report successful waiters as X-Cache: COALESCED; streaming, uncacheable, and failed leaders are never replayed. Separate processes do not coordinate.

  • @setu-ts/messaging-plugin — named NATS JetStream startup errors and an explicit subject set (M90d / X28-2, X28-3). NatsOptions.streamSubjects (and the same member on the public nats arm) supplies the subjects the broker may create the configured stream with when it is absent on the server. There is deliberately no default and no catch-all: NATS refuses subjects: ['>'] without no_ack, and no_ack makes every JetStream publish reject unobserved (the rejected design, measured against a real server). Supplied and the stream is absent, the broker creates it with exactly those subjects; an existing stream is never touched; absent with the stream absent, connect() rejects with the new exported JetStreamStreamError, naming the stream and both remedies. A JetStream-less server now rejects with the new exported JetStreamUnavailableError (the raw 503 carried as cause) instead of surfacing a bare platform error. The messaging-plugin CI suite also gained two real-backend suites (nats-real, kafka-real) against nats:2-alpine -js and apache/kafka:4.0.0, started by both workflows; the package manifest’s scoped net grant gained 4222 and 9092.

  • @setu-ts/messaging-plugin — messageId/timestamp on the cloud brokers’ metadata (M90d / X28-4). IPubSubTransport.open and IServiceBusTransport.open delivered-message callbacks gain optional messageId/timestamp members (source-compatible for existing transports — a two-member implementation stays assignable), the real adapters read message.id/message.publishTime and message.messageId/message.enqueuedTimeUtc, and both brokers copy them onto MessageMetadata only when the transport carried them, so an absent member means “none delivered” rather than “did not look”. All seven first-party brokers now populate both members.

  • @setu-ts/messaging-plugin — Service Bus transport reachability and a retry budget (M90b / X28-5, X28-6). The adapted ServiceBusSdkModule administration client gains an optional getNamespaceProperties() and the adapted transport now implements the documented isHealthy() probe: a successful read proves the namespace is reachable, a positively identified 401/403 counts as reachable too (the namespace answered — a send/listen-only credential is not a network outage), and any other failure is unreachability. The new ServiceBusRetryOptions type (exported) is accepted as retryOptions on the service-bus production arm only and is passed to ServiceBusClient alone, never to the administration client; omission preserves the Azure SDK defaults and maxRetries: 0 is the documented short budget for failing fast toward a dead broker.

  • @setu-ts/health-plugin — bounded, concurrent aggregation. New HealthPluginOptions.indicatorTimeoutMs (positive finite, default 5,000) applies a per-indicator deadline; the service runs selected indicators concurrently, records each latency individually, maps a timeout to { status: 'down', data: { reason: 'timeout' } } and a rejection to { status: 'down', data: { reason: 'error' } } without serializing the thrown value, and keeps checks in registration order. The deadline is validated by ONE check shared by both entry points: HealthService is barrel-exported, so new HealthService(runtime, { indicatorTimeoutMs }) refuses zero, a negative, NaN and Infinity exactly as HealthPlugin does rather than storing a value the deadline timer cannot honour. See Changed for the behavior change to timing.

  • @setu-ts/database-plugin — pool capacity as data. New DatabasePoolCapacity type (exported) and DrizzleAdapterOptions.poolStats — an application-owned callback reading the driver’s own pool API. The adapter publishes the snapshot to the database indicator under data.capacity; omitted, no capacity fields appear. No threshold is applied. DatabaseService also gains readonly isClosed: boolean — a lifecycle-only read that reaches no adapter — which is what the indicator’s uncached gate now reads: gating on isHealthy() called IDatabaseAdapter.isReady() on the one path deliberately outside the probe’s 2-second bound, and cost two readiness reads per poll. Every adapter readiness call now happens inside the bounded, cached probe, and the gate is re-read after the probe settles so a poll already in flight when close() begins cannot publish a reachability answer the probe cached before it.

  • @setu-ts/cache-plugin, @setu-ts/secrets-plugin — truthful reachability. The cache and secrets indicators now report reachable (true/false/'unknown') beside lifecycle, through probes cached 5 s and bounded 2 s via createCachedProbe — exported from @setu-ts/common with its CachedProbeOptions and ProbeTiming types and the resolveProbeTiming helper that reads a runtime’s clock and timers: Redis probes with ping(), Vault with an unauthenticated /v1/sys/health request (no secret read, no token), memory/noop/env report lifecycle truth, and a cloud facade without the new optional isHealthy() member reports 'unknown' — never a secret read standing in for a probe.

  • @setu-ts/queue-plugin — backlog fact. The queue indicator carries backlog whenever at least one depth read succeeded: the sum of each successful name’s ready + processing, deliberately excluding terminal dead. A fact, not a threshold.

  • @setu-ts/common — createPathMatcher(patterns) and PathPattern, the one path-exclusion matcher. Three middlewares already carried a private copy of the matching loop and X32-1’s fix would have made a fourth (§11.1). The copies disagreed: two matched literals only, one accepted a RegExp but ran an O(n) typeof branch per request, and exactly one of the three reset lastIndex before .test — which is the difference between a g-flagged pattern matching every request and matching every other request. The list is partitioned ONCE at construction, literals into a Set and patterns into an array walked only on a literal miss, so an all-literal list (every default in this framework) costs the same hash lookup the hand-rolled copies did, and tenantMiddleware gets faster than its previous per-entry loop. RateLimitOptions.exclude, MultiTenancyPluginOptions.exclude, RequestLoggerOptions.excludePaths and MetricsPluginOptions.excludePaths all read it. Those last two widen from readonly string[] to readonly PathPattern[]: source-compatible for callers, and breaking only for a consumer EXTRACTING the option type into a narrower one.

  • @setu-ts/runtime — RuntimeOptions.maxBodyBytes and RequestBodyTooLargeError, the request body bound no header can switch off (X32-4). requestSizeMiddleware refuses on a declared Content-Length; a chunked request declares none, and since M87 made the body read lazy the read happens inside the handler, after every middleware has returned — so the middleware could not close the bypass at all, and Request.arrayBuffer() buffered a body of any size. The read now streams with a byte cap, comparing the running total before a chunk is retained and cancelling the source on refusal rather than abandoning it. Omitted, the read is unbounded and the uncapped branch keeps arrayBuffer() verbatim, so an application that configures no limit runs the previous code exactly. The refusal is branded with a 413 status hint, so an application running errorHandler answers 413 Payload Too Large in its configured format. There are deliberately TWO knobs for one concern — the mapping runs before any plugin and no channel exists between them — and the docs say which is load-bearing: a reader who sets only maxBodySize keeps the previous behaviour rather than getting a silent regression. HttpAdapterFactories’ members now take an optional HttpAdapterOptions, which keeps a zero-argument injected fake assignable.

  • @setu-ts/http-security-plugin — IpSecurityOptions.trustedProxies and proxyHops, resolving the client from the RIGHT of a proxy header (X32-3). trustProxy: true took the LEFTMOST entry, which is safe only behind a proxy that overwrites the header — and the standard nginx idiom (proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for) APPENDS, so a request arriving with a forged X-Forwarded-For: 7.7.7.7 reached the application as 7.7.7.7, 198.51.100.9 and the leftmost entry was the value the caller chose. Anything keyed on CLIENT_IP_STATE_KEY — including defaultRateLimitKey — was then keyed on attacker input. trustedProxies walks right to left and returns the first entry that is not a trusted proxy; proxyHops returns the nth from the right for proxies with no fixed address, and a header shorter than the declared chain resolves undefined rather than falling back to a guess. The two are mutually exclusive and supplying both throws at middleware construction, as does a trustedProxies entry whose CIDR width is not plain digits (Number('') is 0, so '10.0.0.1/' would compile to a /0 matcher trusting every IPv4 address, and a chain whose leftmost entry is IPv6 would then return that caller-supplied value as the client) and a proxyHops that is not a non-negative integer (0 is the rightmost entry): without that guard it resolves undefined for every caller and silently degrades a rate limiter keyed on the client IP to one shared bucket. With neither supplied resolution stays leftmost, unchanged — X32-3 requires the operator to have opted into trustProxy, so a silent default flip would be a larger change than the finding. Only IPv4 CIDR is expanded numerically; every other form is compared as a case-insensitive literal, deliberately, since a wrong expansion would silently TRUST an untrusted hop.

  • @setu-ts/graphql-plugin — GraphqlPluginOptions.maxNodes, a query-breadth budget (X32-6). maxDepth was the only query-cost control and it bounds NESTING: a document two levels deep can still ask for a hundred thousand fields, because every alias is a separate field. Measured, one such request produced a ~5 MB response and +822 MB RSS with maxDepth: 5 configured and unable to see it, and the only thing bounding it was the request-body size. maxNodes counts the fields a document would resolve — aliases separately, and each fragment spread costing its definition at EVERY spread site, so defining a hundred fields once and spreading them a thousand times counts as a hundred thousand rather than eleven hundred. Expansion is memoized, so a deep fragment graph reports a large count in time linear in the document’s own size; without that the counter would itself be the denial of service. Off by default (0), so no released application starts refusing a document it used to serve. A document’s cost is its largest operation, not the sum of all of them — only the operation operationName selects is ever executed, so summing refused a bundled document whose selected operation was within budget, and the sibling maxDepth was already per-operation. A value that is not a non-negative integer throws at construction, for the same reason maxBodyBytes does: maxNodes <= 0 is false for NaN, so the rule would be built and then never report, silently enforcing nothing while reading as configured. The rule itself is deliberately NOT exported: it is configured through maxNodes, nothing outside the package constructs it, and exporting it would leak the plugin’s private graphql facades into the published surface — which deno doc --lint reports (the M82 precedent).

  • @setu-ts/common — the shared authorization refusal (M90c / X18-2). respondWithAuthorizationFailure(target, failure) and the AuthorizationFailure union ('authentication-required' | 'not-configured' | 'insufficient-privileges') are exported so auth-plugin’s guards and decorator-plugin’s @Roles/@Permissions middleware write the same refusal without either plugin importing the other. Each arm writes through the existing respondWithError seam, so all three answer in the application’s configured error format: 401 Unauthorized / Authentication required, 501 Not Implemented / Authorization is not configured, and 403 Forbidden / Insufficient privileges. That last detail is deliberately fixed text — see Changed for the non-disclosure it replaces.

  • @setu-ts/common — the shared JSON-body parse (M90f / X37-1). parseJsonBody(text) and MalformedRequestBodyError are exported so all three IRequest.json() implementations — the served HTTP path (@setu-ts/runtime), the kernel’s inject(), and @setu-ts/testing’s MockRequest — reject a malformed body with the SAME 400-branded error instead of each raising its own bare SyntaxError. IRequest.json()’s contract now states the rejection.

  • @setu-ts/database-plugin — classified driver errors (M90f / X38-1, X35-2). New exported SerializationConflictError and DatabaseUnavailableError. DatabaseService classifies a driver rejection at its four interception sites (repository wrappers, the beginTransaction() acquisition, the transaction catch, and query()), carrying the original driver error as cause.

  • @setu-ts/secrets-plugin — the package’s first error class (M90f / X20-2). ReadOnlySecretProviderError, rejected by EnvProvider.set — the provider’s only write method — so SecretsService.rotate() inherits the refusal from the one throw site.

  • @setu-ts/common, @setu-ts/queue-plugin — the queue carries a trace (M90i / X34-1, X29-3). AddJobOptions.headers? and IJob.headers? mirror MessageMetadata.headers exactly, giving the queue ingress the header channel it had none of — grep -c headers over common/src/services/queue.ts was 0, so this was a designed capability gap rather than an adapter declining to read something. Both are OPTIONAL, so every existing IQueue implementor stays source-compatible. With a CAPABILITIES.TELEMETRY provider registered, queue.add() now runs in an enqueue <name> producer span that injects a W3C traceparent, and every processor runs in a process <name> consumer span parented from it — through the processors arm, a processors factory, and imperative queue.process() alike. All four adapters carry the map, and an M86 ingress behaviour reads it as IngressContext.headers, the same member the 'messaging' arm uses. Absent telemetry nothing is injected and behaviour is byte-identical; a headers map the CALLER passed is delivered either way, since it is a public option whose delivery must not depend on which capabilities are registered.

  • @setu-ts/cloudflare-plugin — WorkersQueue carries the queue header channel (M90i, review finding). The Cloudflare queue accepted the widened AddJobOptions and dropped headers on the floor, so a caller-supplied map — and any propagated traceparent — was silently lost, while the contract says a supplied map reaches the processor. The { v, name, id, data } envelope gains an OPTIONAL headers, so the version is deliberately NOT bumped: an older consumer ignores the field and a newer consumer reads an older message as carrying no channel, which is what absent means. A malformed map is dropped and the job runs untraced rather than being refused, because the envelope guard RETRIES what it rejects and losing the work to protect the record of it is the worse trade.

  • @setu-ts/common, @setu-ts/telemetry-plugin, @setu-ts/logger-plugin — log records name their trace (M90i / X34-2). trace_id/span_id appeared in common and telemetry-plugin and in no logger, formatter or transport, so every signal the framework emits was individually good and mutually unjoinable. ITelemetryService.activeSpanContext?() and the matching TracerHost.activeSpanContext?() are new OPTIONAL members — the read could not be expressed before, since withSpan hands its span only to its own callback — and LoggerPlugin now enriches every record, at every level and through child(), when a telemetry capability is registered. The field names are snake_case to match the OpenTelemetry log-correlation convention that log backends key on. NoopTelemetryService omits the member, and a service that cannot report an active span enriches nothing rather than emitting empty identifiers. Validity is decided by the SHARED trace- context codec rather than a rule spelled out twice, so the read path and the propagation path cannot disagree about which contexts are real: an empty, malformed or W3C all-zero identifier — what OTel reports for an INVALID span context — is reported as no active span, and never reaches a log record as a trace nothing else could carry.

Changed

  • BREAKING — @setu-ts/logger-plugin — the registered ILogger is a decorator, not the transport instance (M90i / X34-2). CAPABILITIES.LOGGER now resolves to an internal trace-enriching wrapper around the configured ConsoleLogger/PinoLogger/NoopLogger, so logger instanceof ConsoleLogger on the resolved capability no longer holds. Every ILogger method behaves identically and level passes through, so code written against the contract is unaffected. Migration: if you branch on the concrete class, branch on behaviour or on your own configuration instead — ILogger is the contract and the concrete class was never part of it.

    LoggerPlugin deliberately does NOT declare CAPABILITIES.TELEMETRY in optionalDependencies: TelemetryPlugin already declares CAPABILITIES.LOGGER in its own, and the two edges together are a cycle the kernel’s plugin resolver refuses at start() (Circular plugin dependency detected). The capability is resolved at call time instead, which is required rather than prudent — the resolver orders LoggerPlugin first, so telemetry is guaranteed absent when it registers.

  • BREAKING — @setu-ts/messaging-plugin — the Kafka default consumer group is derived per topic (M90d). Measured against a real broker: members of one consumer group must subscribe the SAME topics — kafkajs logs “Consumer group received unsubscribed topics” and every member ends with an EMPTY assignment, so the previously shared messaging-consumers group silently stopped ALL delivery the moment an application subscribed two topics (including request-reply, whose responder subscribes the derived rr.req.<topic> channel). A subscription with no queue now uses <defaultQueue>:<topic>; same topic across instances still load-balances, different topics never share a group, and a caller-supplied queue still names the group itself. The separator is a colon because - is not injective — 'orders-eu' + created and 'orders' + eu-created both yield orders-eu-created, restoring the same failure — and a Kafka topic name may not contain : (probed: the broker refuses one) while a group id may. Migration: tooling keyed on the literal messaging-consumers group reads the derived <defaultQueue>:<topic> names now.

  • BREAKING — @setu-ts/cache-plugin — IRedisClient gains a required ping(). The Redis store’s reachability probe invokes it. Migration: an injected client structurally typed as IRedisClient adds ping(): Promise<string> (ioredis already has it); the probe treats a rejection as unreachability.

  • BREAKING (behavior) — @setu-ts/health-plugin — indicators run concurrently under a deadline. Previously each selected indicator was awaited in registration order, so a 2-second outage across six dependency indicators held /health for 12+ seconds and one never-settling indicator left the endpoint pending forever. A timeout is now recorded as { status: 'down', data: { reason: 'timeout' } } and a rejection as { status: 'down', data: { reason: 'error' } }; report shape and key order are unchanged. Migration: an indicator relying on serial execution relative to a sibling (none is documented to) no longer gets it; assert on the report, not on wall-clock interleaving.

  • @setu-ts/realtime-backplane-plugin — the 'messaging' backplane calls the broker’s isHealthy() through its owner (X21-1). The probe was captured as a detached reference, so a broker whose isHealthy reads instance state (ServiceBusBroker’s private probe cache) threw a bare TypeError through the health endpoint. It now invokes broker.isHealthy() and retains the broker’s own probe cache rather than adding a duplicate one.

  • BREAKING — @setu-ts/auth-plugin — refresh-token stores now revoke credential families. RefreshTokenStore gains required atomic rotate(jti, successor: RefreshTokenRecord): Promise<IRefreshTokenRotation> — the new exported union reports whether the presented token was live when its successor was stored, so a concurrent refresh cannot mint two descendants — and revokeFamily(jti): Promise<readonly RefreshTokenRecord[]>, which returns the records it revoked so their paired access-token identifiers can be revoked with them. Custom implementations must persist the optional lineage fields on RefreshTokenRecord or revokeFamily has no chain to walk. MemoryRefreshTokenStore implements both. RefreshTokenService now issues typed, distinct access and refresh JWT identifiers; bearer authentication rejects a signed type: 'refresh' token. For immediate access-token logout, construct one IAccessTokenRevocationStore and pass it to both AuthPlugin({ jwt: { ... } }) and RefreshTokenService; the new exported MemoryAccessTokenRevocationStore is single-process and the refresh service requires accessToken.expiresIn when this option is used. Policy failures still answer 403, but their detail is now Insufficient privileges rather than disclosing required roles or permissions. Migration: add atomic rotate and revokeFamily to custom refresh stores, retain family fields, serialize family revocation with rotation, and update tests that assert the old 403 detail.

  • BREAKING — @setu-ts/cli — bare setu generate is informational, not an error. With no schematic named it printed the available schematics through the normal output sink yet exited 2 — an error exit whose guidance read like help. It now lists the schematics and exits 0, printing exactly the text setu generate --help prints. No refusal moves: generate <schematic> with a missing name, generate custom with a missing name, an unknown schematic, and an over-arity invocation all keep exit 2 with nothing written. One addition: the --runtime validation every generating path runs now sits above the listing, so bare setu generate --runtime deno2 refuses with exit 2 where the listing used to print over the typo. Migration: a script branching on bare setu generate exiting 2 sees 0 now — branch on the printed list or drop the branch.

  • BREAKING — @setu-ts/cli — unknown flags are refused instead of silently ignored. setu collected every --flag and never consulted the valid names, so a typo scaffolded a DIFFERENT project with exit 0 and no warning: new app --dry-run --templat rest produced the minimal project where --template rest produces the rest composition, and --base-port (the real flag is --port) was swallowed the same way. Each command now refuses a flag outside its own set — subcommand-aware for generate app, generate library, and workspace ports — with exit 2, a message naming the command and, within a small edit distance, a “did you mean” suggestion, and nothing written or created. Flags kept deliberately recognized for a NAMED refusal (--di and --depends-on on new; the transport flags, --runtime, and --di on generate app) still reach their specific guidance, and plugin-registered commands accept only --dir and --config — a CliCommandHandler receives positionals only, so no plugin can read any other flag. Migration: a script passing a stray flag today exits 0 and will now exit 2 — remove or correct the flag. No compatibility mode preserves ignored flags: accepting an unrecognized option would restore the silent misconfiguration this change removes.

  • BREAKING — @setu-ts/cli — extra positional arguments are refused instead of silently ignored. new read the first positional only, so setu new app extra junk scaffolded app and reported success with exit 0; generate read only the schematic word, its name, and (for custom) the custom-schematic name, dropping anything after; and adopt, workspace ports, commands, and help consumed no positionals at all. Each now refuses an over-arity invocation — exit 2, nothing written or created, a message naming the command and the expected count — in the dispatcher before any command body runs, mirroring add’s existing refusal. Tokens after -- are positionals ([parseArgs]) and refuse the same way. Valid arities are unchanged (new app, generate <schematic> <name>, generate custom <schematic-name> <name>, generate app|library <name>, and the bare add/adopt/workspace ports/commands/help forms), and plugin-registered commands still receive every positional. Migration: a script passing stray positionals today exits 0 and will now exit 2 — remove them.

  • BREAKING — @setu-ts/database-plugin — default memory raw-SQL and programmatic-migration refusals now answer 501 Not Implemented instead of a masked 500. query() now rejects with UnsupportedRawQueryError rather than throwing synchronously, so callers using .catch() now receive the refusal. migrate() rejects with the new UnsupportedMigrationError: migrations are a framework-wide unsupported capability, not a raw-query or query-shape refusal. Update status assertions from 500 to 501 and synchronous try/catch call sites to await or .catch(); use each adapter’s migration CLI instead of migrate().

  • BREAKING (behaviour) — @setu-ts/auth-plugin — the rate limiter’s 429 body answers in the application’s configured error format (X32-2). rateLimitMiddleware wrote its own body with ctx.response.status(429).json({ error, message }). M70f routed every first-party short-circuit through the respondWithError seam — upload, tenant, session, the nine auth guards, http-security’s three, the flag guard — and missed this one, presumably because the limiter is middleware rather than a guard. So the ONE status a client is most likely to handle programmatically was the one status that ignored errorHandler’s configuration, and a consumer parsing Problem Details (which this framework’s own generated SDK does) got an unreadable body under application/json. The Retry-After and RateLimit-* headers are unchanged. Migration: the body shape changes. With errorHandler({ format: 'rfc9457' }) the 429 is now Problem Details under application/problem+json, carrying detail and no message; with format: 'default' it is { statusCode, message, details: { detail } }, the shape every other refusal already used; with no errorHandler registered it is the seam’s { error, detail } fallback. A client reading body.message for the limiter’s text should read body.detail (or body.details.detail under 'default').

  • BREAKING (behaviour) — @setu-ts/auth-plugin — an exhausted rate limiter no longer refuses the operational probes (X32-1). RateLimitOptions had no exclusion member at all, and the middleware is documented as a global one, so a burst of abuse made /live answer 429 — which a Kubernetes kubelet reads as a failed liveness probe and restarts the container, whose only fault was being under load. exclude now defaults to the six operational paths (/live, /ready, /health, /metrics, /openapi.json, /docs), the same list tenantMiddleware exempts. An exempt path increments no counter and carries no RateLimit-* headers, because the limit does not govern it. Migration: pass exclude: [] to restore the previous behaviour. A caller list REPLACES the defaults, so spread DEFAULT_RATE_LIMIT_EXCLUDED_PATHS to extend them.

  • BREAKING (wire keys) — @setu-ts/auth-plugin — RedisRateLimitStore namespaces its keys (X32-5). Keys were written exactly as keyGenerator produced them — anonymous, ip:203.0.113.7, user:42 — so two applications sharing one managed Redis counted against each other’s budget, and combined with X32-1 service A’s traffic would restart service B’s pods. Both sibling Redis stores in this framework already namespace and both explain why in their own source. keyPrefix defaults to 'setu:ratelimit:', exported as DEFAULT_RATE_LIMIT_KEY_PREFIX. Migration: in-flight counters under the old keys are orphaned, so the blast radius is one window — they are TTL-bounded and expire on their own. Pass keyPrefix: '' for the previous keys byte for byte.

  • BREAKING — @setu-ts/database-plugin — driver conflicts and outages reach the client as 409 and 503 (M90f / X38-1, X35-2). A backend-reported serialization failure (PostgreSQL SQLSTATE class 40 — 40001, 40P01 — Prisma’s P2034, MongoDB’s TransientTransactionError label, Bigtable’s gRPC ABORTED, Cosmos 449) now answers 409 Conflict through SerializationConflictError, where it was a masked 500 that told a well-behaved client to give up — silently dropping the write and making optimistic concurrency unusable. A transiently unreachable backend (SQLSTATE class 08, 57P03, the node-postgres pool timeout, MongoNetworkError/MongoServerSelectionError, gRPC UNAVAILABLE, Cosmos 429/503, the net errno ECONNREFUSED family) now answers 503 Service Unavailable through DatabaseUnavailableError. The original driver error is preserved as cause (the SQLSTATE stays log-reachable); the served detail is a fixed sentence, never driver text. No classified answer carries Retry-After — the hint channel has no header and no component holds an honest horizon. Migration: an application branching on the masked 500 for these conditions branches on a body it could never have parsed (“Internal Server Error”); instanceof SerializationConflictError is the retry signal. The typed Drizzle builder (getDrizzleDatabase) bypasses the classifier, and DynamoDB’s TransactionConflictException / Cosmos’s 449 are documented-unverified — neither was reproducible against DynamoDB Local or the local emulator.

  • BREAKING — @setu-ts/runtime, @setu-ts/kernel, @setu-ts/testing — a malformed JSON request body answers 400 (M90f / X37-1). IRequest.json() rejects with MalformedRequestBodyError (branded 400 Bad Request) instead of a bare SyntaxError that errorHandler masked to 500. The read stays lazy, so the 400 is produced when the handler reads, and a handler that catches its own rejection keeps full control. An empty body still parses as {} on inject() and MockRequest. Migration: none for well-formed callers; a 500-matching catch for malformed bodies now sees 400.

  • BREAKING — @setu-ts/secrets-plugin — a write against a read-only provider answers 501 (M90f / X20-2). EnvProvider.set (and SecretsService.rotate() on the env provider) rejects with ReadOnlySecretProviderError, answered 501 Not Implemented with the class’s own sentence, where it was a masked 500. Migration: an application catching the old plain Error('EnvProvider is read-only; …') by message still catches the new class (same message); add an instanceof branch to distinguish it from faults.

  • BREAKING — @setu-ts/resilience-plugin — the shedding errors answer 503/503/504 (M90f / X32-7). BulkheadFullError and CircuitOpenError are answered 503 Service Unavailable and TimeoutError is answered 504 Gateway Timeout under errorHandler, where all three were masked 500s that read as bugs in every dashboard. Shedding is the bulkhead’s purpose, so the status is part of the contract. None carries Retry-After — the hint channel has no header, and no component holds an honest drain horizon; 503 is itself the retryable signal. Migration: an application serving its own fallback for these errors is unaffected; one relying on the masked 500 must match the new statuses or instanceof the classes.

Security

  • The deno.lock resolution of npm:nodemailer moves 9.0.4 → 9.1.1 (GHSA-2x7j-588g-ccc2, high). Quadratic (O(n²)) time in nodemailer’s addressparser lets a crafted address list drive a remote denial of service; the advisory was published 2026-09-08 and names < 9.1.0. No source change was needed — smtp-provider.ts already imports npm:nodemailer@^9, which the patched version satisfies — so this moves the resolution CI and the test suite use.

    Scope, stated precisely, because a lockfile entry is easy to over-read: this repository’s lock governs its own CI and tests, not a consumer’s. An application using @setu-ts/mail-plugin resolves npm:nodemailer@^9 against its own lock, so it was never pinned to 9.0.4 by us and receives nothing automatically from this entry. If your lock resolves that specifier below 9.1.0, update it. Only the SMTP provider reaches nodemailer at all; every other mail backend is unaffected.

Fixed

  • @setu-ts/openapi-plugin — a type union is spelled anyOf on every supported zod v4, not whichever spelling the installed patch version chose (issue #253). Draft 2020-12 permits both spellings, and zod’s public toJSONSchema() changed which one it emits INSIDE the declared >=4.4.0 <5 support range: through 4.4 a union of bare types emitted anyOf: [{ type: 'string' }, { type: 'null' }], and from 4.5 it collapses to type: ['string', 'null']. The transformer passed that through, so an application’s zod PATCH version decided the document’s shape — isStructuralShape keys on anyOf, so a nullable or primitive union silently stopped earning a components/schemas entry and a regenerated @setu-ts/sdk client changed shape for no API reason. It also produced a value the published OpenApiSchemaObject.type cannot express (a single type name, not an array), so a consumer switching on it got no compiler warning, and a type array is 3.1-only where anyOf converts down to OpenAPI 3.0. #adaptDocument now normalizes the collapsed form back at any depth, on both the plain and the component-hoisting path, keeping the v4 path in agreement with the v3 path — measured: one schema now yields byte-identical documents under 4.4.3 and 4.5.4. Sibling keywords stay beside the anyOf instead of being distributed into its arms, which would apply a string constraint to the null arm. No public type changed and no consumer call site breaks. Reached the weekly dependency-drift gate rather than a pull request because the committed lockfile resolved 4.4.3; it now resolves 4.5.4, and a pinned npm:zod@4.5.4 case asserts the behaviour against a version that really collapses, so a later lockfile move cannot make it vacuous. The rewrite descends only into schema positions, so a default, const, enum, example or vendor-extension value keeps its own type key verbatim, and a node already asserting anyOf keeps it — the normalized union is conjoined through allOf.

  • @setu-ts/static-plugin — gzip sidecars now use the registered gzip content-coding token. A .gz sidecar was sent as Content-Encoding: gz, which standard clients cannot decode and a shared cache could retain after the origin was corrected. Purge affected cached variants to recover immediately. Conditional evaluation now runs after content negotiation, so an If-None-Match validator for the uncompressed source cannot incorrectly return 304 for a selected compressed sidecar. On runtimes without a filesystem, the registered IStaticFiles service now returns 404 as documented rather than throwing when an application calls serve().

  • @setu-ts/messaging-plugin — the Kafka broker starts (M90d / X28-1). KafkaBroker wired its reconnect supervisor with the uppercase KEYS of kafkajs’s producer.events ('CONNECT'/'DISCONNECT') where the wire VALUES are required (producer.connect/producer.disconnect); kafkajs validates the name and threw KafkaJSNonRetriableError inside register(), so an application configured with broker: 'kafka' never bound a socket. The broker now attaches two declared wire-value constants; a guarded real-import test pins them against the real module’s own events map and proves the uppercase keys are rejected by real kafkajs. The corrected event names also fix the JSDoc that documented the keys as the accepted names (C1).

  • @setu-ts/messaging-plugin — the NATS broker delivers (M90d, found by the new real-backend suite). Two delivery-path defects no injected fake could see, both probed against npm:nats@2.29: subscribe() created the durable consumer through js.consumers.add, which the JetStream client does not expose (consumer creation is a management operation on jsm.consumers.add), so every real subscribe threw a bare TypeError; and the delivery cast read info.timestamp, which nats 2.29 spells info.timestampNanos — an integer above the float safe-integer range — so every real delivery carried an Invalid Date. Consumer creation now goes through the manager and the timestamp is converted from nanoseconds through BigInt; the test fixture models the real timestampNanos field rather than the string it claimed.

  • @setu-ts/messaging-plugin — the NATS broker fails startup with a name, not a bare platform error (M90d / X28-3). See Added: JetStreamUnavailableError and JetStreamStreamError replace the raw 503 and the catch-all-refusal text at both connect() failure sites, carrying the platform error as cause wherever the platform produced one. The absent-stream refusal is the one arm with no cause: no platform call failed, the broker declines to invent a subject set.

0.4.0 — 2026-09-05

A declaration that enforced nothing now enforces, and a caller’s mistake stops reading as a server fault. @Roles/@Permissions recorded metadata and were evaluated by nobody, so a route that looked guarded served every request; they are now enforced, and a deployment with no authorization provider fails closed rather than open. Alongside it, three classes of refusal that a caller caused — an unsupported query shape, a raw query on an adapter that cannot run one, an RBAC guard with no rbac arm — answered a masked 500 Internal Server Error with the useful sentence reachable only in the log; they now answer 501 Not Implemented and say what is wrong.

The in-memory broker stops deadlocking startup. A plugin that awaited its own publish during register() hung with no boot, no error and no log; publish now resolves on dispatch hand-off, the guarantee the real brokers always gave, and the behaviour chain’s delivery gate is bounded by chainReadyTimeoutMs. Non-HTTP ingress also gains the tenant entry point 0.3.0’s notes advertised and the shipped contract could not express: getRepositoryFor(tenantId, entity).

Six breaking changes ride with it, every one carrying migration text in Changed. Four are behavioural: two refusals move from a masked 500 to 501, a restricted route starts answering 401/403/501 where it used to serve, and InMemoryBroker.publish resolves earlier — so a test asserting the old status, or one that awaited publish and then read a handler side-effect, needs updating. Two are API changes, reaching implementors of IMultiTenancyService and callers constructing UnsupportedRawQueryError directly. Under 0.x a minor bump is where breaking changes live — see Versioning.

Added

  • @setu-ts/common — IMultiTenancyService.getRepositoryFor(tenantId, entity), the ctx-free tenant-scoped repository entry point, modelled on the interface’s other ctx-free, id-taking member prefixCacheKey. It exists because 0.3.0’s release notes advertised a tenant concern as expressible once per ingress kind, and measured against the shipped contract it was not: both tenant-bearing members take an IRequestContext, which no ingress path has. The concern became expressible in THIS release, not 0.3.0. An ingress behaviour reads the tenant id from the work item’s own payload (IngressContext.payload) and scopes through the ctx-free member; the id is trusted input, so getRepository(ctx, …) stays the HTTP-path member.

  • @setu-ts/messaging-plugin — the bounded chain-gate wait. MessagingCommonOptions gains chainReadyTimeoutMs (default 10_000; 0 waits forever; ignored when no behaviors factory is configured, because the gate is then never armed; finite, non-negative values up to 2_147_483_647): a dispatch held on the gate rejects after the bound with the new exported ChainGateTimeoutError, whose message names register() as the likely cause. A REJECTED gate still refuses delivery forever, as before. Also exported: InMemoryBrokerOptions, whose optional onDispatchError(error, metadata) is the terminus of the in-memory broker’s handler-failure path (the plugin always supplies one backed by the application’s logger, read at call time; the option is reachable only by constructing the broker directly).

  • @setu-ts/common — the HTTP status hint, a symbol-keyed brand through which a package that may not import @setu-ts/exceptions (AI_GUIDELINES §2.2) states how its own error should be answered: HTTP_STATUS_HINT, HttpStatusHint, withHttpStatusHint(error, hint) and httpStatusHintOf(error). It exists because M70f’s respondWithError seam cannot reach every site — a data source throwing from deep inside an adapter holds no IRequestContext — so its error arrived at errorHandler as a plain Error, was normalized to 500, and was masked.

    HttpStatusHint is an ErrorResponseInit whose detail is required, and errorHandler builds the response from the hint’s status/title/detail and never from the error’s message. Both entry points construct through one implementation, so a hinted throw and a respondWithError call carrying the same values answer byte-identically under every configured format. A hinted error is exempt from maskInternalErrors — narrow by construction rather than by trust, since what it serves is a fixed sentence the brand site wrote, so there is no driver diagnostic in the body for masking to remove. A brand on a deliberately thrown HttpError is ignored; that error already states its own status. Symbol.for, so two copies of common in one process resolve the same key.

    HttpStatusHint.status must be an integer in 400–599; a hint outside that range reads as absent and the error takes the ordinary masked-500 path. An unserveable status would otherwise make the web Response constructor throw out of the error handler itself, and a hint says how an ERROR is answered, which is never a success or a redirect.

  • @setu-ts/common — ResponseSnapshotInit, and the optional ResponseSnapshot.responseInit member that carries it (M88). Terminal json/text/html/byte/redirect responses now defer building the framework Headers object until something actually reads the documented live snapshot.headers view; when the kernel never needed a mutable instance it attaches this header input instead, and the shared runtime mapper hands it straight to the native Response constructor. Explicit and repeated header writes keep the existing path.

    The member is @internal — a kernel-to-runtime protocol, not application surface. Middleware and handler code continue to read snapshot.headers, whose behaviour is unchanged. The initializer is a fresh mutable copy of an immutable internal source, so a native server adding a derived header (@hono/node-server computing Content-Length) cannot contaminate another response; a real Node adapter regression test pins that.

    Source-compatible in both directions: the member is optional, so an existing ResponseSnapshot producer or consumer is unaffected. Measurement, stated narrowly: focused response conversion measured 35.7% lower median cost, and five alternating Node-adapter loopback samples measured a +4.9% median with material local variance. Neither is a production-throughput claim.

Changed

  • BREAKING (behaviour) — InMemoryBroker.publish resolves on dispatch hand-off, not on handler completion (X16-1). The documented contract said it resolves “when all handlers have been invoked”; it actually resolved when every handler had RETURNED, and combined with the behaviour chain’s delivery gate — which opens at the end of onInit — a plugin that published (and awaited the publish) during its own register() deadlocked startup silently: no boot, no error, no log. Measured on 2026-09-03, the blast radius was the in-memory broker alone; rabbitmq and redis-streams already returned before delivery and delivered through the complete chain. publish now resolves once each matching subscription’s work item has been handed to dispatch, the guarantee real brokers always gave. Every invoked handler’s promise is RETAINED: a rejection reaches the broker’s failure path — on in-memory, a logger report via the plugin-supplied onDispatchError — never the caller, and never an unhandled rejection. One slow or throwing fan-out handler also no longer delays or aborts delivery to its siblings.

    Migration: code that awaited publish and then asserted a handler side-effect without awaiting anything else needs one more await (a completion deferred the handler exposes, or a polling wait). A handler rejection no longer propagates to await publish; branch on the handler’s own error path or observe the logger report instead.

  • BREAKING (API) — IMultiTenancyService gains the REQUIRED getRepositoryFor (X16-2). Out-of- repo implementors of the interface must add it; the framework’s own MultiTenancyService is the only in-repo implementor. Required rather than optional because an optional member returning undefined cannot distinguish “no such tenant” from “this implementation does not offer the read”. tenantById was deliberately NOT added: nothing in the committed surface can implement it — MultiTenancyService holds a CRUD store and no tenant catalog — and inventing one is its own design.

    Migration: add one method to any custom IMultiTenancyService implementation: getRepositoryFor(tenantId, entity) returns an ITenantRepository scoped to the id GIVEN (new TenantRepository(store, tenantId, entity) is the shipped shape). Callers change nothing except where they previously could not do this at all.

  • BREAKING (behaviour) — @Roles/@Permissions are now ENFORCED. DecoratorPlugin appends enforcing authorization middleware to every route carrying a restriction: it resolves CAPABILITIES.AUTHORIZATION per request and answers 401 without a principal and 403 on a failed check, byte-identical with the equivalent @UseGuards(requireRole(...)) spelling. When no authorization provider is registered the route FAILS CLOSED — it answers 501 Not Implemented and never serves unguarded — and register() warns once per affected route. A route decorated @Roles/@Permissions that serves today will start refusing, which is the point: an unenforced authorization decoration is the defect — but it will break tests that were passing for the wrong reason.

    Migration: register an authorization provider under CAPABILITIES.AUTHORIZATION (e.g. AuthPlugin({ jwt: { secret }, rbac: { roles } })) so the declared restrictions are evaluated, or pass DecoratorPlugin({ enforceRoles: false }) to restore the previous behaviour (metadata recorded, nothing enforced, no warning). A route carrying both decorators now requires any of the roles AND any of the permissions; method-level declarations override class-level ones.

  • BREAKING (behaviour) — a database query refusal answers 501 Not Implemented, not a masked 500 (X19-1). UnsupportedQueryFeatureError, UnsupportedFilterOperatorError and UnsupportedRawQueryError now carry the status hint above, so a DynamoDB non-key orderBy or a Bigtable offset reaches the client as 501 with a sentence naming the feature and the adapter, instead of {"title":"Internal Server Error","detail":"Internal Server Error"} with the useful message reachable only in the log. This is the shape a developer meets when SWITCHING BACKENDS: an application that worked on MongoDB answered 500 on every ordered endpoint under DynamoDB, and the response said the server was broken.

    Not every UnsupportedQueryFeatureError is branded. The class is shared by caller-caused query shapes and by configuration refusals, so only an allowlist of feature values is answered 501; mapping, endpoint, date-encoding and transaction keep the masked 500, and an unclassified value is not branded. Branding it unconditionally made a blank columnFamily answer every request 501 "Query feature 'mapping' is not supported …".

    The served detail is composed from this package’s own structured fields (feature, operator, connector, adapter) and never from the error’s message, which stays the full diagnostic and reaches the log alone. The four transaction and concurrency errors keep their masked 500: they may legitimately quote backend state, and a concurrency conflict is transient rather than permanent.

    Migration: a client or test asserting 500 on a refused query now sees 501. Branch on the exported error classes with instanceof rather than on the status where you need the distinction.

  • BREAKING (behaviour) — RBAC guards without an rbac arm answer 501, not a masked 500 (X18-2). AuthPluginOptions.rbac became optional in M68; the four authorization guards (requireRole, requirePermission, requireAnyRole, requireAllPermissions) then resolved CAPABILITIES.AUTHORIZATION unconditionally, so the registry’s throw escaped into the pipeline and a principal that genuinely HELD the required role was refused with a masked 500 Internal Server Error — a real fault with /health, /ready and /live all reporting up. They now consult services.has and short-circuit with 501 Not Implemented / Authorization is not configured.

    501 rather than 403 because nothing is wrong with the caller: the deployment cannot evaluate the policy at all, and the condition is permanent for that deployment. A principal that genuinely fails a policy check keeps its 403 — that path is unchanged — and the guards still fail closed either way.

    Migration: supply the rbac arm to make the guards evaluate. A test asserting 500 for this composition now sees 501.

  • BREAKING (API) — UnsupportedRawQueryError takes the adapter name first. The constructor is now (adapter: string, message: string) and the class carries a readonly adapter, matching the structured fields its two sibling refusals already had. Without it the served detail could not name which backend refused, leaving the one refusal in the family less actionable than the others.

    Migration: new UnsupportedRawQueryError(message) becomes new UnsupportedRawQueryError('<adapter>', message). Every in-repo call site is updated; the class is exported, so an application constructing it directly gets a compile error.

Fixed

  • @setu-ts/common / @setu-ts/exceptions — an unserveable status no longer makes the error path itself the fault. respondWithError and createErrorResponder’s responder passed ErrorResponseInit.status straight to response.status(...). The web Response constructor throws RangeError outside [200, 599] and for a non-integer, and TypeError for a body on one of the null-body statuses 204/205/304 — so such a value replaced the error response the caller asked for with an unhandled exception on the real serve path. The null-body three are in range and so survive a range check; every path through this seam writes a body, since ErrorResponseInit.title is required.

    This was reachable from application code through published options, not only from a framework literal: FlagGuardOptions.statusCode, the multi-tenancy rejectionStatus, and a WebSocketGuardDecision.status are all authored by the application, so 4004 — a plausible typo for 404 — crashed every request to that route. A new exported resolveResponseStatus(status, target) clamps an unserveable status to 500 and reports it through the logger capability when the target carries one; Number.isInteger is checked before the range comparisons, because NaN satisfies neither < nor >. Both entry points sanitize before building their response, so the formatted body’s status member and the written status cannot disagree.

    A THIRD door was found while fixing the first two and is closed with them: HttpError’s constructor validates nothing — its own JSDoc says the factory functions are what guarantee a correct status — so throw new HttpError(4004, '...') reached response.status() and threw RangeError out of the one catch that exists to contain throws. errorHandler now clamps before it logs and before it builds the body, so the logged status, the body’s status member and the written status are one number; the thrower’s message survives, since only the number was wrong.

    No behaviour changes for a serveable status. inject() could not observe any of this — it builds no native Response — so the regression tests drive app.fetch.

  • @setu-ts/runtime — a null-body status written with a body no longer kills the request. new Response(body, { status }) throws TypeError: Response with null body status cannot have body for 204, 205 and 304, and the shared snapshot mapper passed the body through regardless. The throw happened inside the HTTP adapter, after the pipeline had finished, so no middleware and no errorHandler could catch it: the request died with an unhandled exception rather than a response.

    Reachable from ordinary handler code, and not only as a typo — status(204).send(new Uint8Array(0)) and status(204).text('') carry a body that is present but EMPTY, and attaching a confirmation body to the 204 that a DELETE idiomatically returns is an easy mistake to make. Such a body is invalid — a 204 carries no content, and a client cannot read one — so it is discarded; return 200 when a confirmation representation is actually needed. Only send() with no argument worked. Present since the M23 fetch model; unrelated to M88, which changed the header source and left body handling byte-identical.

    The body is now dropped rather than the throw propagated — what Express and Fastify both do, and what RFC 9110 §15.3.5 implies, since a 204 has no content. Headers are preserved (RFC 9110 permits representation metadata on a 204), and a dropped stream body is cancel()led rather than discarded, so its source is released instead of leaking.

  • setu add refuses extra arguments instead of silently discarding them. The contract is singular; setu add auth cache used to add one package, report updated deno.json and exit 0 with the rest uninspected. A second positional is now a usage error naming the count (exit 2, nothing written) — the same by-name refusal every other misapplied input to the CLI gets.

  • MultiTenancyPlugin warns at register() when a non-column isolation strategy is selected with no dataStore. 'schema-per-tenant'/'database-per-tenant' NAME the isolation a store is expected to implement; the shipped MemoryTenantDataStore only partitions by the strategy’s label, no shipped adapter is told the strategy, and the README no longer claims physical schemas or databases are created. The headline example moves to 'column-per-tenant'.

  • MultiTenancyPlugin warns at register() when the resolved resolver chain contains a JwtResolver. Tenant identity comes from an UNVERIFIED token claim — a client can mint a token naming any tenant — acceptable only alongside authentication middleware which separately verifies the token. The README and the jwt option’s JSDoc now say so where the choice is made.

  • @Public’s documentation matches its behaviour. It contributes security: [] only for a route without an enforced @Roles/@Permissions restriction. It does NOT bypass authentication or authorization; a restricted route keeps its derived OpenAPI security requirement.

0.3.0 — 2026-09-03

The request path gets roughly 2.4x faster, and non-HTTP ingress gains the behaviour pipeline HTTP already had. Bare /json on Node went from 41,437 to ~100,000 rps — from roughly 34% to ~90% of the bare Hono router this framework is built on — and Deno from 62,637 to 160,130. Alongside it, queue jobs, scheduler ticks, broker messages and WebSocket frames can be registered declaratively and wrapped in a behaviour chain, so an authorization, tenant or audit concern is expressed once per ingress kind instead of hand-rolled inside every handler.

Two breaking changes ride with it, both narrow and both carrying migration text in Changed: the IHttpAdapter signatures widen to sync-or-async, which reaches out-of-repo adapter implementors and no ordinary caller, and the Node adapter now lets @hono/node-server install its own Request/Response globals. Deno, Bun and Workers are untouched by the second. Under this project’s versioning a 0.x minor is what carries breaking changes, which is why this is 0.3.0 rather than a patch.

Changed

  • BREAKING — IHttpAdapter accepts and returns sync-or-async. setHandler now takes (request: IRequest) => IResponse | Promise<IResponse> and fetch returns Response | Promise<Response>. Callers are unaffected — await works on both — but an out-of-repo adapter implementing the narrower Promise-only shape must widen it, because the handler’s return type sits in a contravariant position. TypeScript’s bivariant method parameters mean an existing implementation still compiles; the break is narrow and shows up when a handler that answers synchronously is passed to it.

    Migration: widen the signatures and branch on promise-ness rather than awaiting:

    import { isPromiseLike } from '@setu-ts/common';
    
    setHandler(handler: (request: IRequest) => IResponse | Promise<IResponse>): void { … }
    
    const result = this.handler(frameworkRequest);
    return isPromiseLike(result) ? Promise.resolve(result).then(finish) : finish(result);

    Use isPromiseLike, not instanceof Promise: the handler’s declared Promise<IResponse> is satisfied structurally, so a cross-realm or userland promise answers false to instanceof and would be treated as an already-settled IResponse. Promise.resolve returns a native promise unchanged and adopts a foreign thenable.

  • BREAKING (behaviour, Node only) — @hono/node-server now installs its own Request/Response as globals. The Node adapter previously passed overrideGlobalObjects: false. That flag decided which class backs new Response(...), and node-server serves a response through its synchronous fast path only when the object carries that class’s internal cache symbol — so opting out put every response on the slow path. It is node-server’s own default and what every Hono-on-Node deployment already runs, and its classes are web-standard compatible. Applications that depend on globalThis.Response being undici’s specific implementation on Node are affected; nothing else is. Deno, Bun and Workers are untouched.

  • Route registration refuses nothing new, but matching got faster and params may now be a shared frozen object. A route with no path parameters receives one shared, frozen params record rather than a fresh {}. Reading it is unchanged; mutating it — which was never supported, since IRequestContext.params is Readonly — now throws in strict mode instead of silently succeeding on a throwaway object.

  • @setu-ts/cqrs-plugin: internal composer promotion. Its private composer is removed in favor of @setu-ts/common’s shared composer. CQRS behavior and public surface are unchanged.

Added

  • isPromiseLike(value) in @setu-ts/common. Reports whether a value is thenable by the duck-typed test rather than instanceof Promise, which answers false for a promise from another realm and for userland promise libraries — values that satisfy RouteHandler’s declared Promise<HandlerResult> structurally, so TypeScript accepts them. The kernel and all four HTTP adapters use it to decide whether a handler result needed awaiting; without it the synchronous request path treats such a value as already-settled and sends the response while the handler is still running.

  • @setu-ts/common: ingress behaviour contract. Adds IngressKind, immutable IngressContext, IIngressBehavior, structural BehaviorLike, and the shared composeBehaviorChain; also adds WebSocketUpgradeGuard and WebSocketGuardDecision for route-scoped upgrade decisions.

  • @setu-ts/websocket-plugin: declarative routes and frame behaviours. Adds routes, behaviors, and per-route upgrade guards. When a behaviour chain is configured, frame dispatch is promise-mediated; without behaviours it remains the original direct synchronous invoke.

  • @setu-ts/queue-plugin: declarative processors and ingress behaviours. Adds processors and behaviors; behaviour errors preserve existing retry, onFailed, and final dead-letter handling.

  • @setu-ts/scheduler-plugin: declarative jobs and ingress behaviours. Adds jobs and behaviors; the chain runs inside the distributed lock, so a replica that loses the lock runs neither behaviours nor the handler.

  • @setu-ts/messaging-plugin: declarative subscriptions and ingress behaviours. Adds subscriptions and behaviors on every broker arm. The chain wraps subscribe handlers only; respond() remains unwrapped because its handler returns a result.

Performance

The request path was rebuilt around two principles: allocate nothing a route did not ask for, and never enter the microtask queue when the answer is already known. Measured with bombardier on Node (-c 64, medians of interleaved runs), bare /json went from 41,437 to ~100,000 rps — from roughly 34% to ~90% of the bare Hono router the framework is built on, and from ~65% to 130–160% of NestJS-Express. With three middleware registered — the shape any real application has — Setu-TS runs at 132% of Hono and 113% of raw Express, because Hono’s compose() awaits every stage and loses 40.6% of its throughput to three middleware where this pipeline loses 17.9%.

Every change below except the global-objects one is runtime-agnostic, so Deno and Bun benefit too. Measured the same way, Deno goes from 62,637 to 160,130 rps — 2.56x, and from 33% to ~85% of Hono on Deno. Bun runs at ~77% of Hono on Bun. Neither needs the global-objects change and neither receives it: @hono/node-server exists to translate between Node’s IncomingMessage/ ServerResponse and web Request/Response, and its fast path is a shortcut through that translation. Deno.serve and Bun.serve take a real Response natively, so there is no shim to bypass and no lightweight class to install.

  • Request bodies are read lazily and memoized. The mapping called arrayBuffer() on every request, including bodyless GETs; under node-server that forces the full undici Request the lightweight facade exists to avoid. A GET/HEAD carrying neither content-length nor transfer-encoding now resolves to a shared empty buffer without touching the native request. A side effect worth naming: an ordinary WebSocket upgrade now reaches the handshake with its raw Request undisturbed, which is the hazard M46 had to intercept inside the adapter to avoid.
  • Cold request-context members are lazy. id, services, state, query and signal materialize on first access; RequestContext is a class, so every request shares one hidden class.
  • The framework’s own layers no longer force async. Application.#handleRequest and all four adapters’ fetch handlers return synchronously when nothing needed awaiting, as does Bun’s Bun.serve callback — that one is a separate entry point from the fetch handler, and leaving it async would have lost on the socket path exactly what this won on the fetch path. Lifecycle hooks and global middleware still take the unchanged async path.
  • Route matching answers a single candidate without allocating. The RouteEntry rides on the handler the router already returns, replacing a ${method} ${path} key and a Map lookup; decodeURIComponent is skipped for values containing no %, where it is an identity function.
  • One URL parse per request instead of two, and often zero. Route dispatch reuses the path the mapping already resolved, and that mapping extracts it by slicing rather than constructing a URL, falling back to URL on the only inputs it rewrites — dot-segments and backslashes — so the result is identical for every input. Routing semantics are unchanged; /foo/../admin still resolves to /admin.

Fixed

  • Two committed comments described behaviour the code did not have. The request Headers copy was documented as providing “immutability” when new Headers() is mutable — it normalizes writability, since a server-received Request throws on headers.set on Deno while Node and Bun permit it. And the upgrade-body refusal claimed the mapping “has already disturbed it via arrayBuffer()”, which the lazy body makes false.

  • When a behaviors arm carries a factory, the queue, scheduler, and messaging plugins now hold dispatch until onInit has resolved the whole chain. Without the gate a handler could run against an INCOMPLETE chain: a broker holding a backlog delivers the moment a consumer attaches, so a message reached the handler having run only the instance behaviours, silently skipping every behaviour that needed a resolved capability — an authorization, tenant, or audit behaviour. Queue and scheduler share the shape through the poll loop and the job timer. The gate covers imperative registrations too, including a subscription a LATER plugin makes through the resolved capability in its own register(), which no amount of deferral inside these plugins could reach. It is released once and costs nothing thereafter, and no registration’s timing changes. The WebSocket arm needs no gate: a frame cannot arrive before its socket is open, which is after onInit.

  • @setu-ts/kernel: onClose hooks now ALL run even when an earlier one rejects, with the failures surfaced afterwards (a lone failure rethrown as itself, several as an AggregateError). A close hook releases one plugin’s resources, and aborting the loop at the first failure meant one plugin that could not disconnect kept every later plugin from releasing anything — the M50 onStopping defect in a second place, reachable from stop() and, since this release, from a failed start(). LifecycleManager.runShutdown still aborts at the first failing hook and is deliberately unchanged here: it has no call site this milestone adds.

  • QueuePlugin({ processors }) registers in declared order when the array contains a factory. process() is last-wins on a job name, and registering every instance before every factory made [factoryA, instanceB] on one name resolve to factoryA — the reverse of the declared array, so a processor the developer had replaced kept running the jobs.

  • Ingress behaviour promise and ordering guarantees. next() now always returns a promise, including when a downstream behaviour throws synchronously, so an outer behaviour can recover with next().catch(...). Queue, scheduler, messaging, and WebSocket plugins also now run mixed instance and factory entries in the exact configured order. A failed factory resolution closes resources registered before startup failed.

0.2.0 — 2026-09-02

The version scheme drops the alpha label, and four NoSQL backends land. Every release up to v0.1.0-alpha.10 carried a prerelease suffix. That suffix cost real ergonomics — JSR never tags a prerelease as latest, so every install line in every document had to be pinned by hand — while communicating nothing that 0.x does not already communicate on its own. Under semver, major version zero is the “anything may change” signal, and it is the one consumers’ tooling already understands.

So the label is gone and the promise is unchanged. The public API is still not stable, breaking changes still ship in minor bumps, and 1.0 remains gated on Milestone 40 — benchmarks, a security audit, and the Node/Bun compatibility suites as release gates. What changes is that a caret range now works the way readers expect: ^0.2.0 resolves, absorbs patches, and stops before the next breaking release. See Versioning.

This release is not a no-op. It carries every change listed below, including breaking ones — read the Changed section before upgrading.

Added

  • Cloud Bigtable backend. @setu-ts/database-plugin gains a type: 'bigtable' arm serving Google Cloud Bigtable over npm:@google-cloud/bigtable@^6, with BigtableAdapter exported for the 'custom' arm — the wide-column half of the gap the document-store backends only half closed. Bigtable inverts the DynamoDB problem: its row key is one lexicographically-sorted string, so findById needs no key object, while there is no secondary index of any kind. The per-entity tables mapping composes that row key from several logical fields (rowKey: { fields, separator?, prefix? }), names each field’s family:qualifier, and chooses a value encoding — 'tagged' by default, so a number, boolean, null, Date or object round-trips as itself, with an untagged cell decoding as its raw string so a table written outside the framework stays readable, and 'raw' for one that is entirely foreign. Key fields are written as cells AND recovered from the row key, with the cells winning: a Bigtable row cannot exist with zero cells, and the row key records no type.

    Exactly three narrowings reach the server — the row set (an eq on the whole key is an exact key, an eq pinning a prefix is a prefix range, a pinned prefix plus an in on the final field is a key list), byte-exact value equality for each conjunctive non-key eq, and the column projection. Everything else is evaluated by the same evaluator the memory adapter uses as the portable reference, and a push-down may only ever match a superset of what that evaluator keeps. A value test is an exact byte RANGE and never the SDK’s string form, which is a regex — measured, { value: 'a.*b' } matched both a.*b and axxb — and it is wrapped in a condition filter, because a bare chain strips every non-matching cell and the row would come back carrying only the cell that matched. The projection is interleaved with a one-cell arm for the same class of reason: a filter that removes every cell removes the ROW, so a bare projection would silently drop a row carrying none of the projected columns. Row keys are compared as UTF-8 bytes — that is, by CODE POINT — because JavaScript’s < compares UTF-16 code units and the two disagree for every non-BMP character, which dropped an emoji-keyed row from a cursor walk. orderBy is honoured only as the full key ascending, or empty; a non-key sort, a strict key prefix and descending are refused by name, the last because the emulator this adapter is tested against silently ignores reversed: true (measured: it answered ascending with no error), so the path could not be verified. A non-zero offset is refused too — Bigtable has none — and rawQuery is refused because Bigtable’s data plane is ReadRows, MutateRow and CheckAndMutateRow with no SQL surface behind query(sql, params).

    Pagination is a start-key cursor over the portable keyset codec, which is Bigtable’s own continuation mechanism; nextCursor is non-null iff the page is non-terminal, so a page bounded by maxPageFetches returns zero rows AND a cursor. Transactions are one row, because that is Bigtable’s only atomicity unit: writes are buffered, a second row key is refused at that write with the new BigtableTransactionScopeError, and commit sends one CheckAndMutateRow whose mutation list applies atomically and in order. create and update are conditional writes rather than blind ones — Bigtable’s insert is an upsert — so the match flag is what makes create refuse an existing row and update refuse an absent one, inside a transaction as well.

    connect() issues no RPC: a missing table already answers 5 NOT_FOUND quoting the resource path, while getTables() is a table-admin call a data-plane account commonly cannot make, so probing would refuse a working configuration; configuration is validated at construction instead. The arm is verified against the real cbtemulator in a suite guarded on BIGTABLE_EMULATOR_ENDPOINT, and unlike the Cosmos one that suite is run by CI. No common change and no new capability token: a composed row key shares no type with DynamoDB’s partition/sort key pair.

    A key field’s type is not part of the row key — the key is bytes and a numeric field renders as its decimal text, so 1 and '1' are one physical row and findById('1') answers the row stored under 1, whose id cell still decodes as the number. Tagging the key would make it unreadable in cbt and break every table this adapter did not write, so the mapping does not: choose one type per key field, and zero-pad a numeric one whose lexicographic order matters.

  • Azure Cosmos DB backend. @setu-ts/database-plugin gains a type: 'cosmos' arm serving Cosmos DB’s NoSQL (SQL) API over npm:@azure/cosmos@^4, with CosmosAdapter exported for the 'custom' arm. Every NormalizedQuery member is translated natively; nothing is filtered, sorted or paginated in JavaScript. The per-entity containers mapping names the container, the primary key and the partition key, and an omitted partition key is DISCOVERED from the container definition — a wrong one answers 404 rather than an error, so a declared path is validated against the container and refused by name when the two disagree. Pagination uses the framework’s portable keyset cursor rather than a Cosmos continuation token: measured against the emulator, an ORDER BY query returns none even when maxItemCount is supplied as a query option, and paging by a stable sort is this adapter’s design choice rather than a claim about every Cosmos deployment; on a real account each paged container therefore needs a composite index over (sort field, id). Transactions are a deferred batch, atomic within one container and one partition-key value and capped at 100 operations, with CosmosTransactionScopeError raised at the write that crosses a bound; a buffered update is sent as a patch while its payload fits one patch request, so two such updates of one row compose, while a wider update falls back to a whole-document replace and a second write to that row is then refused; and rollback() is idempotent so a failed commit still reports its own per-operation diagnostic. rawQuery is refused by name, because a Cosmos query is scoped to a container the signature cannot name.

    Cosmos DB’s MongoDB API needs no new arm: it speaks the MongoDB wire protocol, so the existing 'mongodb' arm serves it. That route is documented and unverified against a live account, because the emulator’s MongoDB endpoint tops out at API version 4.0 (wire version 7) while the npm:mongodb@^6 driver this package pins requires wire version 8 — measured, the endpoint handshakes and then refuses on that floor. A live account offers versions above it. The NoSQL arm is verified against the real azure-cosmos-emulator:vnext-preview, in a suite guarded on COSMOS_ENDPOINT; it is not run by CI, whose reason and run instructions are in the package README.

  • @setu-ts/sdk realtime clients. createSseClient consumes authenticated SSE streams with frame parsing, Last-Event-ID resumption, bounded reconnects, and abort teardown; createRealtimeClient filters and replies to the WebSocket heartbeat protocol, re-joins rooms, and reconnects with bounded backoff. setu generate sse and setu generate ws-route provide matching application-local starting points. The generated React hook is emitted only for a project that already configures both React Router and the SDK.

    The generated src/controllers/index.ts barrel now passes the service registry to each generated registrar, so its registerGeneratedRoutes takes an optional second parameter. A project scaffolded before this keeps compiling untouched — generate never rewrites setu.config.ts — but a generated SSE controller resolves CAPABILITIES.SSE from that registry and throws by name until the call is updated to registerGeneratedRoutes(app.router, app.services).

  • @setu-ts/decorator-plugin now exports @Module({ controllers, providers, imports }) and DecoratorPlugin({ modules }). Activated modules flatten imported modules depth-first, register providers before controllers, deduplicate diamonds and cycles by class identity, and warn when a supplied class lacks module metadata. A service with required constructor parameters now refuses startup unless it declares the explicit @Inject(...) tokens the runtime needs.

  • The default rest template now includes a functional greeting controller and service registered through the same seam barrels that setu generate maintains.

  • Keyset cursor pagination. IDataSource.findPage?(query) returns { rows, nextCursor }, and NormalizedQuery.cursor carries the incoming position. The member is optional so an out-of-repo adapter keeps compiling; when it is absent the repository refuses by name, so absence means “cannot page by cursor” and never “there are no more rows”. Implemented on all five shipped adapters — Memory, Prisma, Drizzle, Mongo and D1. offset is untouched and not deprecated; a query carrying both a non-zero offset and a cursor is refused, because the two express contradictory positions. Note that the repository-facing IRepository.findPage added in this release is required — see the Changed entry below it.

  • @setu-ts/common gains EntityKey, PageResult, CursorPayload, CursorValue, and the pure encodeCursor/decodeCursor/keysetPredicate/sortFingerprint/mintNextCursor codec. The codec lives in common because cloudflare-plugin needs the identical encoding and a plugin may not import another plugin (AI_GUIDELINES §2.2) — one copy, not two.

  • The primary-key tiebreaker is a correctness requirement, not a refinement. Measured against live PostgreSQL and MongoDB: over six rows carrying only two distinct sort values, a keyset walk omitting the key tiebreaker returned four of six and reported success. keysetPredicate always appends the resolved key columns, and the negative control is committed as a test.

  • Composite primary keys per adapter: D1EntityMapping.primaryKey and MongoEntityMapping.primaryKey accept a column list, MongoEntityMapping.idType gains 'compound' (a subdocument _id, built in the mapping’s declared column order — a Mongo subdocument _id is matched by exact, order-sensitive equality, so the caller’s key-object property order must never reach the driver), and PrismaAdapterOptions.entities / DrizzleAdapterOptions.entities carry per-entity key configuration. Prisma’s compound-key field name is derived by joining the key columns with _; a model declaring a named @@id must set compositeKeyName, because Prisma rejects the derived name on such a model.

  • UnsupportedQueryFeatureError in @setu-ts/database-plugin, for a query feature the active adapter cannot serve. cloudflare-plugin continues to refuse with CloudflareUnsupportedError; no error class is added to common, which exports none.

  • @setu-ts/openapi-plugin exports SchemaIo and ZodToOpenApi.transform takes it as an optional second argument (transform(schema, io?), defaulting to 'output'), so an existing single-argument call is unchanged.

  • Added a native MongoDB backend to @setu-ts/database-plugin. DatabasePlugin({ type: 'mongodb', options: { url } }) now provides repositories over the lazy-loaded MongoDB driver, including _id mapping, ObjectId-aware injected clients, query translation, and session transactions. MongoAdapterOptions is a union of two arms, so a registration supplying neither url nor client is a compile error rather than a connect() throw; MongoAdapterOptionsBase names the half both arms share. The 'mongodb' member of the published PrismaSqlProvider union is retained but documented as unreachable on Prisma v7 — the adapter arm is the supported MongoDB route. Identity round-trips: an ObjectId is rendered as its 24-hex string while a JSON scalar keeps its own type, primaryKey may name _id itself, and the primary key never travels in an update payload (MongoDB refuses a $set that would change _id). An empty filter group compiles to its boolean identity, matching Memory and Drizzle, because MongoDB refuses $and: []/$or: []. contains is case-sensitive and cannot be made otherwise — MongoDB does not apply collation to $regex. The arm accepts the shared logQueries option like every other built-in adapter.

  • Added a native DynamoDB backend to @setu-ts/database-plugin. DatabasePlugin({ type: 'dynamodb', options }) now provides repositories over AWS SDK v3, including partition-and-sort-key mapping, GSI access-path selection, query translation, and deferred transactions. DynamoAdapterOptions is a union of two arms — an injected client or a lazy endpoint/region/credentials import (import('npm:@aws-sdk/client-dynamodb@^3')) — so a registration supplying neither is a compile error rather than a lazy-import throw. create carries attribute_not_exists(<pk>) because an unguarded PutItem on an existing key silently overwrites the item and drops every attribute absent from the new one; update carries attribute_exists(<pk>) + ReturnValues: 'ALL_NEW' because an unguarded UpdateItem on a missing key silently creates a ghost item and returns it as though it were an update; both guard failures surface as rejections naming the entity. Non-key orderBy and non-zero offset are refused by name with UnsupportedQueryFeatureError — the SDK silently accepts and discards both, so the refusal is a correctness requirement. findPage’s nextCursor is non-null iff the page is non-terminal per LastEvaluatedKey — including a zero-row non-terminal page — and a GSI cursor carries all four table+index key attributes; the fill loop is bounded by maxPageFetches (default 10), and a bounded return always carries a cursor so it is never mistaken for the last page. dateAttributes declares the per-attribute Date encoding ('iso' | 'epochMs'); a Date comparison against an undeclared attribute is refused by name. Transactions buffer writes and flush one de-duplicated TransactWriteItems at commit (≤100 items; a duplicate item key and an over-limit batch are rejected by name); rollback sends nothing.

  • Added the check:docs executable prose-assertion gate. Marked Markdown tables are evaluated in a permission-denied Deno subprocess, including .roo rules, so false language-semantics claims fail CI instead of remaining unchecked prose.

Changed

  • The version scheme drops the prerelease suffix: 0.1.0-alpha.10 → 0.2.0. No code changes with it. Two consequences for consumers: a bare deno add jsr:@setu-ts/kernel now resolves, because JSR points latest at a normal release; and a ^0.2.0 range behaves conventionally, where ^0.1.0 never matched any 0.1.0-alpha.N at all. An application pinning an exact alpha version keeps working and is unaffected until it chooses to move.

  • Breaking (generated class-based modules): setu generate module now emits <name>.module.ts; the managed aggregate barrel exports MODULES and config passes it through DecoratorPlugin({ modules }) instead of exporting controller/service arrays. Existing generated module directories are reported when their declaration is missing, rather than silently omitted. The regenerated barrel retains deprecated controller/service arrays so an existing config keeps compiling while it migrates. Add @Module({ controllers: [<Name>Controller], providers: [<Name>Service] }) to each legacy directory (or delete and regenerate it), then update setu.config.ts to import MODULES and pass modules: [...MODULES] to DecoratorPlugin.

Breaking, portable data-access contract (M79). Three widenings to @setu-ts/common. All three are source-compatible for callers and breaking for implementors, because each widens a type in a position an implementor must satisfy.

Three widenings to @setu-ts/common’s data-access contract. All three are source-compatible for callers and breaking for implementors, because each widens a type in a position an implementor must satisfy. No application that only calls repositories needs a change; an application that implements IDataSource or declares a custom repository key type does.

  • IDataSource.findById/update/delete take EntityKey, not string | number. The new arm is a composite record (Readonly<Record<string, string | number>>), so a join table or a tenant-scoped table can finally be addressed through a repository. A parameter is contravariant, so an out-of-repo adapter still declaring the scalar-only form is a compile error.

    // Before — still valid, nothing to change for a scalar key.
    findById(id: string | number): Promise<Record<string, unknown> | null>;
    // After — an implementor must widen the parameter.
    findById(id: EntityKey): Promise<Record<string, unknown> | null>;
  • FilterComparison.field accepts string | readonly string[]. An array is a nested document path (['address', 'city']); a plain string is unchanged. Any code that READS filter.field and assumes string must handle both. A dotted string is deliberately NOT a path — a column whose name contains a dot keeps its meaning.

  • The ordered-comparison arm (gt/gte/lt/lte) accepts Date. Required for keyset pagination over a timestamp column, and it makes a portable date-range filter expressible for the first time. IRepository’s key type parameter is now constrained to EntityKey, which is breaking only for a declaration that was never supported at runtime.

  • IRepository gains a required findPage member (M79, keyset cursor pagination). The IDataSource.findPage?(query) the Added entry above describes is optional — an out-of-repo adapter keeps compiling and the repository refuses by name when it is absent. That is a different type: the repository-facing IRepository.findPage(options): Promise<Page<Entity>> is required, so a class implementing IRepository directly — without extending BaseRepository — must now implement findPage, which is a compile error until it does. This is the same class of change as the required IRepository.findOne member announced in the 0.1.0-alpha.8 release, and it is the member a reader searching the CHANGELOG for findPage most needs to find: the sibling findOne addition was announced twice, and this one was announced nowhere. The in-repo tripwire is packages/database-plugin/test/fixtures/repository-implementor.ts, a hand-written implementor that fails deno check if a required member is added to IRepository without updating it. See docs/upgrading.md for the reader-side step.

Fixed

  • The Prisma test fixture evaluated NOT as its inverse. matchesPrismaWhere dispatched AND and OR but not NOT, so { NOT: { status: 'archived' } } fell through to the compound-key branch and MATCHED the rows it was asked to exclude — on findMany, update, delete and count alike. The array form’s semantics were established by measurement against real Prisma 7.10 on live PostgreSQL rather than read off the grammar: NOT: [A, B] returns only the row matching NEITHER, so the array negates each condition and ANDs them (NOT A AND NOT B) rather than negating their conjunction, which would have returned three of four rows. Fixture-only; no src behaviour changes.

  • A memory-adapter transaction did not read back its own mutations to rows it had created. effectiveRecords applied shadows and tombstones while mapping the COMMITTED rows and then appended buffered creates untouched — so a row created and then updated in the same transaction read back with its original values while update() had just returned the new ones, and a created row that was then deleted stayed visible although delete() reported true. Both committed correctly, so the divergence was confined to reads inside the transaction.

  • decodeCursor accepted two further malformed tokens. The scan stopped at the first = and never inspected what followed, so token + '=$' decoded to the original payload; and validation ran after -/_ were normalised to +//, where a decoded character is indistinguishable from one the caller supplied, so the standard-base64 spelling of a token was accepted as if it were base64url. Validation now runs once, on the raw token, against the base64url alphabet, with padding accepted only as a trailing run of at most two =.

  • findPage on the memory adapter ignored the caller’s where. It applied the cursor predicate and filter but not where, while findAll applied all three — so a page scoped by tenant, owner or role returned rows outside the caller’s own criteria. Reproduced on the public surface: findPage({ where: { role: 'admin' } }) over three rows returned all three. It returns MORE rows than asked for, so no assertion on a row count alone would necessarily have caught it.

  • A memory-adapter transaction could hide rows its delete never targeted. The overlay key encoded a scalar as entity::value and a composite as col=value joined with |, and both forms collide: string '42' and number 42 produced the same key although record lookup compares with ===, and { a: 'x|b=y', b: 'z' } produced the same key as { a: 'x', b: 'y|b=z' }. Deleting one of a colliding pair made BOTH invisible inside the transaction and then committed a delete for only one, so an in-transaction read reported a state that never existed. The key is now type-tagged and length-prefixed — self-delimiting, so no value can forge a component boundary — and canonicalized by the mapping’s declared column order, which is what record lookup uses. The JSDoc claiming the delimiter “cannot appear inside the column values we accept” was simply false for a string.

  • encodeCursor did not validate on its public path. mintNextCursor guards its inputs, but encodeCursor is exported and reaches the value encoder directly, so an adapter or application building a cursor by hand bypassed the guard: a non-finite number serialized as null and was refused only on the NEXT request’s decode, and an invalid Date threw a bare RangeError: Invalid time value naming neither the cursor nor the value.

  • decodeCursor accepted a token with junk appended. The base64url decoder SKIPPED any character outside the alphabet, so token + '$' decoded to the same payload as token — two different strings decoding to one payload, in a function whose input is untrusted. Non-alphabet characters are now refused; trailing = padding is still tolerated, since it is padding rather than data.

  • A --transport grpc workspace can now add a full-stack member without disabling form CSRF. The CLI mounts GrpcPlugin at /grpc, preserves that direct contribution by registering it after createFullStackAppFromConfig() creates the application and before startup, and emits a form-CSRF exclusion for that protocol prefix only. CsrfFormOptions.exclude also lets an application explicitly exempt exact paths or regular expressions for another separately-mounted non-browser protocol surface. Broker transports remain refused because they need to rewrite the starter-owned messaging configuration, which cannot be expressed as a safe appended plugin.

  • Prisma’s findById and update threw synchronously on a malformed composite key, bypassing a caller using .catch(), while delete on the same object rejected correctly. All three now reject.

  • A multi-column orderBy failed outright on Prisma 7, and had since the connector’s findMany path was written. The adapter emitted one multi-key object ({ a: 'asc', b: 'desc' }), which Prisma 7 rejects with Argument 'orderBy': Invalid value provided. Expected …OrderByWithRelationInput[], provided Object — measured against 7.10 on live PostgreSQL, where a single-key object is accepted and an array of any length is accepted and honours element order as sort precedence. It now always emits an ARRAY of single-key objects, so no sort arity is a special case. Latent until this release, because a two-column sort was the first thing the framework itself produced: the keyset cursor walk appends the primary key as its tiebreaker.

  • SqlJsonDialect is exported from @setu-ts/database-plugin. DrizzleAdapterOptions.dialect is typed by it, so without the re-export the option’s own type was unnameable by a consumer — the defect M52c found on NormalizedQuery reaching a published DataSource signature.

  • A nested JSON path filter now translates on Drizzle rather than being refused, per dialect — PostgreSQL #>> against a text-array path, MySQL JSON_UNQUOTE(JSON_EXTRACT(…)), SQLite json_extract — with the dialect read off the Drizzle instance and a new dialect option for when detection cannot name it. Extraction normalises to text on every dialect and casts back to numeric for an ordered comparison against a number, so age > 9 matches 30 instead of comparing '30' > '9' as text; in expands to an OR of equality legs, since no dialect offers a path membership operator. On Prisma the operator names were established the same way: contains maps to string_contains (raw contains is rejected on a path) and in has no path form at all.

  • A Mongo query naming the primary key discarded every other member of where and orderBy. The scalar-key branch REPLACED the whole object with { _id: … } rather than renaming the column in place, so { id, status } silently dropped status and matched rows it was never asked for, and a sort of { score: 'asc', id: 'asc' } collapsed to { _id: 'asc' }. The sort half was reached on EVERY scalar-key findPage, because the keyset tiebreaker always appends the key column — so a page was ordered by a column the caller did not ask for and the keyset predicate did not agree with. The rename is now in place.

  • Prisma’s scalar key path addressed a hardcoded id, ignoring a configured single-column keyColumns, so a lookup silently queried the wrong column. It now addresses the resolved column; the default ['id'] keeps the pre-M79 shape byte-identical.

  • Prisma’s composite-key refusals named findById regardless of the method that failed.

  • Mongo could write a partial compound _id when a key column was absent from the row, storing a document that no findById could retrieve, since the read path requires every column.

  • @setu-ts/openapi-plugin documented a zod v4 REQUEST body as the shape the server holds after parsing, so a document could contradict the application serving it. ZodToOpenApi converted every schema with io: 'output'. A field carrying .default('free') was therefore listed in the request body’s required while the server accepted a body omitting it and supplied the default — reproduced in one running app, 201 from the route and required: ["name","age","plan"] from its own /openapi.json — so a generated client took a required argument for a field the API defaults. A .transform() field documented as {}, silently, because a transform’s OUTPUT has no JSON Schema representation. And additionalProperties: false was emitted for a plain z.object, which strips an unknown key and answers 2xx rather than rejecting it.

    Every request-side position — body, params, query, headers — is now converted from the input side; response keeps the output side. A z.strictObject keeps additionalProperties: false under either view, so nothing a client may send is widened. This was a consequence of the alpha.10 zod-v4 fix rather than a regression in it: before that, a v4 schema transformed to {}, so there was no required list to be wrong. Zod v3 documents are unchanged — that path has no io concept and already emitted the input view, which is what makes the two majors agree on a request body now.

    Two visible consequences, both by design. A request body built from a plain z.object no longer carries additionalProperties: false. And a schema whose two views differ and that is used on BOTH sides is hoisted into one component per side rather than one shared component; a schema whose views are identical — every zod v3 schema, and any v4 schema with no default, transform, coercion or object-mode difference — still yields exactly one, so most documents are unchanged. A schema registered with addSchema('Name', …) keeps that name on the output side and gains a NameInput twin when a request site reaches it — identified by schema rather than by name, so registering an unrelated AddressInput alongside Address cannot capture it.

0.1.0-alpha.10 — 2026-08-28

The decorator surface moves to the TC39 standard, and realtime grows up. Every shipped decorator was the legacy TypeScript form, which requires the experimentalDecorators compiler option Deno now deprecates and warns on. Removing that option would not merely untype the parameter decorators — the Stage 3 proposal has no parameter position at all, so they would stop parsing. The surface is therefore migrated deliberately now rather than under time pressure later: parameter injection becomes positional @Params(...), constructor injection collapses onto the class-position @Inject(...) list, and no compiler option is required anywhere any more. This is the one change in this release an application must act on, and the migrations are worked through below.

The realtime story closes the two gaps a browser actually hits. A browser can send exactly one credential over an EventSource request or a WebSocket constructor — a cookie — and neither built-in strategy read one, so the two transports the framework offers for realtime could not authenticate a browser at all. AuthPluginOptions.session now reads the session cookie through a new headers-only session read, and an authenticated upgrade’s principal reaches onOpen. Both realtime registries also gained a non-mutating read: room(name) and channel(name) are get-or-create, so a presence endpoint reporting size for a request-supplied name was registering one entry per distinct name polled.

Traces now cross a broker and nest under the request that caused them. Every first-party messaging broker writes traceparent on publish and reports the transport headers it read, and telemetry spans run with their span active rather than arriving as detached roots — which changes exported trace shape for anyone already exporting.

Nothing else here requires an application change unless it is named Breaking below.

Added

  • Broker trace propagation (M75). @setu-ts/common now provides the W3C trace-context codec and header-name constants. When telemetry is registered, every first-party messaging broker writes traceparent on publish and exposes delivered transport headers; the messaging plugin creates producer/consumer spans around that path. MessagingPlugin({ tracing: false }) opts out.

  • @setu-ts/common: a headers-only session read and the authenticated-principal bridge for WebSocket upgrades. ISessionService.fromHeaders(headers) opens a session from a Headers object alone — the read for non-HTTP entry points (a WebSocket onOpen handler, an auth strategy reading a cookie) that have no request context to commit onto — and returns a new read-only SessionView ({ id, data }) or null. It runs the same envelope-open, snapshot-parse, and store-read path as the load behind from(ctx), so it inherits real revocation on the store strategy, but it never commits and never writes. WebSocketConnectionContext gains an optional user?: IPrincipal — the principal that authenticated the upgrade, omitted when it was anonymous — and IWebSocketService.routeUpgrade gains an optional second parameter (principal?: IPrincipal) that carries it. Both additions are optional members, so existing implementors and consumers compile unchanged.

  • @setu-ts/auth-plugin: a cookie-backed SessionStrategy and a caller-supplied strategy hatch. AuthPluginOptions.session takes a SessionAuthOptions whose single required member, toPrincipal(view), maps the opened SessionView to the principal it carries (returning null continues the chain). When present, the plugin appends an internal SessionStrategy — the strategy that reads the session cookie through ISessionService.fromHeaders — after the API-key strategy, and requires the session capability (SessionPlugin) to be registered, or register() throws naming both plugins. AuthPluginOptions.strategies accepts caller-supplied IAuthStrategys, appended after every built-in in declaration order; a name colliding with any other strategy in the assembled chain throws at register(). The assembled order is fixed — jwt → api-key → session → caller-supplied — and the first non-null principal wins. This closes X3-5: a browser can now authenticate over EventSource and a WebSocket upgrade, the two transports that can send only a cookie.

  • @setu-ts/session-plugin, @setu-ts/kernel, and @setu-ts/websocket-plugin: the bridge wired end to end. SessionService implements fromHeaders; the kernel’s terminal handler passes ctx.request.user to routeUpgrade; and WebSocketService threads that principal into buildContext, so onOpen’s context.user is the authenticated peer. No adapter change is needed: since M70a routeUpgrade is the only live upgrade-routing path on every runtime.

  • Declared dependency-compatibility ranges, and a reproducibility artifact on every release. The Zod-facing packages now state the ranges they are tested against rather than leaving them to be inferred: @setu-ts/validation-plugin, @setu-ts/openapi-plugin and @setu-ts/decorator-plugin support zod v3 (>=3.24.0 <4) and zod v4 (>=4.4.0 <5), and deno task check:compat exercises each declared major independently — no package imports zod, so an application may use either major, or both in one process. @setu-ts/database-plugin records its tested Drizzle baseline (0.45.2) and its Prisma v7 integration as the current claims, with wider ranges pending the work that would make them tested rather than guessed. Separately, every GitHub Release now carries a resolved-set.json asset wrapping the committed deno.lock under the release version — exact transitive versions and integrity hashes, so the tagged framework tree rebuilds against the reviewed resolution — and a weekly Dependency drift workflow re-resolves every workspace range into a fresh lockfile, runs the gates against it, and files one issue naming each committed→fresh change. It never modifies deno.lock and cannot block a pull request or a release.

Changed

  • Telemetry spans now nest. Real OTel withSpan callbacks run with their span active using an async-local context manager (unless contextPropagation: false), so work initiated in a request becomes a child of its request span rather than a detached root. This changes exported trace shape for every application already using exporter: 'otlp' or 'console': spans that arrived as separate roots now arrive as one tree, so dashboards or alerts keyed on root-span counts will see fewer roots and deeper traces. Nothing needs changing to adopt it; set TelemetryPlugin({ contextPropagation: false }) to keep the previous flat shape. Activation needs npm:@opentelemetry/context-async-hooks; when that package is absent, or the runtime supplies no async-context store, the plugin logs one warn and degrades to the previous behaviour rather than failing startup.

  • traceparent parsing is stricter. The codec promoted to @setu-ts/common rejects two inputs the telemetry plugin’s private copy accepted: an UPPERCASE-hex header (the previous regex carried the i flag, while W3C Trace Context defines the value as lowercase hex), and an all-zero trace or span id, which is not a valid parent. Both now yield “no extractable parent”, so a request from a non-conformant upstream starts a new trace instead of continuing an invalid one.

  • MessageMetadata.headers is now populated by every first-party broker. It was already read by the RabbitMQ and Kafka brokers and omitted entirely by the memory, Redis Streams, Pub/Sub and Service Bus brokers; all seven now report the transport headers they read, and {} — not undefined — when the transport carried none. A consumer that branches on the member’s presence changes behaviour: if (metadata.headers) was falsy on those four brokers and is now truthy. Test emptiness instead (Object.keys(metadata.headers ?? {}).length === 0).

  • BREAKING — @setu-ts/decorator-plugin: the decorator surface moves to TC39 standard decorators, and the legacy form is removed. Every shipped decorator was a legacy TypeScript decorator, which requires the experimentalDecorators compiler option. Deno deprecates that option and warns on every check and lint run, and its removal would not merely untype the parameter decorators — it would make them unparseable, because the Stage 3 proposal has no parameter position at all. The surface is therefore migrated deliberately now rather than under time pressure later.

    No compiler option is required any more in the framework’s own declaration sites. experimentalDecorators is removed from all eight declaration sites — the decorator-plugin, openapi-plugin and rest-starter manifests, apps/di-decorators, the guide-snippet fixture, both CLI template stamps, and the generated Node tsconfig.json. Do not add it back — and remove only that key: Deno applies its own defaults to every option a manifest does not specify, so declaring one option leaves the others alone (measured on Deno 2.9.6). A project needing no compiler option at all should declare none. If your project’s own manifest still declares experimentalDecorators — for example a deno.json the alpha.8 CLI emitted — remove that key: a migrated project that keeps it still compiles its decorators under the legacy semantics and fails deno check with TS1238/TS1241 on every decorated member, errors that point at the decorators rather than the option. The full reader-side step is in docs/upgrading.md.

    Parameter decorators become positional sources inside @Params(...). @Body, @Query, @Param, @Header, @Cookie, @CurrentUser and @Ctx keep their names but change kind, from parameter decorator to source descriptor. Each stale call site is a compile error rather than a silent behaviour change, because the returned value is no longer a decorator.

    // Before
    @Get('/:id')
    show(@Param('id') id: string, @Ctx() ctx: IRequestContext) {}
    
    // After — sources listed in argument order
    @Get('/:id')
    @Params(Param('id'), Ctx())
    show(id: string, ctx: IRequestContext) {}

    The declaration is now type-checked against the handler’s own signature, which the legacy form never was: a source whose value type disagrees with the parameter it binds fails deno check.

    createParameterDecorator(name, metadata?) is replaced by Custom(name, metadata?), used inside @Params. Resolvers registered with registerParameterResolver are unchanged.

    // Before
    export const TenantId = () => createParameterDecorator('tenant-id');
    async list(@TenantId() tenantId: unknown) {}
    
    // After
    export const TenantId = () => Custom<string | undefined>('tenant-id');
    @Params(TenantId())
    async list(tenantId: string | undefined) {}

    Constructor injection collapses onto the class-position @Inject(...) list, which already shipped and is no longer deprecated; the parameter position is removed. @Optional() changes kind the same way, becoming Optional(token) used inside that list.

    // Before
    @Injectable()
    class Repo {
      constructor(
        @Inject('database') private db: Db,
        @Optional() @Inject('cache') private cache?: ICacheStore,
      ) {}
    }
    
    // After — one entry per constructor argument, in argument order
    @Injectable()
    @Inject('database', Optional('cache'))
    class Repo {
      constructor(
        private db: Db,
        private cache?: ICacheStore,
      ) {}
    }

    Two startup refusals disappear with the form that caused them, because no input can reach them any more: “declares both @Inject forms” and “parameter N has no @Inject token”. The MetadataStore.mergeCtorParam and MetadataStore.ctorInject methods are removed with them — a class-position list is positional and cannot have gaps, so services.inject is the one place a token list lives. A list shorter than the constructor simply leaves the trailing arguments undefined.

    One IMetadataStore read narrows. A standard member decorator never receives the constructor, so it records onto context.metadata and the store replays those writes when the class is read by target. A carrier holds no reference back to its class, so the target-less reads — the controllers/services/routes getters and getCustomDecorators() — cannot replay: a class carrying member decorators and NO class decorator is absent from them until something reads it by target, where the legacy form recorded it eagerly. Every class the plugin registers carries @Controller or @Injectable, and a class decorator flushes eagerly, so this is confined to a class the plugin never registers either.

    New exports: Params, Custom, Optional (new kind), ParamSource, SourceValues, InjectToken, OptionalToken, and the three decorator-kind types SetuClassDecorator, SetuMethodDecorator, SetuClassOrMethodDecorator.

    Generated projects change shape. setu generate controller and setu generate module emit @Params(...) and the class-position @Inject, and no scaffolded project stamps a decorator compiler option. A project generated before this release keeps working only until it is regenerated; migrate its decorated sources with the transformations above.

    Node is unaffected in requirement but not in reason. A generated Node project still runs through tsx, because V8 has not shipped decorators — measured on Node v24, both node and node --experimental-strip-types answer a standard decorator with SyntaxError: Invalid or unexpected token. Its tsconfig.json no longer sets any decorator option.

  • @setu-ts/common: ISessionService.fromHeaders is a REQUIRED member. Callers are unaffected — the addition is source-compatible for every consumer. But an application that implements ISessionService itself (not the framework’s SessionService) now fails to type-check until it adds fromHeaders(headers: Headers): Promise<SessionView | null>. It is required rather than optional because a strategy in another package must call it without a capability-shaped guard, and the framework’s SessionService is the only in-repo implementor (the M51b IGraphqlService.subscribe precedent). Migration: add the member to your implementation; the contract is read-only — it must open the session from the supplied headers, return null for every condition the load path treats as “no usable session” (absent cookie, unopenable envelope, unparseable snapshot, absolute expiry or idle timeout passed, or a gone store entry), and never commit, advance the seen stamp, or write.

  • A non-mutating read on both realtime registries: IWebSocketService.peek(name) and ISseService.peek(name) (M74, X3-8). room(name) and channel(name) are get-or-create, so a presence or dashboard endpoint reporting size for a name taken from a request registered one entry per distinct name polled — measured at 3 → 53 rooms across 50 read-only requests, with nothing to reclaim them until an unrelated socket disconnected. peek returns the room or channel when one exists and undefined otherwise, registering nothing:

    const present = ws.peek(`board:${boardId}`)?.size ?? 0;

    This is breaking for anyone implementing either service contract outside this repository: peek is a required member, not an optional one, because an optional peek? returning undefined cannot distinguish “no such room” from “this implementation does not offer the read”. Add a lookup that does not create; both first-party services delegate to a single map read. Consumers are unaffected.

  • ISseService.channelCount (M74) — the counterpart to IWebSocketService.roomCount, reported by the sse health indicator as channels. Nothing reclaims a channel, so the value only rises for the life of a running application — a climbing value is the signal that channel names are being derived from unbounded input. Shutdown discards every channel and resets it to zero. Breaking for an out-of-repo implementor of ISseService in the same way peek is: add a getter returning the registry size. The health payload gains a field, which is additive for consumers.

  • JsonValue in @setu-ts/common (M74) — a recursive JSON-safe value type. Its object arm admits undefined deliberately, because JSON.stringify drops such a property rather than failing, so an optional field still assigns.

  • SseMessage.data is narrowed to JsonValue (M74, X3-8). Breaking. The member was string | number | boolean | null | readonly unknown[] | Record<string, unknown>, whose last two arms admitted values JSON.stringify cannot represent — while PUBLIC_API.md claimed the member “accepts any JSON-serializable value”. It now does. What this rejects, and what to do instead:

    • A bigint anywhere in the payload ({ balance: 10n }, [10n]). JSON.stringify throws on it, and the throw surfaced differently depending on configuration: conn.send threw to the caller, channel.publish with no backplane delivered to nobody and reported nothing, and channel.publish with a backplane threw synchronously. Convert the value first — { balance: String(balance) }.
    • A function or symbol value. JSON.stringify silently drops the key, so the data never arrived. Remove it from the payload.
    • An interface that extends Record<string, unknown> to satisfy the old object arm. Change it to extends Record<string, JsonValue | undefined>. A named interface still does not assign without an index signature — TypeScript grants implicit ones only to object-literal types, which was equally true before this change — so a type alias remains the simpler option.

    A property written T | undefined is unaffected. A circular structure still throws at runtime; no type can express acyclicity.

  • @setu-ts/openapi-plugin: an unrepresentable schema node is now REPORTED instead of silently emitting {}. A type zod cannot represent in JSON Schema (z.date(), z.bigint(), …) still degrades to an empty schema — never a throw — but the operation owning it now carries a machine-readable vendor extension naming the route: "x-setu-unrepresentable": [{ "at": "<operationId>", "reason": "…" }]. The extension is absent when every schema is representable, so valid documents are unchanged.

Fixed

  • @setu-ts/messaging-plugin: a transport header whose bytes are not valid UTF-8 is dropped rather than decoded to replacement characters. The normalizer decoded byte values with a lenient TextDecoder, which substitutes U+FFFD for malformed input — so a subscriber received a string the producer never sent, indistinguishable from a real value, while the module’s own contract says a value with no faithful string form is dropped. Decoding is now fatal and a rejected value is omitted. The throw is caught inside the normalizer, which matters on the Kafka path: an escaping throw inside eachMessage prevents the offset commit and the record is redelivered indefinitely. Valid multi-byte UTF-8 is unaffected.

  • @setu-ts/messaging-plugin: MessageMetadata.headers could carry values that were not strings. The member is declared Readonly<Record<string, string>> and is documented as populated by every first-party broker, but RabbitMqBroker and ServiceBusBroker reached it by assertion rather than conversion. An AMQP field table legitimately carries numbers, booleans, timestamps, byte arrays and nested tables, and the Service Bus SDK types application properties number | boolean | string | Date | null, so a subscriber reading metadata.headers.x could get a value of any of those types while the compiler promised a string. Both now normalize at the transport boundary through the same helper Kafka already used: a byte value is decoded as UTF-8, a number or boolean is stringified, a Date becomes ISO-8601, the first element of a repeated header is taken, and a value with no faithful string form is dropped rather than rendered as [object Object]. Kafka’s private copy of that logic was deleted in favour of the shared helper.

  • @setu-ts/messaging-plugin: the Kafka producer put the entire message payload into Kafka transport headers. publish passed the payload object itself as headers whenever it was non-null, so every field of every message was duplicated into the record’s headers — and a non-string field is not a legal IHeaders value (Buffer | string | (Buffer | string)[]), so kafkajs had to coerce it. Only framework-owned headers are sent now. A consumer reading payload fields off metadata.headers was relying on the defect and must read them off the message.

  • @setu-ts/messaging-plugin: metadata.headers on the NATS broker exposed the client’s private internals instead of the message headers. The delivered MsgHdrs was cast to Record<string, string>; probed against real npm:nats@2.x, that yields undefined for every real key and Object.keys of ['_code', '_description', 'headers']. Headers are now read through the object’s own keys()/get(). NatsOptions.headersFactory supplies the MsgHdrs constructor when the connection is injected (a lazily-loaded module provides its own); with neither available the broker publishes without headers and reports it once.

  • Three committed docs still said WebSocket upgrades and gRPC requests bypass the middleware pipeline, which M70a made false. packages/websocket-plugin/README.md claimed “the adapter therefore consults the plugin’s upgrade router first”, and the ARCHITECTURE.md package-diagram notes said the §10 pipeline “is likewise bypassed for upgrade requests, by design” and that RPC is “intercepted inside the HTTP adapter’s fetch path”. Since M70a the kernel’s terminal handler decides both, after the pipeline has run and before route matching; the adapter stores the upgrade router without consulting it, and setRpcHandler is deprecated and consulted by nothing. Each correction now matches the canonical prose in ARCHITECTURE.md §10 and PUBLIC_API.md. Both documents were reachable by a reader deciding whether a guard protects a socket, which is exactly the question M70a exists to answer. Two further copies were found in @setu-ts/common’s own JSDoc, which jsr.io renders: IHttpAdapter.setUpgradeRouter said the handshake happens “when the pipeline does not short-circuit and route matching returns no match” — the pre-review ordering that M70a’s code review inverted, because a catch-all was shadowing every upgrade — and RpcFetchHandler still described the adapter consulting it before mapping the request. ARCHITECTURE.md also claimed grpc-plugin depends on the http-adapter capability; it resolves no adapter at all since M70a. Documentation only — no behaviour, signature or export changed.

  • @setu-ts/openapi-plugin produced an EMPTY OpenAPI schema for every zod v4 schema. Zod v4 removed the private _def.typeName marker the transformer dispatched on, so every v4 schema fell through to {} and /openapi.json served {"schema":{}} for any route documented with one. Zod v4 schemas are now converted through schema.toJSONSchema() (draft 2020-12, adapted to OpenAPI 3.1), with dedup, $ref/components extraction and recursive-schema hoisting working on both majors. Both zod v3 and v4 are supported; the plugin imports neither — detection is by duck-typing toJSONSchema. Zod v3 output is byte-identical.

0.1.0-alpha.9 — 2026-08-26

A security release, and the closeout of the alpha.8 smoke programme. Driving the published packages through twelve exercises — real projects, real brokers, a real cluster — produced a register of verified defects rather than a list of suspicions, and this release closes it. The five that change what an application is exposed to are listed first: no middleware ran before a WebSocket upgrade or a gRPC request, so a guard could not refuse either; a cached response crossed tenants; a session minted under one tenant authenticated a write into another; an unhandled error returned the failing SQL and its bound values to the caller; and feature flags had no tenant dimension at all.

The rest is what the register kept finding once it looked. Capabilities reported up with their backends stopped, so a dead dependency triggered no restart and no rolling-deploy gate. The default branch of an injectable seam was the one line no test ever ran, because every test injects — which is how @setu-ts/grpc-plugin shipped unable to load on Node or Bun, and how the SDK’s own default transport died on a browser’s first request. Generated artifacts compiled and were wired to nothing. Database adapters reported success while doing something other than what their contract said.

Two contracts tightened as a result. The kernel’s application registry seals after bootstrap and a duplicate route is refused rather than silently overwriting — one of the two handlers used to become permanently unreachable with no diagnostic. And @setu-ts/common gained the optional runtime seams (onSignal, onExit) that let a generated entry point stop hard-coding the runtime it was scaffolded for: one main.ts body now serves Deno, Node and Bun byte-identically.

Nothing here requires an application change unless it is named Breaking below.

Security

  • The middleware pipeline now runs before every WebSocket upgrade and gRPC request (M70a). setUpgradeRouter and setRpcHandler were consulted inside the HTTP adapter before the request was mapped and entered the pipeline, so no middleware applied to either: an unauthenticated WebSocket could write through a guarded endpoint, an unauthenticated gRPC client could read and write through one, metrics and security headers were absent on both, and RPC kept answering 200 through a shutdown drain while ordinary paths answered 503. Every inbound request now runs the pipeline first; the handshake or RPC dispatch happens only in the kernel’s terminal handler, after the pipeline declines to short-circuit, and before route matching so an application catch-all cannot shadow either. No application change is required. Note that an accepted upgrade is answered by the runtime’s own 101, which does not carry response headers a middleware wrote on ctx.response; the pipeline still runs, so a guard can refuse, and a refused upgrade is an ordinary HTTP response carrying everything.

  • A tenant is no longer served another tenant’s cached response (M70b, X4-1). cacheMiddleware keyed on method:url alone, and the cache: { prefix: true } isolation the multi-tenancy plugin advertised was a string no package read — so with one route cached, whichever tenant missed first populated the entry and every other tenant was served its body, with X-Cache: HIT. The key now composes a length-prefixed tenant segment, read from the committed ctx.request.tenant, around the default and any custom key function. An application with no tenancy resolves no tenant and keeps byte-identical keys.

  • A session is now bound to the tenant it was minted under (M70b, X4-3). Tenancy resolves at priority 40 and the session loads at 260, and neither consulted the other, so an acme session authenticated a write into globex — CSRF token included, because that token lives in the session that crossed with it. The resolved tenant is now sealed into the session on commit and a mismatch is refused with 403 before the handler runs (SessionPlugin({ tenantBinding }), default true). Inert without tenancy: nothing is sealed, so nothing is compared.

  • An unhandled error no longer returns the failing SQL and its bound parameter values to the client (M70b, X12-3). errorHandler — the middleware every CLI template emits — copied a raw error’s message into the response, so an ordinary unique-constraint violation disclosed the schema, the table, every column, and every bound value to any caller who could reach the route. maskInternalErrors (new, default true) replaces that message with the status title; the logger still receives the real error first. See Changed for the compatibility note.

  • Feature flags can be scoped to tenants (M70b, X4-6). FlagContext.tenantId and FlagDefinition.tenants add a tenant restriction evaluated ahead of every other rule, so a user allowlist cannot cross a tenant boundary. createFlagGuard derives tenantId from ctx.request.tenant. Absent tenants, evaluation is unchanged.

Added

  • setu new --broker <name> and --queue <name> select a standalone project’s message broker and job queue at scaffold time (M72). Values derive from the same transport registry the workspace --transport flag reads; the selected arm rewrites the template’s MessagingPlugin/QueuePlugin wiring to an environment read, adds its connection variable to the generated dotenv pair, and emits a broker-only docker/compose.yaml so the scaffold can complete app.start(). Each flag is refused — never silently ignored — wherever it would be a no-op: Cloudflare Workers, starter-composed templates (full-stack; use --template microservice for a broker), templates registering no matching wiring, unknown or arm-less names, workspaces, and generate app. No existing flag’s behaviour changes; a default scaffold is byte-identical.

  • setu new can ask for the choices it already accepts as flags (M72). At an interactive terminal it prompts for runtime, template, broker and queue (standalone) or runtime and transport (workspace); every prompted value is expressible as a flag, so prompts are never a second configuration surface. Non-interactive by construction in three layers: CliDependencies.ask is optional and programmatic callers pass none; the executable supplies the terminal prompter only behind Deno.stdin.isTerminal(); Deno’s own prompt returns null on a non-terminal. --yes (-y) takes every default and asks nothing. New exports: the Prompter and PromptChoice types; createTerminalPrompter stays internal.

  • Decorated validation schemas can be enforced without hand-wiring middleware (M70n, E1/E2) — see Changed for the breaking default. DecoratorPluginOptions.enforceSchemas controls it, and @Body()/@Query()/@Param() read the VALIDATED value (transforms, defaults and coercions included) via the new validatedStateKey(target) helper exported from @setu-ts/common — the cross-package state-key wire format both plugins must agree on byte-for-byte, replacing two hardcoded literals. Presence-tested with ctx.state.has, so a validated null or 0 is honoured; falls back to the raw source when no validated value exists. @Header/@Cookie deliberately unchanged (case-insensitive header lookup; no cookies schema key).

  • PasswordHasher.verify(stored, secret) throws MalformedPasswordHashError (M70n, X3-9) when stored is not a well-formed pbkdf2$… string, instead of returning false. Both parameters are plain strings, so a reversed call used to fail closed silently — every correct password answered 401 Invalid credentials with nothing logged. Exported for instanceof; a genuinely wrong password still returns false.

  • StoredAuditEntry and AuditQuery exported from the audit-plugin barrel (M70n, X4-7). They are the return and parameter types of every exported storage class’s query member, which were previously unnameable by any consumer (the M52c NormalizedQuery class).

  • SseMessage.data accepts any JSON-serializable value (M70n, X3-6): string | number | boolean | null | readonly unknown[] | Record<string, unknown>. The encoder already handled all of them; the type rejected what the implementation accepted, so named interfaces needed casts.

  • A process-local notice on RealtimeBackplanePlugin (M70n, X3-4). Registering bare defaults to the single-process 'memory' transport AND silenced the consumers’ startup notices — worse than not registering it. The plugin now logs the notice itself at register() naming 'redis'/ 'messaging'; localNotice: false suppresses it.

  • ReactRouterPluginOptions.publicFiles (M70n, X5-5), default true: files Vite copies to the client build root (public/robots.txt, favicon.ico) are served with must-revalidate instead of answering an HTML 404 through the SSR catch-all. false restores prefix-only behaviour.

  • FullStackStarterOptions.static (M70n, X5-9): a gated arm registering StaticPlugin — the browser-serving tier could not compose static files through configuration. Absent, the plugin list is unchanged.

  • The OpenAPI document is derived from validation middleware (M70m, X11-5). A route carrying validateBody(schema) contributed nothing to the document, so the generated client for an API’s only write took no argument and answered 400 against the live server, and every response was typed void. @setu-ts/common gains VALIDATION_METADATA, RouteValidationMetadata, withValidationMetadata and validationMetadataOf — the RouteSecurityMetadata mechanism applied to request shape — every @setu-ts/validation-plugin helper AND IValidationService.middleware(...) brand what they validate, and @setu-ts/openapi-plugin reads the brand to fill requestBody and parameters. On by default; a declared schema field still wins per field, and OpenApiPlugin({ deriveRequestSchemas: false }) turns derivation off. That flag disables derivation ONLY — the owner exclusion, operationId format and schema deduplication changes below are unconditional, so it does not restore the previous document. See Changed for the compatibility note. A cookies brand derives nothing, with cause: RouteSchema has no cookies field and @setu-ts/sdk refuses an in: 'cookie' parameter, so emitting one would turn a working document into a codegen failure.

  • A derived route is documented as answering 400 (M70m). The validation middleware genuinely answers it, and an operation carrying no 4XX is flagged by every strict linter. Description only, no body schema — that shape depends on the validation plugin’s configured errorFormat, which the generator cannot see. A route declaring its own 400 is left alone.

  • OpenApiPlugin({ excludeOwners }) (M70m, X11-8), defaulting to ['health-plugin', 'metrics-plugin']. /health, /live, /ready and /metrics were documented by default, so every generated client shipped the application’s operational surface. Exclusion is by RouteInfo.owner rather than by path because those paths are configuration — a static path list stops working the moment an endpoint is renamed. Pass [] to document them again. See Changed for the compatibility note — a regenerated client loses getHealth and friends.

  • The generated SDK client can be published to JSR (M70m, X11-4). createApi had an inferred return type, which JSR rejects as a slow type — so a consumer had to hand-edit a file whose own header says “Do not edit manually”, or lose .d.ts generation for their whole package. The generator now emits export interface Api { … } and createApi(client): Api, renameable with the new OpenApiCodegenOptions.apiTypeName.

  • Declared error responses are typed in the generated client (M70m, X11-7). An operation declaring a non-2xx response now emits a status-discriminated union and an is<Operation>Error narrowing guard, so a document’s 4xx schemas finally type something. HttpClientError is generic in its body (HttpClientError<TBody = unknown>), which is source-compatible: the bare name still means HttpClientError<unknown>.

  • ZodToOpenApi accepts an optional onSchema hook (M70m), consulted at every node it transforms. Additive — new ZodToOpenApi() is unchanged — and it is what lets the document generator deduplicate a schema nested inside another.

  • IStorage.put takes object metadata (M70k, X8-6). put(path, data, options?) accepts a new PutObjectOptions carrying contentType and metadata. Optional, so every existing two-argument call is unchanged. Without it every stored object was application/octet-stream, so a presigned URL — the entire point of the feature that produces one — downloaded the object instead of rendering it. S3, GCS, Azure Blob and Cloudflare R2 each record both on the object in their own spelling; the memory and local providers accept and do NOT persist them, documented per provider, because neither backend has anything that could read an attribute back.

  • ProcessOptions.onFailed (M70k, X8-4). Invoked once when a job has exhausted its attempts, immediately before it is dead-lettered — the first programmatic notice that work was permanently abandoned. IQueue has no getJob and no dead-letter accessor, so before this the only way to find a dead job was to open a Redis client. A callback that throws or rejects is reported and swallowed; the dead-letter still happens.

  • Queue metrics (M70k, X8-4). With @setu-ts/metrics-plugin registered, the queue publishes queue_jobs_total{name,outcome} where outcome is completed, retried or dead_lettered. Absent the metrics capability no collector is built and behaviour is unchanged.

  • Per-name queue depths in the queue health payload (M70k, X8-4). data.queues reports { ready, processing, dead } per job name for the memory and Redis adapters — the durable view a per-process counter cannot give after a restart. OMITTED, never reported as zeros, on RabbitMQ and SQS: “this adapter cannot tell you” and “there is nothing there” are different answers.

  • QueuePluginOptions.deadLetterTtlMs (M70k, X8-4). Bounds how long a dead-lettered job’s payload is retained (Redis). Without it the jobs hash grows for the lifetime of the deployment; opt-in, because the retention exists for debugging. Configuring it MOVES a dead job’s payload out of queue:<name>:jobs into queue:<name>:dead:jobs, and only that key and the dead set carry the expiry — the live hash holds every queued job’s payload for that name, so expiring it would destroy work that is merely waiting. Retention is a bound, not a deadline: at least the TTL, and at most the TTL past the last dead-letter, because the sweep runs only when one arrives and the shared key carries a single deadline. It errs late deliberately — dropping a payload early would discard the debugging data the option exists to keep. The payload move is ordered before the dead-set insert so a concurrent dead-letter’s sweep cannot delete a member whose payload has not been written and strand it beyond every later sweep.

  • UploadMiddlewareOptions.maxBodyBytes (M70k, X8-3). An explicit ceiling on the request body the upload middleware will parse, default 50 MB.

  • IWorkerHandle.onExit? and IWorkerHost.reportsExit? (M70k, X8-7). An optional worker-exit signal, implemented over node:worker_threads’ 'exit' on Node and Bun’s non-standard 'close', and omitted on Deno, whose web Worker emits nothing at all when a worker ends itself. Absence means “this runtime cannot tell me a worker died”, never “no worker has died”.

  • WorkerExitError (M70k, X8-7), exported from @setu-ts/worker-pool-plugin for instanceof handling. Distinct from WorkerTaskError, which carries an error the worker managed to report.

  • QueueDepths is exported from @setu-ts/queue-plugin: it appears in the public signature of MemoryQueue.depths and RedisQueue.depths, and a type a consumer can see but cannot name is a defect (the M52c NormalizedQuery class).

  • Per-adapter option arms on DatabasePluginOptions (M70j, D7). MemoryDatabaseOptions, PrismaDatabaseOptions and DrizzleDatabaseOptions join the exported CustomDatabaseOptions, and BuiltInDatabaseOptions becomes the union of the first three, keeping its published name. PrismaAdapterOptions and DrizzleAdapterOptions narrow DatabaseAdapterOptions for their arm. See Changed for the compile-time requirement this introduces.

  • A request-scoped error responder seam in @setu-ts/common (M70f). respondWithError(ctx, { status, title, detail?, details? }) and the IErrorResponder / ErrorResponseInit / ERROR_RESPONDER_STATE_KEY types let a package that produces an error response — but may not import @setu-ts/exceptions, where every formatter lives — answer in the application’s configured format. errorHandler publishes a responder (built from the formatter and content type it already resolved at factory time) into ctx.state before next(); every short-circuiting site in the kernel and in the storage, multi-tenancy, session, auth, http-security and feature-flags middleware now delegates to it, so one errorHandler({ format: 'rfc9457' }) governs every error body those sites produce. validation-plugin is deliberately not part of the seam — it owns its own errorFormat option and formats validation failures itself — so an application configures the two together; the CLI templates and rest-starter now do (C3). @setu-ts/common also gains serializeError / SerializedError, a pure serializer that turns any thrown value into a plain object with a bounded cause chain.

  • INotifier.sendSettled? and ChannelSendResult in @setu-ts/common (M70f, X8-12). An optional, non-throwing twin of send that reports one { channel, ok } result per requested channel (a failure carrying its serialized error), so a caller behind a retrying queue can retry the one failing channel instead of re-sending the whole notification. NotificationService implements it; send’s AggregateError members now each name their channel ("channel '<name>' failed"), the original error riding on cause.

  • Reachability-aware health signals for six infrastructure plugins (M70c). messaging-plugin, realtime-backplane-plugin, storage-plugin, mail-plugin, queue-plugin, and service-discovery-plugin now report a backend’s reachability, not just its lifecycle. Each provider/broker/adapter gains an optional isHealthy() probe (a PING, a HEAD, a GetQueueAttributes, Consul’s /v1/status/leader, a Kubernetes limit=1 EndpointSlice LIST), and each plugin’s indicator maps the two signals — isReady() (lifecycle) and isHealthy() (reachability) — to a status. A configured backend that is down is now down (or degraded for the backplane, whose local delivery still works) instead of up. An unprobeable provider honestly reports data.reachable: 'unknown' rather than claiming health.

  • createCachedProbe(options) and CachedProbeOptions in @setu-ts/common (M70c). A small pure helper that wraps a reachability check in a cache so a health scrape does not become load against the backend: the outcome is cached for ttlMs (default 5000) measured on an injected monotonic clock, concurrent callers during one in-flight probe share a single probe call, and each probe is bounded by timeoutMs (default 2000) on an injected timer seam (setTimer/clearTimer, defaulting to the ambient ones) so a custom IRuntimeServices’ timers are honoured rather than bypassed. A probe that rejects, throws, or exceeds its timeout resolves false — the returned function never rejects. Reporting unprobeable is the caller’s job, not the helper’s: a port that implements no probe is what each plugin’s indicator surfaces as data.reachable: 'unknown'.

  • ReconnectSupervisor in messaging-plugin (M70c). A drive-mode broker (RabbitMQ — amqplib has no reconnection of its own) now re-establishes its connection on loss through a backoff-driven supervisor instead of failing open, re-asserting the exchange and replaying every active subscription. Retries are unbounded by design, with full-jitter exponential backoff capped at 30s: an outage longer than any attempt cap is exactly the case that must still self-repair, and the health signal reports the fault throughout. A single connection loss opens exactly one attempt loop even when the client reports it through more than one event (amqplib emits 'error' and then 'close'). The broker exposes a reachability() that distinguishes “connected and answering” from “connected but the backend stopped answering”.

  • docs/health-indicators.md and test/health-indicator-audit.test.ts (M70c). A classification of every ctx.health.register site in the framework — live-state, justified-literal, or configuration-literal — enforced by a test that fails if a new (or moved) indicator is added unclassified. Five out-of-scope configuration-literal sites that hide a real backend are recorded in smoke/DEFECTS.md (H-70c-1…H-70c-5).

  • Worker pool metrics (M45b). With @setu-ts/metrics-plugin registered, @setu-ts/worker-pool-plugin now publishes six Prometheus series, all labelled task_module: gauges worker_pool_workers, worker_pool_busy_workers, worker_pool_queued_tasks, and counters worker_pool_tasks_completed_total, worker_pool_tasks_failed_total and worker_pool_tasks_rejected_total (the last two also labelled reason). Pool saturation was previously answerable only by polling /health and diffing counts by hand. Nothing is configured and nothing changes without the metrics plugin — the instruments exist exactly when CAPABILITIES.METRICS does, there is no new plugin option, and no export was added. The gauges are written from the same snapshot the worker-pool health indicator reads, so the two surfaces cannot disagree, and no polling interval is armed. ..._failed_total summed over reason equals the health payload’s failed; ..._rejected_total covers refusals that never became tasks (queue_full/pool_closed/unavailable), which the health payload cannot see at all.

  • setu add <plugin> — installs a framework package into the current project, pinned to the version of the CLI that added it, and updates deno.json and package.json when a project carries both. Accepts a short name, the bare package name, or the full specifier (setu add auth, setu add auth-plugin, setu add @setu-ts/auth-plugin). It writes the manifest and reports deno install --min-dep-age 0 rather than running it — on release day the pin is younger than Deno’s dependency-age policy, so the flags need to be visible. Both places that used to say “install @setu-ts/auth-plugin” now name this command.

  • IRuntimeServices.onSignal?(signal, handler) and the RuntimeSignal type in @setu-ts/common, implemented by the Node, Deno and Bun runtime adapters. Optional on the established fs? / workers? / dns? precedent: it is omitted on Cloudflare Workers (an isolate is evicted, never signalled) and on Deno for Windows (addSignalListener('SIGTERM') throws there), so a caller reads it with ?. and gets a correct no-op rather than a crash.

  • A generated project’s main.ts now installs a graceful-shutdown handler through that seam, and one body serves Deno, Node and Bun byte-identically. It reaches no runtime-specific API at all — no Deno.addSignalListener, no Deno.exit, no process.on, no Deno.build.os check — so moving a project between runtimes no longer means rewriting its entry point.

  • setu generate migration now emits a managed src/migrations/index.ts listing every migration in filename order and a project-local src/migrations/run.ts that applies them, run with deno run -A src/migrations/run.ts (add --down to reverse). The migration it wrote was previously an orphan that nothing imported and nothing could run.

  • Every template emits a test task, so the *.service.test.ts that setu generate module writes is reachable at all — and on each target it emits a harness that target can actually execute. @std/testing/bdd reaches Deno.test internally, so the test it used to emit everywhere died on Bun with ReferenceError: Deno is not defined before a single assertion ran. Bun and Node now get bun:test and node:test, which are built in, so those two targets also stop declaring @std/testing/@std/expect — two dependencies that could only fail there.

  • setu generate detects the target runtime from the project’s own manifests instead of assuming Deno whenever --runtime is absent, which nobody passes. setu new svc --runtime bun records the choice once and every later generate now honours it.

  • IRequest.raw?: Request and IRequestContext.raw?: Request — the undisturbed web Request, attached by the HTTP adapter beside the mapped request whose body has been buffered. Optional, so existing implementors are unaffected; the kernel treats an absent raw as neither an upgrade nor RPC and falls through to the 404.

  • UPGRADE_INTENT, WebSocketUpgradeIntent, setUpgradeIntent, upgradeIntentOf in common — how the kernel tells the adapter to perform a handshake it has already authorized.

  • isWebSocketUpgradeRequest in common — the shared RFC 6455 §4.2.1 predicate. @setu-ts/runtime re-exports it rather than keeping a second copy, so its published surface is unchanged.

  • IWebSocketService.routeUpgrade? and IGrpcService.claims? — both optional, so existing implementors keep compiling. claims is the path guard that keeps an ordinary 404 intact.

  • inject() now populates IRequest.raw, so an injected request can exercise the upgrade and gRPC paths instead of silently falling through to the 404.

  • CacheMiddlewareOptions.vary — a per-request discriminator whose values are length-prefixed into the cache key after the tenant segment, for any dimension beyond the tenant.

  • MultiTenancyPluginOptions.exclude — paths that skip tenant resolution entirely, defaulting to the operational probes the framework’s own plugins serve (/live, /ready, /health, /metrics, /openapi.json, /docs). Pass [] to exempt nothing. See Changed.

  • SessionPluginOptions.tenantBinding (default true) — seals the resolved tenant into the session and refuses a mismatch with 403.

  • ErrorHandlerOptions.maskInternalErrors (default true) — see Security and Changed.

  • FlagContext.tenantId (in @setu-ts/common) and FlagDefinition.tenants — both optional, so every existing caller, implementor, and flag definition is source-compatible.

  • A factory arm on the four registration seams (M70d). CqrsPluginOptions.commandHandlers, queryHandlers and behaviors, EventsPluginOptions.handlers, and HealthPluginOptions.indicators each accept, beside the instance, a factory — RegistryFactory<T> = (services: IServiceRegistry) => T (new, @setu-ts/common). A factory is resolved at the onInit phase — the first phase at which the registry holds every capability — and its result is registered exactly as the instance arm would be, so a generated handler or indicator can build a capability (the event bus, a database, the logger) at startup. Instance entries keep their register() timing byte-identically, so an existing configuration is unchanged. A factory that throws rejects start(), naming the option and the entry, with the original error preserved as cause (through resolveRegistryEntry, also new in @setu-ts/common).

  • DatabaseAdapterOptions.provider, the exported PrismaSqlProvider type, and UnsupportedFilterOperatorError — see Changed for the contains behaviour they govern.

  • Telemetry auto-instrumentation outcomes are now reported, not silent (M70e). The instrumentations registry reports every outcome through the plugin’s logger (ctx.logger, read at call time): an enabled instrumentation logs one line at debug, a loader or enable failure one line at warn carrying kind and reason. A failure remains a documented no-op — it is still never thrown — but it is no longer invisible. The plugin declares the logger capability in optionalDependencies, so the kernel registers a plugin-provided logger (e.g. LoggerPlugin) before it and the standard configuration (RuntimePlugin + LoggerPlugin + TelemetryPlugin) reports every outcome; without any logger plugin the app still boots and the outcomes are recorded on the registry handle with nothing emitted. The five instrumentation loaders’ default importers now take zero arguments (the specifier is a literal inside the default), so the default path is the one a published artifact can actually run.

  • A recurrence gate for computed import() specifiers (M70e). scripts/npm-specifier-audit.ts walks every packages/*/src file and refuses any dynamic import() whose first argument is not a string literal, unless the call carries a computed-specifier: <reason> marker (an empty reason is rejected). test/npm-specifier-gate.test.ts runs it on every suite run, and deno task release:verify enforces it as check 6, so the X7-3 shape (a specifier routed through a variable that JSR’s static npm-compat rewrite cannot reach) cannot re-enter the source tree.

  • Scheduler multi-instance deduplication (M70l, X10-2). A cron or every fire claims a slot lock keyed on the fire’s intended time (scheduler:job:<name>:<slot>) which is never released and expires on ttlMs, so replicas sharing one lock backend run an intended fire exactly once between them — a guarantee that holds only while ttlMs exceeds the maximum skew between replicas, since the TTL is how long a claimed slot stays remembered. A delay job differs: its slot is keyed on the job name (scheduler:job:<name>:once), claimed at REGISTRATION rather than at fire time, and released when the entry leaves the registry, so re-registering the name after it fired fires again. The existing per-handler mutex is kept unchanged for overlap protection. MemoryLock now sweeps every expired entry on acquire, because a recurring slot key is never released and never reacquired, so the previous lazy per-key delete could never reclaim it and the map grew one entry per job per fire, forever.

  • MetricsPluginOptions.excludePaths (M70l, X10-7). Request paths the HTTP metrics middleware skips entirely. When supplied it REPLACES the default ['/health', '/live', '/ready']; the plugin’s own scrape endpoint is always excluded either way, so /metrics no longer counts its own scrapes or the health probes.

  • Generated Kubernetes members carry the chart’s graceful-shutdown pieces (M70l, X10-4/X10-6). setu generate app now emits lifecycle.preStop.sleep.seconds: 5 (Kubernetes 1.30+) on every container, and prometheus.io/scrape|port|path annotations on members generated with a template (recorded via the new metricsEndpoint field of setu.workspace.json; absent means unknown and emits none).

  • SchedulerUnavailableError (M70l, X9-2). SchedulerPlugin.register() refuses to register on Cloudflare Workers, where its timers cannot fire, naming WorkersCron and [triggers] crons as the replacement.

Changed

  • Breaking: ctx.state keys now use the owner-prefixed convention (M71). Migrate literal readers from 'clientIp' to 'http-security-plugin:client-ip', '__he_telemetry_span' to 'telemetry-plugin:span', 'validated:<target>' to 'validation-plugin:validated-<target>', 'setu.error.responder' to 'exceptions:error-responder', and 'setu-ts:session' to 'session-plugin:session'. Prefer CLIENT_IP_STATE_KEY, TELEMETRY_SPAN_KEY, validatedStateKey, and ERROR_RESPONDER_STATE_KEY where those exports are available; SESSION_STATE_KEY is exported from its own module but is not a public package export, so read a session through getSession(ctx) rather than the key.

  • Breaking: application-scoped registry mutation now stops after bootstrap (M71). Plugins that retain a context and call register, registerFactory, or unregister after runBootstrap() now receive an error. Register during register(), onInit, or onBootstrap; use the request-scoped ctx.services in middleware for per-request services.

  • Breaking: request identity fields reject a second implicit assignment (M71). request.user and request.tenant permit one assignment and then throw; use replacePrincipal or replaceTenant for a deliberate replacement. This is an accidental-overwrite guard, not an authorization boundary: a write before authentication remains a permitted first write.

  • BREAKING: @ValidateBody(schema) (and @ValidateQuery/@ValidateParams) now enforce their schema by default (M70n, E1). The decorators shipped as inert metadata — a decorated route accepted any body — so an application upgrading that carries a decorated route whose body its schema rejects starts answering 400 with no configuration change. Enforcement appends the validation capability’s middleware LAST in the route’s chain (innermost, after guards), preserving guard 401/403 precedence; with no validation provider registered the schema stays description-only and ONE warning per route names the controller, handler, targets and ValidationPlugin. Migration: pass DecoratorPlugin({ enforceSchemas: false }) to keep the previous description-only behaviour.

  • BREAKING: the default session cookie is renamed hono_session → setu_session (M70n, X9-10). The framework is not Hono, and the rename only gets more expensive with every release that ships it. In-flight sessions are invalidated at deploy: every user is logged out once. Migration: pin SessionPlugin({ cookie: { name: 'hono_session' } }) for one maxAge window to preserve live sessions, then drop the pin.

  • BREAKING: IResponse gains a REQUIRED html(body) member (M70n, X4-11), implemented in the kernel’s ResponseBuilder and testing’s MockResponse. It sets content-type: text/html; charset=utf-8 — the charset is not optional, because a bare text/html lets a browser sniff the encoding. Migration: an out-of-repo IResponse implementor adds the member; every caller-side use is source-compatible.

  • BREAKING: the cacheControl callback receives a leading-slash path (M70n, C5/D8). The value was slash-less for a file (assets/app.js) but the literal '/' at the prefix root, while the docs said only “root-relative” — a callback written against one observed shape was silently wrong for the other. Both shapes are now normalised to a leading slash (/assets/app-A9acsx54.js, '/' at the root) and documented as such. Migration: a callback comparing against a slash-less form (e.g. path.startsWith('assets/')) changes to /assets/; one matching on extensions or the root literal is unaffected.

  • WorkersCron.dispatch now rejects on failure instead of always resolving (M70l, X9-5). Breaking. A firing trigger with no registered handler throws naming the expression; one or more rejecting handlers run to settlement — a failing handler still never abandons the others — and then dispatch throws an AggregateError carrying every failure. createScheduledHandler is a bare delegation, so the rejection reaches Cloudflare and counts the whole invocation as failed: that is the signal, and it needs no logger configuration. A configured logger still receives both reports first. An application that wants the old fire-and-forget behaviour wraps its own handlers in try.

  • The generated Cloudflare Workers fetch export no longer propagates a failed boot (M70l, X9-8). A boot error (a mistyped binding, a broker briefly down at cold start) is now logged to the platform’s console and answered with a generic 503 Service Unavailable — the stack never reaches the client. The failed boot promise is cleared, so the next request re-attempts the boot rather than being pinned to the failure for the isolate’s life. An application that wants the error body can wrap its own handler around app.fetch.

  • The generated Dockerfile folds chown -R into the dependency-cache layer (M70l, X10-5). A standalone chown -R rewrites metadata on every file the cache layer created, so overlayfs copied the ENTIRE Deno module cache into a second layer — measured at 563 MB vs 362 MB with the fold, paid on every push and every node pull. Image contents are unchanged; only the layer layout differs, so existing deployments need no action beyond the next rebuild.

  • every jobs arm on an absolute epoch grid (M70l, X10-2). The first fire lands on (floor(now / interval) + 1) * interval rather than a full interval after registration, at registration, re-arm, and resume alike. The period is unchanged; only the phase moves, and the fire is never LATER than before — it may come sooner than one full interval after registration, so a job assuming “I have been up a full interval” behaves differently. This is what makes replicas started at different instants agree on distributed-lock slot keys. Breaking (resume contract): the released contract stated that resuming an every job restarts the interval “from now”; resuming now lands the next fire on the next epoch grid boundary instead. The implementation has always resumed on the grid (that is the alignment replicas need to agree on slot keys), so the contract and PUBLIC_API.md are corrected to match — a resumed fire may come sooner than one full interval after resume() (never later).

  • RabbitMqBroker declares its queues with the shape their subscription implies (M70l, X10-1). Breaking. A caller-supplied queue name (a consumer group) is declared { durable: true } — it survives a broker restart, which is what queue documents; the private per-subscriber queue and the RPC reply inbox are declared { exclusive: true, autoDelete: true }. The previous unconditional { durable: false } named non-exclusive form is refused by RabbitMQ 4 outright (541 INTERNAL-ERROR … transient_nonexcl_queues). CI’s RabbitMQ service moves to major version 4 with this change.

    Migration — deployments upgrading against an EXISTING broker. A named consumer-group queue that the old client created as { durable: false } cannot be re-declared { durable: true }: RabbitMQ answers 406 PRECONDITION_FAILED and closes the channel, so every subscriber on that queue stops until the queue is redeclared. To restore service, delete the existing non-durable queue (drain it first if in-flight messages must be preserved — a non-durable queue’s contents are lost on broker restart anyway) and let the new client re-declare it as durable:

    rabbitmqadmin delete queue name=<consumer-group-queue>

    Deployments whose brokers are provisioned fresh (or whose queues were already declared durable) see no change. Private per-subscriber queues and the RPC reply inbox are exclusive/auto-delete and are recreated per connection; they never carry this problem.

  • Reused OpenAPI schemas are deduplicated symmetrically, and named from their first use (M70m, X11-6). The first use of a reused schema was inlined and never rewritten, so one shape appeared both inline and as a $ref in one document, under the meaningless name Schema1; and a schema nested inside another was not counted at all, so two structurally identical cases behaved differently. Every site of a reused schema now carries a $ref, nested schemas are deduplicated too, and a hoisted component is named from the site that first reached it (GetOrdersByIdResponse404). A generated client’s component type names change accordingly — regenerate it. A reused primitive stays inline: a $ref to {"type":"string"} is larger than the schema it replaces.

  • operationId no longer carries path braces (M70m, X11-8). GET /orders/{id} derived get-orders-{id}, which Redocly’s recommended ruleset flags as URL-unsafe and which breaks any tool that puts an operationId in an anchor, a filename or a URL. It is now get-orders-by-id. This renames generated client methods — getOrdersId becomes getOrdersById — so regenerate clients and update call sites. Nothing else reads an operationId.

  • Breaking (behaviour): an operation’s requestBody and parameters are now derived from its validation middleware (M70m, X11-5). Derivation is ON by default, so the document emitted for any application whose routes carry validateBody/validateQuery/validateParams/ validateHeaders changes without a configuration change, and a derived route also gains a 400. Regenerate any committed client. OpenApiPlugin({ deriveRequestSchemas: false }) restores the previous request shape — and nothing else; see the two entries below.

  • Breaking (behaviour): operational routes are no longer documented (M70m, X11-8). /health, /live, /ready and /metrics are excluded by default via the new excludeOwners. A regenerated client therefore NO LONGER declares getHealth, getLive, getReady or getMetrics, so any call site using one stops compiling — that is the intended correction, since those operations were the application’s operational surface rather than its API. Pass excludeOwners: [] to document them again.

  • Breaking (behaviour): CORS no longer refuses content-type while advertising POST (M70m, X11-3). CorsOptions.allowedHeaders defaulted to [] while methods defaulted to every standard verb, so a preflight offered POST/PUT/PATCH/DELETE and then carried no Access-Control-Allow-Headers — every browser blocked every JSON request made against the configuration the README itself showed. Omitting the option now ECHOES the preflight’s Access-Control-Request-Headers for an allowed origin and adds Vary: Access-Control-Request-Headers. An explicit list still allows only those headers, and an explicit [] still allows none, so any application that configured the option is unaffected. The origin allowlist — the actual security boundary — is unchanged, and a denied origin echoes nothing.

  • Generated SDK client source is deno fmt- and deno lint-clean (M70m, X11-9). It emitted 4-space indentation against the 2-space config every scaffolded project ships, left nested inline object types unindented, and opened with a blanket deno-lint-ignore-file carrying no rule list — so codegen output could not be committed to a scaffolded project without formatting a file whose header forbids editing it. Output is now 2-space and depth-indented, signatures past 100 columns wrap one parameter per line, a path template too long for one line is emitted as an equivalent […].join(''), an empty-object schema emits Record<PropertyKey, never> rather than the ban-types-rejected {}, and no pragma is emitted at all. The two committed generator-output fixtures were removed from deno.json’s fmt.exclude, so the repository’s own fmt and lint gates now enforce this permanently. Every multi-line type is also hoisted into an exported alias rather than written inline at a use site — an inline (non-$ref) request body, parameter or success response was emitted at whatever indentation its use site sat at, and a success type is written at TWO indentation levels at once, so no single indent could be correct. Both original fixtures name every schema through $ref and so could not show it, while a schema derived from validation middleware and used once arrives inline; a third fixture generated from an inline-schema document now covers the case.

  • GrpcPlugin’s basePath now defaults to / (the root), and native gRPC-binary requests are refused with UNIMPLEMENTED (M70i, X7-2 / X7-4). The old default '/grpc' was unreachable by every native client — a gRPC path comes from the method name alone, and no client has a prefix option. RPC detection stays segment-aware, so ordinary routes like /grpcfoo are untouched.

    Migration — applications that relied on the default. If you pointed clients at http://host:3000/grpc/<service>/<method>, either pass basePath: '/grpc' explicitly or point the client at http://host:3000/<service>/<method>. Applications that already set basePath explicitly see no change.

    Native gRPC-binary requests (application/grpc, application/grpc+proto, application/grpc+json) are answered with a Trailers-Only UNIMPLEMENTED (grpc-status: 12) instead of reaching Connect’s handler, where every call failed with an opaque “missing status” transport error after a successful handshake: no runtime on which the plugin loads exposes the HTTP/2 trailers the native protocol requires. Connect (application/connect+*) and gRPC-Web (application/grpc-web+*) remain fully supported on all runtimes; point native gRPC clients at a gRPC-Web-capable proxy or switch them to Connect.

  • graphql-plugin: resolvers can be typed without casts, and the WebSocket context no longer pretends to carry an HTTP request (M70i, X6-4 / X6-6 / X6-7). FieldResolver is generic (FieldResolver<TSource = unknown, TContext = unknown, TArgs = Record<string, unknown>>; existing all-unknown resolvers stay assignable), and DefaultGraphqlContext is typed against @setu-ts/common (services: IServiceRegistry, user?: IPrincipal, tenant?: ITenant). Over HTTP the context is { services, requestContext, user?, tenant? }; over WebSocket it is { services, connection } and requestContext is absent — the upgrade request is closed once the handshake response returns, so a synthesized one would be dead by resolver time. The upgrade request’s headers/query live on connection.headers/.query. requestContext is therefore typed optional; code that dereferenced it must narrow first.

    A resolver’s ctx.services is now the live registry on every entry point. execute() and subscribe() take their request context optionally, and a call passing only params previously received {} there — so ctx.services.get(...), which services: IServiceRegistry now invites without a cast, threw a TypeError that error masking reported as Internal server error. The fallback is the plugin-level registry instead.

    APQ refusals now follow the documented media-type watershed from one owner: under application/json an APQ miss answers 200 with PersistedQueryNotFound in the body (the one error a client must read and retry); under application/graphql-response+json it carries the resolver’s own status.

  • BREAKING: StoragePluginOptions is now a union discriminated on provider (M70k, X8-11). One unknown key used to make the compiler report EVERY property of the literal as not assignable to type 'never' while never naming the offending one, because the union’s first member was MemoryProviderOptions = Record<string, never> — which accepts any object shape while requiring every property to be never. Discriminating the union alone did NOT fix the reporting (measured); removing that member did. Migration: provider stays optional on the memory arm, so StoragePlugin() and StoragePlugin({ provider: 'memory' }) are unchanged. Every other arm now requires its own options and its required fields — bucket for 's3'/'b2'/'gcs', containerName for 'azure' — which were previously runtime failures. MemoryProviderOptions is removed; the memory arm takes no options, so drop options: {} if you passed it.

  • IAwsS3Client is renamed IS3Backend, with the old name kept as a deprecated alias (M70k, X8-10). The old name promised something it never was: @aws-sdk/client-s3’s surface is send(command), so injecting a real S3Client was refused with Injected S3 client is missing required methods. What the type declares is the backend surface this package’s own adapter PRODUCES. Migration: rename the import; the shape is unchanged, and the alias keeps existing code compiling in the meantime (AI_GUIDELINES §9.2 — the replacement is a working, identical shape, so a rename does not need to be a compile error). There is still no supported way to configure the underlying SDK client.

  • BREAKING: WebWorkerLike requires addEventListener (M70k, X8-7). A real web Worker is an EventTarget, and the worker-exit signal needs it. Migration: an injected fake worker must add the method; a real Worker already satisfies it.

  • Upload size refusals answer 413 rather than 400 (M70k, X8-3). A body over the parse bound and a file over maxSize are now 413 Request entity too large / 413 File too large; a malformed body, a disallowed MIME type and too many files remain 400. Telling a client its request was malformed when it was merely large sent it to the wrong fix.

  • LocalStorageProvider.connect() proves its root is writable and refuses to start otherwise (M70k, X8-9). It also became a rejection rather than a synchronous throw, so a caller using .catch() on this Promise-typed method reaches it. Migration: none for a working configuration; a root the process cannot write now fails at startup naming the cause — and, on Deno, naming --allow-write — instead of failing every upload while /health reported up.

  • BREAKING (types only): a 'prisma' or 'drizzle' registration must now name the options its adapter cannot run without (M70j, D7). DatabasePluginOptions is a union discriminated on type, and its built-in arm made every adapter-specific field optional on a nested bag, so omitting one was a runtime throw at connect() rather than a compile error — unlike the 'custom' arm, which has required adapter since M52c. type: 'prisma' now requires options.prismaClient and type: 'drizzle' requires both options.drizzleInstance and options.drizzleTables. Only a registration that already failed at startup stops compiling, and the runtime guards are unchanged, so a JavaScript caller sees exactly what it saw before.

    // Before — compiled, then threw at app.start()
    DatabasePlugin({ type: 'prisma' });
    DatabasePlugin({
      type: 'drizzle',
      options: { drizzleTables: { User: users } },
    });
    
    // After
    DatabasePlugin({ type: 'prisma', options: { prismaClient } });
    DatabasePlugin({
      type: 'drizzle',
      options: {
        drizzleInstance: createDrizzleDatabase(db, (database, work) => database.transaction(work)),
        drizzleTables: { User: users },
      },
    });
  • BREAKING (behaviour): the Memory adapter refuses an unknown select or orderBy column (M70j, X12-5). The default adapter used to accept both silently — a projection quietly lost the field and a sort quietly returned rows in insertion order — while Prisma and Drizzle answered 500 for the identical call, so a rule proved in development became a production failure. A field that no stored row carries is now refused with the entity, the clause and the observed column list, matching Drizzle’s message. A field present on at least one row counts as known, and an entity holding no rows accepts anything. where and filter are deliberately unchanged: with no schema this adapter cannot tell an unknown column from one absent everywhere, and matching nothing is a defensible answer. If a projection over a column that has never been written is intentional, write the column (even as null) on one row, or list only fields the store has seen. Uniqueness and column types remain unenforced and cannot be enforced by a schema-less store — the package README now states that plainly.

  • The Drizzle table registry validates id lazily (M70j, X4-9). connect() refused every registered table without an id column, so a composite-key join or per-tenant table registered only for the typed query builder made the whole registry unusable. The registry now accepts any table definition; a repository for an id-less table is refused by name at getRepository(), where IRepository’s single-key contract actually applies. Strictly widening — no configuration that worked before changes.

  • Error bodies now answer in the application’s configured format (X4-8, C3) (M70f). Every short-circuiting site — the kernel’s 404/400/503/500 terminals, createUploadMiddleware’s rejections, the multi-tenancy 400, the session tenant-mismatch and form-CSRF 403s, the auth guards, the http-security 413/403s, and the feature-flag guard — now writes its body through the responder seam, so an errorHandler({ format: 'rfc9457' }) governs them all.

    Migration — with errorHandler registered (every CLI template and starter). This is the case that changes, and it changes for every one of these sites: they previously bypassed the configured format and answered their own application/json body, and they now answer in it. Under format: 'rfc9457' a rejection that was {"error":"Too many files","detail":"Maximum 3 file(s) allowed"} (application/json) becomes {"type":"about:blank","title":"Bad Request","status":400,"detail":"Maximum 3 file(s) allowed","instance":"/upload"} (application/problem+json). A client that branched on the error member must read detail instead — the site-specific label (Too many files, Tenant Required, Tenant Mismatch, Invalid MIME type) is no longer a body member, because RFC 9457 §4.2 requires title to be the status title for the about:blank problem type. Under format: 'default' the label is the message member and the disclosure is details.detail. There is no option that restores the pre-M70f ad-hoc bodies; answering in the configured format is the defect being fixed (X4-8).

    Migration — with no errorHandler registered. Sites that answered { error, message } now answer { error, detail } (the disclosure moves from the non-standard message key to the RFC 9457 detail key), and the feature-flag guard’s bare text/plain Not Found is now the JSON fallback {"error":"Not Found"}.

  • IGrpcService.addService’s implementation parameter is now unknown (M70f). It was Partial<ServiceImpl>, an index-signature type that rejects a class instance — whose methods live on the prototype rather than as own properties — although Connect accepts one, so a class-based service implementation could not be passed without a cast. Widening is source-compatible for callers: an implementation that type-checked before still does. ServiceImpl is removed from @setu-ts/common — the widening left it with no reader anywhere in the framework, and while the project is in prerelease a dead export is deleted rather than carried as deprecated surface. Migration: drop any as Partial<ServiceImpl> cast or ServiceImpl annotation at an addService call site and pass the implementation directly.

  • An unhandled request error is now logged by the kernel (X11-2) (M70f). The fallback 500 path previously discarded the error and logged nothing even with LoggerPlugin registered; it now reports the message and stack through CAPABILITIES.LOGGER (guarded so a missing or broken logger degrades silently). The response body stays opaque — the message is not disclosed to the client.

  • A raw Error in log metadata no longer serializes to {} (X2-5) (M70f). The console and pino loggers normalize any Error value in merged metadata through serializeError before redaction, so any call site is safe on those two loggers, and the two known raw-Error call sites (events-plugin’s handler failure and errorHandler’s cause) serialize explicitly, so those stay correct on a third-party ILogger too. A third-party logger does not normalize, so a NEW call site handing it a raw Error can still render {} — pass serializeError(err) there.

  • A gRPC handler error is now logged (X7-5) (M70f). grpc-plugin wraps each application handler so a thrown or rejected error is logged at error level with the procedure name and a serialized error, then rethrown — the masked wire response is unchanged. The logger is resolved at call time, so a logger registered after GrpcPlugin is still seen. GrpcPluginOptions gains interceptors?: readonly unknown[], threaded into createConnectRouter (which previously dropped the argument its own facade declared).

  • Breaking (behaviour): a route that names its path now beats a catch-all, in either registration order (M70g, X5-1/F1). The kernel’s tie-break ranked candidates by static-segment count, and a * segment was COUNTED AS STATIC — so GET /* tied with GET /openapi.json and whichever registered first simply won. ReactRouterPlugin mounts its SSR catch-all at PLUGIN_PRIORITY.NORMAL (500) while OpenApiPlugin registers at OPENAPI (700), deliberately last so it can document every route, so every full-stack application silently lost /openapi.json and /docs to an SSR 404 page, with no error anywhere; the same shape removed those endpoints under a root-mounted StaticPlugin. * is now its own segment kind and the ranking is: more static segments, then FEWER wildcards, then earliest registration. That also corrects an inversion — /a/* outranked /a/:id in both orders. Migration: an application relying on a catch-all registered first to shadow a later single-segment route now serves the later route. The shadowed route was unreachable by accident rather than by configuration, so the change is toward what the developer wrote; a route that must not be reachable should not be registered. Known limit: the rule compares counts rather than positions, so /a/* loses to /:x/b on /a/b.

  • Breaking (message): the duplicate-route refusal now names the plugin that registered the route first (M70g, X5-6/X4-4). Route 'GET /*' is already registered. became Route 'GET /*' is already registered by plugin 'react-router'., or … by the application. for a route the application registered itself. The old message named the pattern and the SECOND claimant, which the stack trace already carried; the missing half was who held it. Migration: a test asserting the exact old string needs the suffix; the message is otherwise unchanged.

  • Breaking (behaviour): setu generate no longer lists a hand-registered module in the generated barrel (M70g, X4-4/F2). The seam scanner admits any file matching a family’s suffix and exports, so a developer’s own src/controllers/admin.routes.ts, wired by hand from setu.config.ts, was swept into the CLI-owned barrel by an UNRELATED setu generate route report — and since M68 refuses a duplicate METHOD path, the application stopped booting, with an error naming two files the developer had not touched, from a command that reported success. A candidate whose symbol already appears in setu.config.ts is now left out of the barrel and reported — for a barrel that registers something, which excludes the functional src/services/index.ts re-export, where that symbol is the developer consuming the barrel exactly as its header documents. A file the barrel claims for the first time is reported as adopted, once, at the moment it happens. Migration: none for generated artifacts, whose symbols never appear in setu.config.ts. A project that deliberately wired a conventionally-named module by hand AND relied on the barrel registering it too was already failing to boot.

  • Breaking (behaviour): setu generate health-indicator refuses a name an installed plugin claims (M70g, A1). setu g health-indicator database in a project with DatabasePlugin wrote a file, type-checked, and then threw Duplicate health indicator name: "database" at app.start(). Every plugin that registers an indicator claims a name, and fifteen of them claim exactly the ones reached for first — database, cache, storage, session, events, mail, audit, and more. The command now refuses before writing, naming the plugin. Migration: choose a qualified name (billing-schema rather than database), or remove the plugin.

  • Breaking (behaviour): the gRPC health bridge maps degraded → NOT_SERVING (M70c, X7-8). It previously mapped degraded → SERVING, so a degraded process answered SERVING on grpc.health.v1.Health/Check while the health plugin’s /ready already returned 503 — the two health faces of one process disagreed, and gRPC clients kept load-balancing onto a replica HTTP had taken out of rotation. Migration: a client that relied on SERVING while a process is degraded now sees NOT_SERVING; that is the agreement the framework’s own default already produced on the HTTP side. up → SERVING and down → NOT_SERVING are unchanged.

  • Breaking (behaviour): a service-discovery indicator reports down, not up, when its backend was never reached (M70c, X10-3). The old comment claimed down was “unreachable by construction”, but a provider that never resolved (e.g. a Kubernetes API whose TLS the runtime rejects) reported up forever. The indicator now composes everResolved (set on the first successful read, never by a stale-cache serve) with the provider’s reachability probe: never-resolved-and-unreachable is down, a stale cache or a just-unreachable warm cache is degraded, otherwise up.

  • Health indicators are projected to { status, data } (M70c, X3-7). An indicator’s undeclared fields were previously echoed verbatim into /health — a returned details sat beside the built-ins’ data. The health service now projects every result to the documented shape.

  • Breaking (behaviour): an unhandled non-HttpError 500 no longer carries its own message. maskInternalErrors defaults to true, so the body’s detail/message becomes the status title. Migration: nothing, if you were reading the message for diagnostics — it is still logged, in full, with its cause chain, exactly as before. Set maskInternalErrors: false to restore the previous body verbatim. A deliberately thrown HttpError (a message the developer chose for the caller) and every 4xx are never masked. Where a maskInternalErrors: true error would previously have carried a stack under includeStackTrace: true, it no longer does — a stack begins with the message that was just masked, so masking wins over that option.

  • Breaking (behaviour): contains on the Prisma adapter is now connector-aware, and is refused on SQLite. Prisma emits a bare LIKE with no ESCAPE clause, so the value was matched as a pattern: a search for % returned every row in the table and a search for 50% off matched rows that do not contain it. The value is now escaped on postgresql/postgres, mysql, sqlserver and cockroachdb (whose LIKE defaults its escape character to backslash), passed through unchanged on mongodb (whose contains is a $regex match, where % and _ are already literal), and refused on sqlite with UnsupportedFilterOperatorError, because no escaping is expressible there through Prisma’s filter API. Migration: a Prisma + SQLite application using contains must switch to a raw query or the Drizzle/Memory adapter — it was returning wrong rows before, silently. If the adapter cannot detect your connector it throws the same error naming the new provider option; pass provider: '<connector>' in the adapter options.

  • Breaking (cache keys): a tenant-aware application’s cache keys change shape, because the tenant segment is now part of them. The effect is a one-time cold cache on upgrade, not a correctness problem — expect a miss spike rather than investigating one. Keys in an application that resolves no tenant are byte-identical to before.

  • MultiTenancyPlugin({ required: true }) no longer rejects the operational probes. It answered 400 on /live, /ready, /health, /metrics, /openapi.json and /docs, which carry no tenant header — so a required-tenant deployment never passed the readiness probe in the Kubernetes manifests the CLI itself generates. Those six paths are now exempt by default. Migration: if your application serves its own business routes on any of those paths and wants them tenant-gated, pass an explicit exclude list (or [] to exempt nothing).

  • Breaking (generated layout): src/routes/ is merged into src/controllers/. A scaffolded project had two directories for HTTP endpoints, wired by two mechanisms, and setu generate route and setu generate controller each owned one — so “where does an endpoint go?” had two answers and the seam scanner could adopt a hand-written file from the other. There is now one directory and one barrel carrying both shapes.

    To migrate an existing project: move everything under src/routes/ into src/controllers/, delete src/routes/, update the import in setu.config.ts from ./src/routes/index.ts to ./src/controllers/index.ts, and run any setu generate to regenerate the barrel. An un-migrated project degrades loudly rather than silently — the scanner reports each file it skipped and why.

  • Breaking (generated output): setu generate module’s barrel re-exports the service module rather than the stub symbol. It emitted export { listWidget } from './widget.service.ts', so replacing the generated stub — the obvious next step — broke the barrel with TS2305, and the barrel is not reachable from deno check main.ts setu.config.ts, so it stayed broken through a full green run of every gate. It is now export * from …. The generated test and route module still import the function by name, which is correct — they exercise and serve it. Existing projects need no change; regenerating replaces the named re-export.

  • Breaking (runtime host seams): DenoHost, NodeHost, BunHost, NodeModules and BunModules gained required members. These are public type exports whose own JSDoc advertises injection as the supported way to test an adapter, so a consumer holding a hand-written fake now fails deno check: DenoHost needs build: { os: string } and addSignalListener, NodeHost and BunHost need onSignal, and both *Modules.proc need on. Add those members to the fake — the three fixtures in this repository needed exactly that. IRuntimeServices.onSignal itself is optional and source-compatible; only the injectable host seams are affected.

  • Breaking (generated output): a generated health indicator’s shape now follows the project’s generator mode. A class-based project keeps today’s class; every other project — which since M65 includes rest and microservice — gets export const <name>Indicator: IHealthIndicator. An indicator generated before this therefore no longer matches what its barrel imports: it is dropped from the barrel, its health check stops running, and setu generate reports the file by name. Rename its export to the one reported, or delete it and regenerate. (The report used to say “regenerate it”, which could not be followed — the file is not CLI-owned, so the overwrite check refused. It now names both routes out.)

  • Breaking (generated output): a CLI-generated registration artifact no longer registers until it exports a factory (M70d). The CLI-owned barrel no longer constructs the artifact with new X() — it references an exported factory by name, and setu generate now emits that factory (create<Pascal>…()) in the artifact file itself. An artifact generated before this change exports no factory, so the seam scanner rejects it: it is left out of the regenerated barrel, not registered, and setu generate reports the file by name. Migration: add the two-line factory the new schematic emits — export function create<Pascal>…(): <Pascal>… { return new <Pascal>…(); } — or delete the file and re-run the schematic.

  • The class-based template now emits DiPlugin({ autoRegister: true }) (M70d, E3). autoRegister defaults to false, and both the container’s external resolver and its registry fallback are gated on it — so a bare DiPlugin() could not resolve any @Inject(CAPABILITIES.X) at startup, and the entire plugin ecosystem was unreachable from a scaffolded class-based service. Migration: none for new projects; a hand-written setu.config.ts that relies on the container NOT falling back to the registry should pass autoRegister: false explicitly.

  • The documented install lines and the generated install task pass --min-dep-age 0, matching the minimumDependencyAge the scaffolded manifest already set.

  • A generated full-stack project can be started by following its own README: it emits install/build tasks and start depends on build, and it declares nodeModulesDir so check:app resolves on a cold checkout.

  • The generated full-stack config resolver carries a return-type annotation, which restores excess-property checking. TypeScript does not apply it to an object literal returned from a contextually-typed callback, so a misspelled starter arm previously type-checked, booted, and reported nothing.

  • Cloudflare Workers projects: deno task start serves instead of binding nothing and exiting 0, post-response waitUntil work is no longer dropped, and the emitted wrangler.toml documents all the binding types most projects reach for.

  • A --transport rabbitmq workspace passes its own deno fmt --check. Generated Markdown prose and plugin arguments are wrapped programmatically rather than hand-wrapped around whichever value the author happened to interpolate.

  • Kubernetes probes generated for a workspace member select httpGet only when that member’s template actually serves a health endpoint, and tcpSocket otherwise.

  • IGrpcService.available is now unconditionally true. It reported whether the HTTP adapter implemented setRpcHandler; dispatch no longer depends on any adapter capability.

  • A non-conformant WebSocket upgrade carrying a body is refused 400 by the kernel rather than failing inside the runtime’s own upgrade call with a runtime-specific message. RFC 6455 forbids a body on the handshake.

  • WebSocket upgrade detection moved from the HTTP adapter into WebSocketService’s own router, which is where a routing failure can still be logged. Behaviour on the wire is unchanged.

  • Node’s raw upgrade listener now refuses an unroutable upgrade with the status the pipeline produced (404 for a path with no WebSocket route) rather than a fixed 400.

Fixed

  • Every doc site showed authMiddleware() registered at a priority its own architecture table does not grant it (M70n, X3-1). ARCHITECTURE.md §10 reserves 300 for authentication, but a bare app.middleware.add(authMiddleware()) takes the kernel default of 500 — AFTER every band in that table, so a hand-written global guard in any documented band rejected valid credentials while route guards worked. The README, both JSDoc examples and PUBLIC_API.md now write the explicit { priority: 300 }, and §10 states that its priorities are conventional bands, not self-registrations, with 500 outside the table’s range.

  • The backplane’s documented partition guarantee was wrong for the first ~11 seconds (M70n, X3-3). ioredis’s default offline queue holds publishes during a short partition and delivers them LATE (measured ~5.9 s) — which an application told “frames during a partition are missed” will not defend against — until the maxRetriesPerRequest budget exhausts and drops them with a warn per frame. Both READMEs and PUBLIC_API.md document the actual shape; neither ioredis default is reachable through plugin options (inject a client pair to change it).

  • The websocket README nominated a cookie as the first credential to verify in onOpen — and a cookie cannot be verified there (M70n, X3-5). ISessionService.from(ctx) needs the request context an upgrade bypasses, and no open-from-headers seam exists. The README now says what works: a signed query parameter or subprotocol nonce, with the composition gap tracked in smoke/DEFECTS.md.

  • room()/channel() are get-or-create, and nothing public said so (M70n, X3-8). A presence endpoint reading size for caller-supplied names created one room/channel per distinct name, reclaimed only on some other connection’s disconnect. Both READMEs and PUBLIC_API.md document the semantics.

  • A hand-written RouterContextKey literal silently returns its default (M70n, X5-7) — keys match by identity and the declaring module reliably exists twice in a framework-mode build. The react-router README now carries the contextKeyFor warning (and its loader example, which read a nonexistent context.services, actually compiles).

  • resilience.wrap() rebuilt per request silently disables the circuit breaker (M70n, X7-9). Retry and timeout keep working, so the broken shape looks identical to the working one. The resilience README and PUBLIC_API.md say to hoist the wrapped call to module or plugin scope.

  • SessionPlugin({ csrf: {} }) — the registration every doc shows — could not accept a JSON mutation (M70n, X4-5): csrf.headerName had no default, so the token was readable only from a urlencoded body. It defaults to 'x-csrf-token' (the name the package’s own JSDoc example already used); an explicit name still wins.

  • every jobs armed their timer for a full interval instead of the delay to the grid boundary (M70l, X10-2). every() computed a grid-aligned nextRunAtMs but armed the timer for the whole intervalMs, so an off-grid registration ran the job a full interval LATER than the boundary it was aligned to — defeating the grid alignment (and the slot agreement it exists for) until the re-arm after the first fire. The timer is now armed for Math.max(0, nextRunAtMs - now) at registration and on every re-arm, so each fire lands at the boundary it was aligned to.

  • delay jobs no longer deduplicated across replicas (M70l, X10-2). The fire slot for delay entries was keyed on nextRunAtMs, which for a delay is now + delayMs and therefore carries per-replica startup skew: two replicas registering the same one-shot delay 700 ms apart computed different slot keys and BOTH ran the handler. The slot is now claimed at REGISTRATION, keyed on the job name (skew-independent), and released when the entry leaves the registry — on fire, remove(), or TTL expiry — so a re-registration under the same name after the delay fired gets a fresh slot and still fires (the regression the name-keying previously guarded against stays guarded). cron and every keep their fire-time, grid-aligned slot keys, which are skew-independent by construction.

  • RabbitMqBroker treated any user queue named rr.inbox.* as the private RPC reply inbox (M70l, X10-1). The reply inbox’s transience was detected by pattern-matching queue names against the internal rr.inbox. prefix, but SubscribeOptions.queue has no reserved-prefix restriction — a legitimate consumer group named e.g. rr.inbox.orders was declared { exclusive: true, autoDelete: true } and non-durable, so a second instance’s use of it was refused. The inbox is now marked transient by a flag on the internal subscribe call at the point it is created, and a user subscribe({ queue: 'rr.inbox.orders' }) is declared as a normal durable competing-consumer group queue.

  • Every documented example of reading a validated request read the WRONG ctx.state key (M70m, found in verification). validateBody and friends store under validated:${target} — validated:body, validated:query, … — while packages/validation-plugin/README.md, that package’s module JSDoc (which is what jsr.io renders as its package page), ARCHITECTURE.md and five PUBLIC_API.md examples all wrote validatedBody/validatedQuery/validatedParams. A route following any of them received undefined and answered with an empty body — validation itself worked, so nothing failed loudly. 11 sites corrected; the code’s key is released behaviour and is unchanged. Every test in the package already used the real key, which is exactly why no gate could see it.

  • An upload’s maxSize did not bound what the middleware parsed (M70k, X8-3). The buffering bound was Math.max(maxSize * 2, 50 MB) under a comment reading “cap at 50 MB”, which made 50 MB a floor: a 1 KB per-file limit multipart-parsed a 40 MB body before rejecting it, and a 100 MB limit raised the bound to 200 MB and delivered a 60 MB body to the handler unchecked. It is now min(maxSize * 2 + framing, maxBodyBytes) — a real cap. Note what this does and does not do: it bounds the PARSE and the per-part copies, not the initial read, because the HTTP adapter buffers the whole body before any middleware runs and IRequest exposes no body stream.

  • taskTimeoutMs: 0 leaked a pool slot permanently (M70k, X8-7). A worker that ended its own thread raised no host event, so the task timeout was the only thing that ever settled its task — and 0, a documented and reasonable choice for long CPU-bound work, removed it. On a size: 1 pool one self-terminated worker wedged the pool forever. The pool now settles the task with WorkerExitError and frees the slot wherever the runtime reports the exit (Node, Bun). On Deno it cannot, so the pool reports exitDetection: false in its health payload and warns once at register() rather than leaving the developer to discover the wedge.

  • A dead-lettered job was invisible through every surface the framework offered (M70k, X8-4). See the three additions above; together they answer “this job is being abandoned”, “how often is this happening”, and “how many are sitting there right now”.

  • The local storage provider could not work in a scaffolded project (M70k, X8-9). The generated start task requests --allow-read and not --allow-write, so every upload failed while /health reported storage: up — the M70c liveness probe calls stat, a READ, which the granted permission satisfies. Three defects had to be understood before the one-flag cause was visible. The provider now fails at startup naming the flag, its health probe reflects writability, and setu add storage prints the note.

  • The storage README’s Uploads example was broken three ways (M70k, X8-8) — maxFileSize (the option is maxSize, and the compiler’s suggestion maxFiles means something else), file.contentType (the field is mimeType), and a getUploadedFile(ctx, 'avatar') the middleware’s own fieldname filter guaranteed would return undefined. The same example sat in PUBLIC_API.md. None of it was catchable because the doc-fence gate covered ten docs/ guides and no package README; it now covers the three READMEs this milestone rewrote.

  • The queue README documented two options that do not exist (M70k) — region and queues. SQS configuration lives under sqs, and RabbitMQ derives its queue names from prefix. Found by the new README fence gate.

  • IDatabaseService.query() was inoperative on the Drizzle adapter (M70j, X12-2). The adapter called execute({ sql, params }), a shape no Drizzle driver accepts, so every raw query failed with the internal TypeError: query.getSQL is not a function — a method on the capability’s own interface with no working path on that adapter at all. It now builds a real Drizzle SQL from the statement and its parameters: the text is emitted verbatim for an ascending-placeholder statement ($1… on PostgreSQL, ? on MySQL and SQLite) and every value is bound, never interpolated. A placeholder count that disagrees with params, a gap in the $N sequence, or both placeholder styles in one statement is refused before the driver is reached, because a mis-bound parameter is silent. On PostgreSQL a jsonb ?/?|/?& operator is indistinguishable from a placeholder, so such a statement is refused or fails at the database rather than being mis-bound — write it with $N placeholders. The defect survived because the unit fake accepted any argument; the proof is now the real Drizzle SQL generator, and that fake refuses a non-SQLWrapper exactly as a driver does.

  • logQueries: true silently dropped a portable count filter (M70j). The service’s logging wrapper declared count(where) and called ds.count(where), discarding the second parameter IDataSource.count(where, filter?) defines — so repo.count({ filter }) answered a different number with query logging on than with it off. Every existing test built the service without logging, which is why it survived. The wrapper now spreads the data source it wraps before overriding the six required methods, so a member the contract does not require cannot be dropped the same way again.

  • X10-3 — a service-discovery indicator that never reached its backend reported up (M70c). See Changed.

  • X7-8 — the gRPC health bridge disagreed with /ready about a degraded process (M70c). See Changed.

  • X3-7 — a health indicator’s undeclared fields leaked into /health (M70c). See Changed.

  • S3Provider is unusable against MinIO/R2/B2 with a custom endpoint (M70c). The client config never set forcePathStyle, so the AWS SDK used virtual-hosted-style addressing (<bucket>.<endpoint>) against the custom host and every request failed (400 MalformedXML on put, 404 NoSuchBucket on get). A custom endpoint now forces path-style (<endpoint>/<bucket>/<key>), which is what the documented “R2 and MinIO via endpoint” promise requires; AWS (no endpoint) is unchanged.

  • The Redis backplane reachability probe reported false forever against a healthy Redis (M70c). RedisBackplane captured client.ping and called it unbound; ioredis’s ping() reads this.options, so the call threw TypeError: Cannot read properties of undefined (reading 'options'), swallowed into false — the indicator reported degraded/reachable: false for a healthy backplane, inverting the milestone’s purpose. The probe now calls ping.call(client) (the pattern RedisStreamsBroker already used).

  • RedisQueue.isHealthy() reported false forever against a healthy Redis (M70c). The same unbound ping() defect in the queue adapter’s probe; fixed with ping.call(client).

  • RabbitMqQueue crashed the host process on a real backend outage (M70c, surfaced by the §3.7 real-outage suite). Two defects no fake-based test could construct: disconnect() threw IllegalOperationError: Channel closed when the channel/connection had already been torn down by the fault (both closes are now best-effort), and connect() installed the connection’s 'error' fault listener only AFTER createChannel() — a socket reset during channel creation (the port is open before the AMQP handshake is ready after a restart) was an unhandled 'error' event that killed the process. The listener is now installed before the channel is created, and the channel gets its own 'error' listener (amqplib emits error on every channel when the connection resets).

  • X11-1 — the SDK’s default fetch receiver broke the client in a browser (M70e). The default transport stored the bare global fetch on a private field and called it as this.#fetch(...), so in a browser its receiver was the HttpClient instance and the first request died with TypeError: Illegal invocation before any network I/O. The default now resolves globalThis.fetch at call time with the global as its receiver, so the default works in a browser unchanged — and a globalThis.fetch installed after construction (a mocking library or polyfill) is honoured rather than shadowed by a value captured at construction. An injected fetch is always used as-is. No configuration change is required; the fix is in the default.

  • X7-3 — @setu-ts/grpc-plugin could not load on Node or Bun (M70e). The Connect/Protobuf-ES specifiers reached import() through a constant map, so JSR’s static npm-compatibility rewrite never saw them and the published artifact shipped npm: verbatim — unresolvable on Node and Bun. The default importer now calls four literal import('npm:…') expressions (one per module, including the two /protocol and /wkt subpaths), so the rewrite reaches every one. The injectable seam is reshaped rather than removed: loadConnectModule now takes a partial record of per-module zero-argument importers (Partial<ConnectImporters>) instead of one specifier-taking ModuleImporter, so each of the four failure branches is still drivable in isolation. Both ModuleImporter and defaultImporter are gone; neither was exported from the package barrel, so no consumer could reference them. Migration: none — the published package now loads on Node and Bun; the Connect/Protobuf-ES packages must be installed with the host runtime’s package manager (the README names the deno add / npm i / bun add commands).

  • Telemetry outcome reporting was silent in the standard plugin configuration (M70e). The reporter reads ctx.logger at call time, but TelemetryPlugin (priority 30) declared no dependency on the logger, so the kernel registered it before LoggerPlugin (priority 100) and every outcome line was dropped — the documented “reported through the plugin’s logger” behaviour was unreachable in the standard configuration. TelemetryPlugin now declares CAPABILITIES.LOGGER in optionalDependencies, so the kernel orders the logger provider first; the edge is optional, so an app without a logger plugin still boots with no lines emitted. A kernel-level e2e test with the real LoggerPlugin pins both directions. Migration: none — the standard configuration now reports as documented.

  • A non-cloneable worker task input no longer kills the host process (M45b, X8-2). @setu-ts/worker-pool-plugin documents that task inputs travel by structured clone, so passing a function or a class instance is a documented misuse that should reject the returned promise — and it did, but only when a worker happened to be idle. When the task was queued first, the dispatch ran from inside a worker message callback with no promise to reject into, so the DataCloneError escaped as an uncaught exception and took the whole process down, losing the results of every other in-flight task with it. A pool of size: 1 with one task already running is an entirely ordinary state under load. TaskPool.dispatch now catches, rejects that task alone, and keeps the worker — which never received the message — so the pool carries on and the freed slot takes the next queued task instead of stalling behind the bad one. Both paths now behave identically. No application change is required.

Deprecated

  • IHttpAdapter.setRpcHandler? — the kernel resolves IGrpcService from the service registry and dispatches after the pipeline, so nothing calls this. All four first-party adapters accept it as a no-op. To be removed in the next major release.
  • RpcInterceptorStore (@setu-ts/runtime), GrpcUnavailableError and GrpcService.createFetchHandler (@setu-ts/grpc-plugin) — all reachable only through the retired pre-pipeline seam. Retained as published surface; nothing throws or installs them.

Known limitations

  • On Deno, WorkerPoolPlugin({ taskTimeoutMs: 0 }) disables crash detection for a self-terminated worker (X8-7). Node and Bun report a worker’s exit, so the pool settles the task with WorkerExitError and frees the slot regardless of the timeout (see Fixed). Deno emits no event at all for a worker that ends its own thread, so IWorkerHost.reportsExit? is omitted there, the timeout remains the only thing that settles such a task, and 0 leaves that run() unsettled with its slot held — which wedges a size: 1 pool permanently. The plugin reports exitDetection: false in its health payload and warns once at register(), but set a timeout on any Deno pool whose task module can call self.close().

0.1.0-alpha.8 — 2026-08-14

A release about what the generator actually produces, and what the database adapters actually ran. Building a three-service monorepo on the published alpha.7 packages found a scaffold that could not be installed on release day, could not answer its own /health probe, could not type-check its own frontend routes, and failed deno fmt --check on 62 of the 74 files the CLI had just written — every one of them past the gate that type-checks generated output and stops there. The gate now formats, lints, installs, type-checks and boots each template and requests what the project advertises.

Generation itself changed shape: decorators are no longer the default. --template rest and --template microservice register no DecoratorPlugin, setu generate reads the style from the target project’s own manifest, and the decorated-plus-DI composition moved to one opt-in, --template class-based (the old --template nest and setu new --di are refused with a message naming it).

The breaking changes ride together, and every one of them is a compile or startup error rather than a silent behavior shift: a Drizzle instance must now be wrapped in createDrizzleDatabase(), a Prisma client must be constructed by the application and injected, the kernel refuses a duplicate METHOD path instead of letting the second registration overwrite the first, and a class implementing IRepository without extending BaseRepository must now implement findOne. The database work is the reason: both ORM adapters shipped paths that could not work against a real server — Drizzle filtered, sorted and paginated whole tables in JavaScript and addressed writes with a fabricated column, and Prisma’s lazy client construction passed an option v7 rejects outright. Both now execute their drivers, and repositories gained a portable filter expression tree plus findOne so a search no longer has to drop to raw query().

All 47 packages move as one version, because the CLI stamps its own version as the dependency range for every project it scaffolds. Installs still need an explicit version (jsr:@setu-ts/kernel@^0.1.0-alpha.8) — JSR does not point latest at a prerelease — and Deno refuses dependencies younger than 24 hours unless you pass --min-dep-age 0.

Added

  • Typed native Drizzle query access. getDrizzleDatabase(service, configured) returns the exact configured instance, and getDrizzleTransaction(uow, configured) returns a derived DrizzleTransaction<typeof drizzleDb> — Drizzle’s callback-scoped native transaction for an IUnitOfWork. Both preserve the application’s schema inference for joins and aggregations, which the single-entity repository contract cannot express, and the transaction form shares one commit/rollback boundary with repository writes. Both take the opaque configuration built by createDrizzleDatabase (see Changed), so the result type is derived from the application’s own database rather than from an unchecked type argument. Promise-aware SQLite Proxy/libsql-shaped instances without execute() now connect for repositories and typed builders; raw query() alone rejects with a descriptive limitation. The exported UnitOfWork constructor accepts an optional adapter-type third argument while preserving the released two-argument call shape.

  • ConfigPluginOptions.envFileOptional. When true, a path in envFilePath that does not exist is skipped instead of throwing. The default is false, so nothing about an existing application changes. Only ABSENCE is tolerated — a file that exists and cannot be read still throws, which the implementation establishes with a stat probe rather than by interpreting a runtime-specific error code.

  • Scaffolded projects generate their configuration. setu new and setu generate app emit a gitignored dotenv file beside a tracked <path>.example and wire ConfigPlugin({ envFilePath, envFileOptional: true }); --env-file <path> chooses the path. A template that requires a value names it in both files — full-stack emits SESSION_SECRET, with a development value in the ignored file and an empty one in the committed example.

  • setu workspace ports --reallocate reassigns every member to a currently bindable port and rewrites the manifest, discovery maps, Compose and Kubernetes artifacts together. Workspace creation and member allocation now probe a port before claiming it.

  • setu generate app --depends-on <member>, repeatable, records startup prerequisites in setu.workspace.json. The generated root dev runner starts prerequisites first and waits for each one’s /ready endpoint before starting its dependents, naming a cycle or a stalled prerequisite and terminating started children on failure.

  • RestStarterOptions.serviceDiscovery, inherited by the microservice and full-stack tiers. Supplied → one ServiceDiscoveryPlugin is registered; omitted → the plugin list is unchanged. This is what lets a full-stack workspace member consume the discovery map the CLI already generated for it, through the starter factory rather than a hand-written registration.

  • Portable repository filters and findOne. FindOptions and CountOptions gain an optional filter expression tree — comparison leaves (eq, contains, gt, gte, lt, lte, in) composed with and / or — conjoined with the existing equality where map, which is unchanged. Memory, Prisma, Drizzle and D1 each translate it natively rather than filtering in JavaScript, so a search no longer has to drop to raw query(). FilterExpression, FilterComparison and FilterOperator are exported from @setu-ts/common and re-exported from @setu-ts/database-plugin. IRepository.findOne(options?) returns the first match or null through the same one-source path as findAll.

  • RouteInfo.owner. app.router.listRoutes() now reports the name of the plugin whose register() created each route, and leaves it absent for a route added directly by application code — so middleware can derive the paths the plugins own instead of hand-listing them.

  • AuthPluginOptions.rbac is optional. A JWT-only application supplies jwt alone; AuthPlugin then registers jwt and authentication and neither creates nor advertises the authorization capability. Supplying rbac is unchanged in every respect.

  • @Ctx() for decorated controller handlers. The decorator-plugin now injects the active IRequestContext into a decorated handler, so it can configure ctx.response (including status and headers) or return a streaming response without leaving the class-based routing API.

  • Startup warnings for two silent decorator misconfigurations. When a logger is registered, DecoratorPlugin.register() now warns about a class listed in controllers that carries no @Controller metadata (which registers no routes, so every path 404s — usually caused by two copies of the package in one process), and about a custom parameter that no registered resolver can satisfy (which reaches the handler as undefined). Both warn rather than throw, so no working application changes behavior.

Changed

  • A Drizzle instance must now be wrapped in createDrizzleDatabase() before it is injected. DatabaseAdapterOptions.drizzleInstance changed from unknown to the opaque DrizzleDatabaseIdentity, so an unwrapped instance is a compile error, and connect() also rejects one at runtime naming the requirement. This is breaking for every existing type: 'drizzle' configuration.

    Migration — wrap the instance and pass a bridge that calls its own transaction:

    DatabasePlugin({
      type: 'drizzle',
      options: {
        drizzleInstance: createDrizzleDatabase(db, (database, work) => database.transaction(work)),
        drizzleTables: { User: users },
      },
    });

    The bridge is application-supplied rather than inferred because a driver whose transaction callback is synchronous (better-sqlite3) commits before any awaited unit-of-work runs, so accepting one would report atomicity the database never provided. Those drivers are refused by createDrizzleDatabase’s types, which is why the requirement is a compile error rather than a runtime surprise.

  • The kernel refuses a duplicate route instead of silently replacing it. Registering the same method and path twice — from a plugin, a group, or application code — now throws Route '<METHOD> <path>' is already registered. at registration time. Previously the second registration overwrote the first in the router’s entry map, so one of the two handlers became permanently unreachable with no diagnostic: a setu g route todos in a project that already had a @Controller('/todos') shadowed one of them at random depending on load order.

    This is a breaking change for an application that registers duplicates today, and it surfaces at app.start() rather than at the first request. Migration: remove the duplicate registration, or give one of the two routes a distinct path. Distinct methods on one path, and distinct path patterns, are unaffected.

  • IRepository gains a required findOne member. Classes extending BaseRepository inherit the implementation and need no change. A class implementing IRepository directly — without extending BaseRepository — must now implement findOne, which is a compile error until it does. IDataSource.count gained an optional second filter parameter, which existing implementations satisfy unchanged.

  • contains now carries an explicit ESCAPE clause on the Drizzle adapter. The predicate is built with Drizzle’s sql tag as LIKE ? ESCAPE '\' rather than a bare like(). Escaping the pattern alone relies on the dialect’s default escape character, which PostgreSQL and MySQL define as a backslash but SQLite does not define at all — so on a SQLite-backed instance a search whose value contained %, _ or \ would have matched nothing. DrizzleOperators (not exported from the package barrel) accordingly requires sql in place of like.

  • Scaffolded REST-derived templates now answer errors as RFC 9457 Problem Details. The inline errorHandler() the CLI emits passes { format: 'rfc9457' }, matching what the starter factories already did. @setu-ts/exceptions itself is untouched: errorHandler() still defaults to the 'default' format.

  • The Prisma and Drizzle database adapters now execute their drivers, and both require the application to own the client. Both shipped with paths that could not work against a real server, so each is a breaking configuration change rather than a tuning option.

    PrismaAdapter no longer constructs a client. Prisma v7 generates its client into an application-selected output path, which a JSR package cannot locate, and the removed lazy import('npm:@prisma/client') path passed the legacy datasources constructor option that v7 rejects outright. options.prismaClient is now required and is validated at connect().

    Migration: generate the client in the application, construct it, and inject it — DatabasePlugin({ type: 'prisma', options: { prismaClient } }). options.url is still accepted for source compatibility but is no longer a working Prisma connection mechanism; an adapter configured without a client now fails at startup with a message naming the requirement, where it previously failed at the first query.

    DrizzleAdapter now translates every repository operation into real Drizzle builder calls against real columns. It previously selected whole tables and filtered, ordered, paginated and projected them in JavaScript, and passed a fabricated { column: 'id' } object to eq for writes — so update and delete addressed nothing. options.drizzleTables is now required, each registered table must carry an id column, and every field named by where, orderBy or select must be a real column on it. drizzle-orm must be installed alongside the injected instance; the previous silent fallback to placeholder operators is gone, since it produced expressions no driver could execute. create, update and delete read their result from the driver’s RETURNING clause and throw descriptively on a dialect without it, rather than echoing the input back as if it had been persisted.

    Migration: pass a table registry beside the instance — DatabasePlugin({ type: 'drizzle', options: { drizzleInstance: createDrizzleDatabase(db, (database, work) => database.transaction(work)), drizzleTables: { User: users } } }).

  • Generated projects are functional by default, and decorators plus DI are one opt-in. --template rest and --template microservice no longer register DecoratorPlugin, and setu generate now derives its output style from the packages the target project actually holds: a project with @setu-ts/decorator-plugin gets decorated classes, and one without it gets ctx-first routes and plain exported functions. setu generate module is ungated as a result — in a functional project it writes a service plus a src/routes/<name>.routes.ts module the managed routes barrel already registers, so the module serves GET /<name> and POST /<name> (a real 201) with no edit to setu.config.ts. Decorated write handlers now take @Ctx(), which is how they set that status. setu generate service emits a plain exported function plus a src/services/index.ts re-export barrel — a convenience, not a registration: nothing imports it for you, because no plugin option takes a list of functions.

    Nothing about an existing project changes. Style is read from its manifest, so a project scaffolded with decorators keeps generating decorated classes, including one that holds DecoratorPlugin without DiPlugin.

Fixed

  • A stock scaffold now installs, boots, answers its probes, type-checks and formats clean. Four defects, all reproduced against the published alpha.7 packages by building a three-service workspace, and all four passed the drift check that type-checks generated output:

    • deno install on a pristine project failed outright on release day — Could not find version of '@setu-ts/common' that matches '^…' … newer than the specified minimum dependency age. Deno 2.9 refuses a dependency published in the last 24 hours and setu new pins projects to the CLI’s own just-published version, so the window was guaranteed. The generated root now sets minimumDependencyAge.
    • GET /health on a stock --template rest project answered 500 — the path its own generated Kubernetes probes point at. HealthPlugin’s selfIndicator reads runtime.hostname(), which Deno gates behind --allow-sys, and the generated start task never asked for it. The per-template denoPermissions seam already existed and simply had no entry.
    • Every .tsx route in a --template full-stack project failed deno check with 79 TS2686. Deno’s default JSX transform is the CLASSIC one, so a React template must declare jsx itself and this one did not. (This entry originally said that declaring any compilerOptions replaces Deno’s own react-jsx default, making the emitted experimentalDecorators the cause. That mechanism is false — measured and corrected later; see the M63 entry of CLAUDE.md. The fix below is unaffected and correct.) Compiler options are now per template, and full-stack gained the check:app task that reaches route modules deno check main.ts never sees.
    • A fresh workspace failed deno fmt --check on 62 of the 74 files the CLI itself wrote: no fmt configuration was emitted at all, and with one added the .tsx emitters still disagreed. Generated imports are now sorted and wrapped the way deno fmt does, and emitted JSX is single-quoted.

    The gate that keeps them fixed formats, lints, installs, type-checks and boots every template, then requests what the project advertises — deliberately without a blanket permission grant, since a permission the generated task forgot to ask for is unobservable under -A.

  • IRepository.count() on the Drizzle adapter no longer drags the whole match set over the wire. It selected every matching row and measured the resulting array’s length in JavaScript, so counting a million-row table transferred a million rows. It now selects drizzle-orm’s count(*) aggregate and reads the single row the database returns. Every fake-backed test passed either way; the guarded real-Drizzle proof now asserts the emitted SQL contains count(*) and names no columns.

  • A functional project no longer reports its own services as stale. setu generate scans each generated family and refuses to list a file that does not export what the barrel would import, reporting it so the artifact is never silently unwired. The service family has two shapes and the scan used only the class one, so in a functional project every setu generate printed Skipped src/services/x.service.ts: it does not export XService followed by Regenerate it to bring it up to date — for a file the CLI had just written, naming a barrel that does not exist there, with advice that loops (the regenerated file is identical). The scan now selects the spec matching the project’s own style.

Removed

  • --template nest was renamed to --template class-based. The composition is unchanged — the REST set plus DecoratorPlugin and DiPlugin, with a decorated controller and an injected service written for you. The framework has a class-based mode, not a NestJS mode, and the name said otherwise. The old name is refused with a message naming the new one rather than falling into the generic unknown-template error.

  • setu new --di was removed, and is refused with a message pointing at --template class-based. Decorators and dependency injection were independently selectable, which made four compositions out of one axis; two of them — decorators without a container, and a container with nothing decorated to construct — are the incoherent middle. --template full-stack consequently has no DI opt-in from the CLI at all; FullStackStarterOptions.di is still there for an application composing the starter itself.

    Migration: --template rest --di and --template nest --di both become --template class-based; a bare --di has no direct equivalent, and a project wanting a container without decorators should register DiPlugin() in its own setu.config.ts.

0.1.0-alpha.7 — 2026-08-12

A deployment release. A generated workspace now emits the artifacts that ship it — a parameterized Dockerfile, a Compose stack carrying every member plus the broker its transport needs, and a Deployment and Service per member, all regenerated whenever a member is added. This repository gained the same for itself: one image that builds any example, a Helm chart with its rendered manifests committed beside it, and a deployment guide that finally writes down the RBAC the kubernetes service-discovery provider has always needed. A workspace is also no longer Deno-only (--runtime node|bun builds one on npm workspaces), an existing single-service project can become one with setu adopt, members can share code through libs/, and two more transports carry a workspace’s internal traffic.

Deploying for real is what found the defect that would have undermined all of it: nothing handled SIGTERM. A generated main.ts installed no signal handler, so docker stop and every pod eviction killed the process outright — measured at exit 143 after 1 ms, with app.stop() never run: no drain, no service-discovery deregistration, no database or broker disconnect, and a terminationGracePeriodSeconds that did nothing. Fixed in the generator and in every example here.

All 47 packages move as one version, because the CLI stamps its own version as the dependency range for every project it scaffolds. Installs still need an explicit version (jsr:@setu-ts/kernel@^0.1.0-alpha.7) — JSR does not point latest at a prerelease — and Deno refuses dependencies younger than 24 hours unless you pass --min-dep-age 0.

Fixed

  • A scaffolded project now survives SIGTERM. A generated main.ts installed no signal handler, so docker stop and every pod eviction killed it: measured at exit 143 after 1 ms, with app.stop() never run — no drain, no service-discovery deregistration, no database or broker disconnect. M39 found and fixed this in this repository’s own examples and documented the pattern as recommended; the generator kept emitting the defect. main.ts now catches SIGTERM and SIGINT — Deno.addSignalListener (Windows-guarded, where it throws) or process.on for Node and Bun, nothing on Cloudflare Workers, which has no process. Both npm targets declare the type package their process reference needs.
  • setu new --template full-stack served a blank home page. app/routes.ts wraps two flatRoutes groups in layout() calls and neither had an _index, so / matched a layout with no child: <Outlet /> rendered nothing and the server answered 200 with an empty <body> — not a 404, not an error. Measured on a scaffolded project: /products and /login rendered while / returned 2761 bytes of shell and no visible text. M37c found and fixed exactly this in apps/full-stack; the template kept emitting it, because every check requested /products and /login explicitly and nothing ever requested /.
  • Install snippets named versions the workspace no longer shipped. Six of them, with packages/sdk/README.md two releases behind — and a package README is the first thing a new user runs, rendered on jsr.io. Nothing could see it: release:verify reads manifests and cross-package specifiers, not prose, and a stale-but-real version still resolves, so the command works and installs something old. deno task check:docs now compares every @setu-ts specifier in markdown against the shipping version, exempting the changelog and the release runbook, whose old versions are a record; it found two more on its first run. Also corrected: packages/cli/README.md still called the framework Hono Enterprise, the name dropped in v0.1.0-alpha.5, and three github.com/setu-ts/hono-enterprise links 404’d — the repository is setu-ts/setu-ts.

Added

  • Container and Kubernetes artifacts for a generated workspace. setu generate app emits docker/Dockerfile (one parameterized image per member), .dockerignore (at the workspace root, where Docker reads it — without one the host’s node_modules is copied over the one the image installed), docker/compose.yaml (every member plus the transport’s broker) and k8s/members.yaml (a Deployment and a Service per member), all regenerated for the whole workspace whenever a member is added. M39 owns this repository’s own deployment objects; nothing produced any for a user’s project.

  • Containerization and Kubernetes orchestration for the framework itself (M39). The framework could be served on four runtimes and found by an orchestrator, but nothing here showed how to ship it to one: a single collector config was the only deployment artifact in the repository. Now docker/Dockerfile builds any example through one file (--build-arg APP=<name>, rather than fifteen near-copies), docker/Dockerfile.compiled offers a deno compile → distroless variant, docker/compose.yaml runs the stack, and k8s/chart/ is a Helm chart — Deployment, Service, Ingress, ConfigMap/Secret projection, HPA, PodDisruptionBudget, and the ServiceAccount + RBAC — with its rendered manifests committed beside it in k8s/manifests/ and deno task check:deploy failing on any drift between the two.

    docs/deployment.md is the guide, and it documents the thing that was written down nowhere: the exact Role the kubernetes service-discovery provider needs to read EndpointSlices. Two things a reader might expect are corrected there rather than left implied — scratch is impossible, because the compiled binary is dynamically linked against glibc, and the distroless win is 44.9 MB against 52.4 MB rather than an order of magnitude, since the binary embeds the whole Deno runtime. The reason to prefer it is the absent shell, not the size.

    The build context must be the repository root: an example’s deno.json maps only its direct dependencies, so @setu-ts/common reaches kernel through the root workspace, and an image built without the root manifest fails resolution against JSR instead. Three defects only a real cluster surfaced are fixed in what ships: runAsNonRoot refuses a non-numeric image user (both images declare numeric UIDs), an emptyDir at /deno-dir masks the build-time module cache and makes every pod re-resolve from jsr.io at startup, and helm template defaults .Release.Namespace, so the RoleBinding named a ServiceAccount in the wrong namespace and granted nothing while applying cleanly.

  • --transport pubsub and --transport service-bus. Both were previously refused because each needs a value no scaffold can invent. Every transport with a connection value now reads it from the environment with a local fallback, and for these two that fallback is the vendor’s own local-emulator setting — so a scaffolded workspace runs against an emulator unconfigured and against the real service with one variable set.

  • setu generate library <name>. Shared code as a workspace member under libs/, importable by every sibling as @<scope>/<name> with no import-map entry anywhere.

  • setu adopt. Converts an existing single-service project into a workspace holding it as the first member. It moves only the files the CLI emits, so .git, CI configuration and deno.lock stay at the repository root.

  • setu generate app --port <n>. A member can be given a specific port; one another member already binds is refused.

  • --template full-stack as a workspace member. Its Vite build needs node_modules, which only the workspace root may enable, so the root gains nodeModulesDir when such a member arrives — and not before, because with it set an ordinary member’s first deno check materialises every npm package the framework lazily imports.

  • Node and Bun workspaces. setu new --workspace --runtime node|bun builds a monorepo on npm workspaces instead of a Deno one — the framework claims runtime independence, and only the monorepo was Deno-only. The runtime is recorded in setu.workspace.json (absent means deno, so nothing existing changes) and every later command reads it back: root manifest shape, environment reads in generated source, library manifest and test runner, base image, and install and run commands. Cloudflare Workers is refused, because each Worker is its own deploy unit.

  • A proto toolchain for --transport grpc members. An example proto, both buf manifests and a proto:gen task. Both the compiler and the codegen plugin run through Deno’s npm compatibility, so nothing needs buf or protoc on a PATH.

Changed

  • A workspace transport’s connection value is an environment read, not a literal. A generated member’s MessagingPlugin wiring was url: 'redis://127.0.0.1:6379', which is unreachable from inside a container — two Compose services do not share a loopback interface, so the member dialled its own container. It is now Deno.env.get('REDIS_URL') ?? 'redis://127.0.0.1:6379'.
  • The generated discovery map reads each sibling’s host from <MEMBER>_HOST, falling back to 127.0.0.1, for the same reason: inside a container loopback is the container itself, so a fixed address had every member dial ITSELF on its sibling’s port. The generated Compose stack and Kubernetes objects set those variables to the service names.
  • A new workspace’s root declares both member globs (./apps/* and ./libs/*) at creation, so neither a service nor a library ever rewrites it. A workspace created earlier gets the second glob added when its first library arrives.

0.1.0-alpha.6 — 2026-08-11

A generator release. Eleven of the fourteen artifacts setu generate emits now reach a registration site with no edit to a file you own — the other three have a stated reason they have none. Decorators and dependency injection became independent choices, a repository can hold more than one deployable service, and Cloudflare Workers gained the last capability it was missing. Alongside that: a documentation hub with nine curated guides, RFC 9457 Problem Details, and an OpenAPI document derived from the guards that enforce authentication.

All 47 packages move as one version, because the CLI stamps its own version as the dependency range for every project it scaffolds. Installs still need an explicit version (jsr:@setu-ts/kernel@^0.1.0-alpha.6) — JSR does not point latest at a prerelease — and Deno refuses dependencies younger than 24 hours unless you pass --min-dep-age 0.

One behavior change to already-generated code: setu generate controller emitted a controller that answered 500 on every request, in every release since the CLI first shipped in v0.1.0-alpha.2. The fix changes the shape of what it emits — see Changed below for the migration.

Added

  • Workers-native messaging: the last edge capability gap (M59). cloudflare-plugin already served QUEUE, CACHE, STORAGE, DATABASE and REALTIME_BACKPLANE on Cloudflare Workers. CAPABILITIES.MESSAGING was the one token it could not: all ten messaging-plugin brokers need a socket or a socket-bound SDK. A new messaging arm serves it from the platform itself.

    app.register(
      CloudflarePlugin({
        env,
        messaging: { binding: 'MESSAGES', rpc: { binding: 'REPLY_INBOX' } },
      }),
    );
    
    // Consuming is a MODULE export — no plugin option can declare one.
    export default { fetch: app.fetch, queue: createMessagingHandler(app) };

    publish is a Queues producer call; subscribe registers into a dispatch table the queue export drives, matching InMemoryBroker’s fan-out and round-robin group semantics. Two limits are documented rather than papered over: Cloudflare allows exactly one active consumer per queue, so cross-service fan-out needs one queue per consumer, and a publish nobody subscribed to is acked rather than retried — retrying ordinary pub/sub would dead-letter every fire-and-forget message.

    request/respond ship behind the opt-in rpc arm. A queue reaches its one consumer Worker and never the caller, so the reply travels through a Durable Object the caller holds a WebSocket to while its request is in flight (ReplyInboxObjectCore, which the application exports as its own DO class). Without the arm both throw, naming the binding to add. A queue carrying RPC must set max_batch_timeout = 0: the platform default of 5s alone exhausts the default reply budget.

    CloudflareRequestTimeoutError and CloudflareRemoteHandlerError mirror messaging-plugin’s two RPC errors as distinct classes, because §2.2 forbids a plugin importing another plugin. Exactly one provider of CAPABILITIES.MESSAGING can be registered, so which to catch is never ambiguous.

  • setu new --template microservice --runtime cloudflare-workers (M59). The template refused that target unconditionally. The refusal was right about MessagingPlugin and QueuePlugin needing raw sockets and wrong about the capabilities, which the platform serves itself. A new declarative TemplateDefinition.runtimeSwaps replaces those two with CloudflarePlugin on Workers only, and contributes the queue module export, the Durable Object class, and the wrangler stanzas — including max_batch_timeout = 0. The other three runtimes are byte-identical. Because Cloudflare invokes ONE queue export for every consumed queue, the emitted handler routes on the queue name and both queues get a consumer: one handler for both would feed the messaging broker its job batches, and an unconsumed producer discards every IQueue.add() silently. TemplateDefinition.unsupported and its refusal branch are removed: microservice held the last entry, so both became unreachable. CLI-internal, never a published export.

  • Monorepos: one repository, many deployable services (M62). The CLI had no workspace concept at all, so a second service meant setu new other --dir . — a fully independent project with its own manifest, its own lockfile, and no knowledge of its sibling. The sharp edge was discovery: the microservice template wires ServiceDiscoveryPlugin({ provider: 'static', services: {} }) with a deliberately EMPTY map, because a sample entry would have named a dead port, so every caller’s map was hand-edited in every service and nothing propagated a new name.

    setu new acme --workspace                          # the root, no member yet
    cd acme
    setu generate app orders --template microservice   # apps/orders, port 3000
    setu generate app billing --template microservice  # apps/billing, port 3001
    deno task dev                                      # runs every member

    A workspace is a Deno workspace whose root declares members by GLOB ("workspace": ["./apps/*"]), so adding a service creates a directory and rewrites no manifest — no file you own is ever edited. Each member is an ordinary scaffolded project with its own framework pins, because plugin detection reads one directory’s manifest and never walks up; two members may install different plugin sets.

    Adding a service registers it with its callers. Every member carries a CLI-owned src/discovery/services.ts, regenerated for all members on each setu generate app, exporting SERVICE_PORT (its own) and SERVICE_ENDPOINTS (every sibling). The member’s main.ts binds the former and its setu.config.ts hands the latter to ServiceDiscoveryPlugin, so the port a member binds and the port its siblings dial are one datum and discovery.resolveUrl('billing') works from any sibling with no configuration. The map is the LOCAL development topology; a deployed one comes from a real backend (consul, kubernetes, dns).

    The workspace chooses how its services talk. --transport http|grpc|memory|redis|rabbitmq| nats|kafka is recorded in setu.workspace.json and inherited by every member, because services can only meet on a bus they share; generate app refuses the flag and names the workspace-level one. http stays the default, so an upgrade changes nothing. This closes a silent failure the workspace itself made reachable: the microservice template registers MessagingPlugin(), whose default broker is in-process, so two generated services publishing and subscribing on one topic exchanged nothing while both reported success. --transport tcp is refused with an explanation rather than aliased to HTTP — there is no raw-TCP transport here.

    Refusals rather than silent surprises: generate app outside a workspace names setu new <name> --workspace; a duplicate member names the directory it already has; a non-Deno --runtime names the standalone alternative; --template full-stack is refused because its Vite build needs nodeModulesDir, which Deno accepts only in a workspace ROOT; and new --workspace refuses --template because a root registers no plugins.

  • Decorators and DI are independently selectable in the generator (M61). AI_GUIDELINES states that decorators are optional, DI is optional, and that no feature requires either — but the CLI offered one coarse control. No template gave you neither and refused g controller/g module; rest/microservice gave decorators without a container; only nest gave both, along with a worked NestJS-style example you may not have wanted.

    setu new --di adds DiPlugin to any template, so a container is now a choice of its own:

    setu new app --di                       # a container, no decorators
    setu new app --template rest --di       # decorators and a container
    setu new app --template nest --di       # a no-op — nest already registers DiPlugin

    It changes the composition, never the generated source: DecoratorPlugin branches on the container’s presence, so the same @Injectable class works either way and what changes is the lifecycle it gets. On --template full-stack the flag reaches the starter’s own di arm rather than a plugin wiring, because a starter-composed template owns its whole plugin set. Adding it to a template that already registers DiPlugin is deliberately a no-op — the kernel refuses a duplicate plugin name at start(), so a second registration would scaffold a project that type-checks and then cannot boot.

  • setu generate route is now a first-class decorator-free path. A project scaffolded with no template registers the runtime plugin alone, so g route is the only HTTP handler it can generate — and it used to land unwired: the schematic wrote src/routes/<name>.routes.ts and a src/routes/index.ts barrel while the generated setu.config.ts imported neither, so the route answered 404 until you edited the config by hand. The no-template path is now a seam host for the three families that need no plugin (route, middleware, plugin), so a generated route, middleware or plugin is wired from scaffold time exactly as it is under --template rest.

    Existing projects are unaffected — nothing rewrites a scaffolded setu.config.ts. Each barrel’s header states the two lines to add; add them once and every later generate is wired.

  • Generated code is now wired (M60). setu generate emitted fourteen artifacts and exactly one of them — the M58 domain module — reached a registration site. The other thirteen compiled and did nothing: g service emitted a class nothing constructed, g health-indicator an indicator nothing registered, g command-handler a handler no bus dispatched to. Eleven now reach a registration site with no edit to a file you own, and three are documented as having none.

    Each wired schematic emits its artifact plus a CLI-owned index.ts seam barrel for its family, and the rest, microservice and nest templates scaffold a setu.config.ts that already imports every barrel they can consume. See PUBLIC_API “Generated code is wired” for the per-schematic table.

    setu new shop --template microservice
    setu g health-indicator external-api --dir shop   # appears in GET /health
    setu g metric orders-placed --dir shop            # appears in GET /metrics at boot
    setu g command-handler create-user --dir shop     # the command bus dispatches to it

    guard, job and migration are deliberately unwired, and their emitted JSDoc now names the real call instead of implying a site is waiting: a guard belongs on one route (a global one would answer 401 for /health), a job’s transport is a choice between queue.process and the scheduler that the artifact cannot make for you, and nothing in the framework reads migration files — there is no setu db:migrate.

  • CqrsPluginOptions.commandHandlers / .queryHandlers, and EventsPluginOptions.handlers — declarative handler registration, as { type, handler } pairs. Pure additions: omitting them behaves exactly as before. The events option subscribes through the same exported subscribeHandler a caller would use by hand, so the two routes cannot diverge. Needed because IApplication exposes no lifecycle hook, so application code has no phase in which to reach a bus that does not exist until its plugin has registered. CommandHandlerRegistration, QueryHandlerRegistration and EventHandlerRegistration are exported alongside them.

  • NamedMetricConfig is exported from @setu-ts/metrics-plugin. MetricsPluginOptions.customMetrics is typed as an array of it, so without the export that option could take an inline literal but a caller could not declare its own array in a variable.

  • CqrsPlugin and EventsPlugin join setu new --template microservice. Both are in-memory and construct with no configuration, so the tier’s rule that a scaffolded plugin needs no credentials holds, and neither needs a socket so the Cloudflare Workers refusal is unchanged. They are also the only host a scaffolded project can have for g command-handler, g query-handler and g event-handler, all three of which were gated on plugins no template installed.

  • A documentation hub, and gates that keep it true (M38). Nine curated guides under docs/ — getting started, plugin architecture, the plugin catalog, the programmatic API, decorators, writing custom plugins, migrating from NestJS and from Fastify, the examples index, and runtime deployment — plus a reproducible deno doc API-site generator (deno task docs:api). Every package README now links to its own PUBLIC_API.md section.

    The guides are mechanically checked rather than trusted: committed fixtures representing all nine are type-checked against the workspace, a Markdown gate validates the package catalog and every cross-file anchor, and a JSDoc lint ratchet freezes the measured diagnostic count so documentation debt can only be paid down, never added to. A below-baseline run fails and names the constant to lower. No package source, manifest export, capability token, or plugin option changed.

    PUBLIC_API.md section anchors now carry their package name, which breaks external deep links (#storage is now #storage-setu-tsstorage-plugin).

Fixed

  • Every Cloudflare Worker misdetected its own runtime as node (found while booting a CLI-scaffolded Workers project in M59). detectRuntime() tested navigator.userAgent.includes('cloudflare') — lowercase — and workerd reports 'Cloudflare-Workers', so the check never matched and detection fell through to 'node' on every real deployment. That answer selects the runtime adapter, so a Worker built through RuntimePlugin() ran the Node adapter on Cloudflare, and the cloudflare health indicator reported degraded with a misleading detail. It also silently disabled every runtime.platform() === 'cloudflare-workers' guard, including messaging-plugin’s cloud gate — so Pub/Sub and Service Bus attempted their gRPC/AMQP SDK load instead of failing with the named CloudBrokerUnavailableError.

    The comparison is now case-insensitive. No test caught this because the unit fakes sent 'cloudflare-workers/v1' and 'cloudflare', strings the platform never sends — a test double that violated the real contract, so the suite tested the double. The fakes now use the real string, and apps/cloudflare asserts detectRuntime() against real workerd in its smoke, which is the only place the platform sends its own user agent. Both were verified to fail without the fix.

  • A mistyped Queues binding now fails at register(), not at the first send (M59). BindingRegistry.queue() cast its binding unvalidated, so a missing [[queues.producers]] stanza or a name typo let an application boot clean, report up from the cloudflare health indicator, and fail on the first add() with a bare TypeError pointing at nothing. A new isQueueProducer guard closes the last hole in that family — the same defect M52c fixed on D1 and M52d on Durable Objects. Behaviour change for anyone whose queue binding was already wrong: the failure now arrives at startup, naming the binding.

  • setu new --runtime cloudflare-workers produced a project that could not be built or deployed. wrangler bundles src/index.ts with esbuild, which resolves neither jsr: specifiers nor a Deno import map — and the Workers target declared its framework packages only in deno.json, emitting no package.json and no .npmrc. So the flow the CLI itself prints, npm install && npx wrangler dev, failed with one Could not resolve "@setu-ts/…" per package. There was nothing to install.

    Workers projects now also emit package.json (the npm-compat @jsr/… dependencies, plus wrangler pinned in devDependencies and dev/deploy scripts) and .npmrc. Verified against real workerd through wrangler dev: a scaffolded project serves /, /health, /metrics, and every generated route, controller and module. The Deno target deliberately still gets no package.json — that would switch it to node_modules resolution.

    Existing Workers projects are not rewritten. Add a package.json declaring the same @setu-ts/* packages your deno.json lists, using their npm:@jsr/setu-ts__<name> form, plus an .npmrc containing @jsr:registry=https://npm.jsr.io.

  • setu new --runtime node could not run any decorated code. Generated Node projects started with node --experimental-strip-types main.ts, and Node’s built-in TypeScript support erases types without transforming code — so a legacy decorator was a bare SyntaxError: Invalid or unexpected token, and the constructor parameter property setu generate module emits was ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. In practice a scaffolded Node project booted until the first setu generate service, generate controller or generate module, and setu new --template nest --runtime node never booted at all. Deno, Bun and Cloudflare Workers were unaffected.

    Node projects now declare tsx in devDependencies and start with tsx main.ts, which reads the experimentalDecorators the generated tsconfig.json already sets. --experimental-transform-types was evaluated and rejected: it handles the parameter property but still refuses the decorator, because it does not enable experimentalDecorators. No other target carries the dependency — Bun compiles TypeScript outright, and Deno and Workers never invoke it.

    Existing Node projects are not rewritten. To pick this up, add tsx to your devDependencies and change the start script from node --experimental-strip-types main.ts to tsx main.ts.

Changed

  • The controller and module gate refusals now name setu generate route as the decorator-free alternative. The gate itself is unchanged (those schematics emit @Controller, so an ungated project would get source whose own import cannot resolve), but refusing with only “install @setu-ts/decorator-plugin” read as though decorators were required to serve HTTP, which is the opposite of what the framework promises.

    The "controller" schematic requires @setu-ts/decorator-plugin, which is not installed in /path/to/app.
    Install it, then run this command again.
    Or run `setu generate route user-profile` — it registers handlers on the router API, so it needs no decorators.
  • setu generate plugin now writes src/plugins/<name>.plugin.ts, not src/plugins/<name>.ts. The seam barrel is regenerated from a directory scan, and a suffix of .ts would admit any module you hand-wrote in that folder — the barrel would then import a <Pascal>Plugin symbol you never wrote, and your project would fail to compile naming a file you never generated. Existing generated files are untouched; only new generates take the new path.

  • setu generate service emits an @Injectable when @setu-ts/decorator-plugin is installed, registered under the token <name>-service and listed in src/services/index.ts. Without that package the output is unchanged, byte for byte, and the schematic stays ungated — so it keeps working in a project with no plugins at all.

  • An artifact generated before its family gained a second export is skipped and reported, rather than listed in a barrel that cannot compile. middleware gained a <SCREAMING>_MIDDLEWARE_PRIORITY constant and metric a <SCREAMING>_METRIC declaration in this release; an artifact generated earlier has the right filename and lacks that export, so a barrel regenerated over it named a symbol the file did not have and the project stopped compiling — from a command that reported success. The scan now admits a file only when it exports everything the barrel will name, prints what it skipped and why, and tells you to regenerate. The same rule keeps a hand-written module in a scanned directory out of the barrel.

  • setu generate refuses a name that would collide with an existing artifact (exit 1), naming the conflict and the consequence. route, controller and module all mount /<name>, and the kernel’s router keys routes by method and path — so a duplicate silently overwrites and one artifact becomes unreachable. service and module both register @Injectable({ token: '<name>-service' }), and the decorator plugin keeps the first class under a token — so the wrong service would be injected, which was observed as a 500 on every request to the affected module. Both checks apply only when decorator-plugin is installed, since neither collision can exist without it.

  • Fixed: setu generate controller emitted a controller that answered 500 on every request. DecoratorPlugin builds a handler’s argument list from parameter metadata alone and never passes the request context positionally, so the emitted list(ctx: IRequestContext) received undefined and threw on the first ctx.response. Handlers now take only decorated parameters and return plain values, which the plugin serializes as JSON; create takes @Body(). The 201 on create is gone rather than faked — a decorated handler cannot set a status code, so a handler that needs the context belongs on app.router.get(...) (setu generate route).

    Regenerate any controller produced by an earlier release, or drop its ctx parameter and return a plain value. Shipped alongside the module schematic below because it is the same package and the same one-line class of defect.

  • setu generate module <name> scaffolds a whole domain sub-module and wires it in (M58), instead of requiring g controller + g service plus a hand edit of setu.config.ts. Emits an @Injectable service, a @Controller injecting it by token, a service test, a per-module barrel, and a regenerated aggregate barrel at src/modules/index.ts exporting MODULE_CONTROLLERS / MODULE_SERVICES. The rest, microservice and nest templates now scaffold a setu.config.ts that already imports both and passes them to DecoratorPlugin, so nothing the developer owns is ever edited by the CLI.

    setu new shop --template rest
    setu g module orders --dir shop   # wired; no edit to setu.config.ts

    Gated on @setu-ts/decorator-plugin, like g controller. --template full-stack is not a host: its layering is routes → features → services and it has no src/modules/ concept. A project scaffolded before this release adds the barrel import once; every later g module is automatic.

    A directory counts as a module only when it holds both canonical files, so an unrelated folder under src/modules/ is skipped instead of producing a barrel that imports files which do not exist. The host templates declare @std/testing and @std/expect (a deno.json import on Deno/Workers, an npm:@jsr/std__* alias on Node/Bun), so the emitted test runs with no further setup.

    GeneratedFile gains an optional managed flag and SchematicOptions an optional modules list. Both are additive — existing custom schematics compile unchanged. A managed file is exempt from the overwrite refusal, which previously covered every path without exception; only src/modules/index.ts is managed today, and the exemption is per file rather than a --force flag so a mistyped g service still cannot clobber hand-written work.

  • The OpenAPI document can be derived from the guards that enforce authentication (M57), instead of requiring every route to declare a requirement a second time. @setu-ts/common gains a SECURITY_METADATA symbol, a RouteSecurityMetadata type, and the pure withSecurityMetadata / securityMetadataOf helpers; every guard @setu-ts/auth-plugin ships is branded with them, and @setu-ts/openapi-plugin reads the brand off a route’s middleware.

    app.register(
      OpenApiPlugin({
        securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } },
        deriveSecurity: { scheme: 'bearerAuth' },
      }),
    );
    
    app.router.get('/todos/:id', { middleware: [requireAuth()], handler }); // → requires bearerAuth
    app.router.post('/login', { middleware: [publicRoute()], handler }); // → public

    Opt-in and non-breaking. Without deriveSecurity nothing is derived and the document is byte-identical, and a requirement declared on schema.security always wins over a derived one. The brand is symbol-keyed and non-enumerable, so guard identity and behaviour are unchanged; Symbol.for is used so two copies of common in one process resolve the same key.

    Three limits are documented rather than left to discovery: only route-level middleware is inspected (app.middleware.add() is invisible to a route, which is correct for authMiddleware() — it populates the principal and never rejects); roles and permissions cannot be expressed, because an OpenAPI requirement names a scheme and none can be inferred from 'admin'; and the scheme name is configured rather than inferred, with an undeclared name refused at register().

  • RouteSchema.security and a document-level security option describe which operations need authentication. @setu-ts/openapi-plugin accepted securitySchemes and emitted them under components, but nothing ever declared a requirement — OpenApiOperation.security existed in the generator’s types with no assignment anywhere — so no operation was marked protected and generated clients had no signal that a route needed a token.

    RouteSchema in @setu-ts/common gains an optional security, alongside the tags and summary it already carried, plus a new exported SecurityRequirement type. The addition is optional, so existing routes and existing implementors are unaffected.

    app.register(
      OpenApiPlugin({
        securitySchemes: {
          bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
        },
        security: [{ bearerAuth: [] }], // document-level default
      }),
    );
    
    app.router.post('/login', { schema: { security: [] }, handler }); // explicitly public

    An empty security array is meaningful and is not the same as omitting the field: per the OpenAPI specification it declares the operation public, which is how a route opts out of the document-level default. Omitting the field leaves the operation inheriting it. Declaring this enforces nothing — authentication is still enforced by middleware and guards; this describes the route for documentation and client generation.

  • OpenApiPluginOptions.exclude keeps operational endpoints out of the document. Paths are matched exactly against the fully-resolved router pattern — router-style (/todos/:id, not the OpenAPI template /todos/{id}) and including any router.group() prefix — and every method on a matched path is omitted.

  • @Public reaches the OpenAPI document. A decorated route marked public is now documented with an empty security array, so it opts out of a document-level requirement. Without this the opt-out was reachable only from a programmatic schema, and a decorated login route would have been documented as requiring the token it issues. @Roles/@Permissions are deliberately not mapped: a role is not a security scheme, and no declared scheme can be inferred from one.

  • A security requirement naming an undeclared scheme is refused at register(), naming the offending scheme and the declared ones. Emitting it produced a document that is invalid per the specification — Swagger UI renders a lock on every operation with no Authorize button to satisfy it, and strict validators and client generators reject it — while the spec endpoint still answered 200, so nothing downstream could detect it.

Fixed

  • The OpenAPI document no longer lists its own delivery endpoints. GET /openapi.json and GET /docs were generated as API operations, so every consumer of the spec — Swagger UI readers and generated clients alike — was handed the documentation machinery as part of the API. Both are now excluded automatically, honoring endpoint/specEndpoint when they are customized. The routes are still served; only the document entries are gone.

  • Path parameters are typed as strings instead of rendering as any. A path parameter with no entry in the route’s params schema was emitted as schema: {}, which OpenAPI reads as “any type” — Swagger UI rendered an untyped box and client generators produced unknown arguments. Every path segment arrives as a string, so an undescribed path parameter now defaults to { type: 'string' }. A declared params schema still wins, per parameter.

Changed

  • Problem Details move from RFC 7807 to RFC 9457 (M56). RFC 7807 was obsoleted by RFC 9457 in July 2023, and the framework advertised the withdrawn specification in two packages, a public format alias, an exported symbol in each, the three starters, and every documentation site.

    @setu-ts/exceptions and @setu-ts/validation-plugin each gain an 'rfc9457' format arm and an rfc9457Formatter export. 'rfc7807' and rfc7807Formatter are deprecated, not removed (AI_GUIDELINES §9.2), and are scheduled for removal in v1.0.0.

    RFC 9457 changed very little on the wire — Appendix D lists three changes, none touching the five core members or the application/problem+json media type — so the bodies were already structurally valid. One 7807-era habit did need correcting, and that is the only behavior change here.

    ⚠️ Breaking: type is now about:blank for status-only problems. @setu-ts/exceptions previously minted a URI from the status code for every error (https://setu-ts.dev/errors/404), which identifies nothing the status member does not already carry. RFC 9457 §4.2 registers about:blank for precisely that case, and that is what the 'rfc9457' format now emits. Clients matching on type to distinguish errors should read status instead. The one error carrying an extension member, validationError(), keeps a concrete type URI — https://setu-ts.dev/errors/validation, the same URI @setu-ts/validation-plugin emits for the same problem type.

    // Before                                   After
    { "type": "https://setu-ts.dev/errors/404", { "type": "about:blank",
      "title": "Not Found",                       "title": "Not Found",
      "status": 404,                              "status": 404,
      "detail": "User 42 does not exist" }        "detail": "User 42 does not exist" }

    Two escape hatches, in order of preference: read status, which is what it is for; or keep the deprecated format: 'rfc7807', which is unchanged and still emits the status-derived URI — a deprecated symbol must not silently change behavior (§9.4).

    @setu-ts/validation-plugin has no wire change at all: its type was always a semantic URI rather than a status-derived one, so rfc7807Formatter there is a deprecated alias bound to the same object and the emitted body is byte-identical.

    The three starters (rest, microservice, full-stack) now compose errorHandler with format: 'rfc9457', so an application built on one of them picks up the new type on upgrade. Applications wiring errorHandler themselves are unaffected until they change the format string.

Fixed

  • The Problem Details media type survives a second formatter. Both packages keyed application/problem+json off a single formatter reference, so that passing a formatter directly (format: rfc9457Formatter) agreed with passing the alias (format: 'rfc9457'). Adding a second formatter to that check without generalizing it would have served a Problem Details body as application/json — which generic problem-details clients ignore — while the string alias tested fine. The check is now a membership test over every Problem Details formatter, covered by tests that drive each spelling by reference.

  • ARCHITECTURE.md documented a type URI the code never emitted. The Problem Details example showed https://setu-ts.dev/errors/not-found; the formatter emitted https://setu-ts.dev/errors/404. Corrected along with the rest of the section.

0.1.0-alpha.5 — 2026-08-08

This release renames the project and moves every package to a new JSR scope. It is the first release published under @setu-ts; the @hono-enterprise packages are archived and receive no further versions. Because the scope changes, every consumer must update their imports and their manifest — there is no upgrade path that leaves specifiers untouched.

Two things about a prerelease under a brand-new scope, both of which will otherwise surprise you: JSR does not point latest at a prerelease, so every install instruction must carry an explicit version (jsr:@setu-ts/kernel@^0.1.0-alpha.5); and Deno refuses dependencies younger than 24 hours unless you pass --min-dep-age 0, which affects the maintainer verifying the release rather than ordinary users.

The project is renamed from Hono Enterprise to Setu-TS, and every package moves to a new JSR scope. The old name asserted an association with the Hono project that does not exist: this framework is not built, endorsed, or maintained by the Hono team, and its actual use of Hono is one file — the kernel’s router delegates matching to jsr:@hono/hono. That dependency is unchanged and unaffected. The rename removes the false signal, nothing else.

⚠️ Breaking 1 of 5: every import specifier changes. @hono-enterprise/<pkg> becomes @setu-ts/<pkg>, published under the @setu-ts JSR scope. On the npm compatibility path, @jsr/hono-enterprise__<pkg> becomes @jsr/setu-ts__<pkg>. This is a find-and-replace across your imports and your manifest; no API changes with it. The @hono-enterprise packages are archived on JSR — hidden from search and closed to new versions, but every published version stays installable and existing pinned builds keep resolving. They are deliberately not yanked: yanking signals a defective release and would break range resolution, and these versions are superseded rather than broken.

⚠️ Breaking 2 of 5: existing session cookies are invalidated. The HKDF info parameters behind @setu-ts/session-plugin carried the old project name, so the derived encryption, signing, and key-id values all change. Every previously issued cookie fails to open and is treated as absent, signing users out once. No configuration changes and no key rotation is required — this happens on deploy and does not repeat.

⚠️ Breaking 3 of 5: the realtime backplane default topic changes. DEFAULT_TOPIC moves from hono-enterprise.realtime to setu-ts.realtime, so replicas on either side of the upgrade do not see each other’s frames. Restart all replicas together rather than rolling them, or pin the old value explicitly via the topic option to upgrade in stages. This mirrors the alpha.3 request-reply wire change; if you do not use the backplane, nothing here applies to you.

⚠️ Breaking 4 of 5: RFC 7807 type URIs change. The error-document base moves from https://hono-enterprise.dev/errors to https://setu-ts.dev/errors, affecting every Problem Details body from @setu-ts/exceptions and @setu-ts/validation-plugin. Clients matching on the type field need updating. Per RFC 7807 these URIs are identifiers and are not required to resolve.

⚠️ Breaking 5 of 5: the CLI binary is renamed. honoe becomes setu — setu new, setu generate, and so on. Reinstall the CLI to pick up the new executable name; scaffolded projects are otherwise unchanged apart from the scope in their generated manifests.

Two further identifiers change with the rename and are noted for completeness rather than as breaking: the exported SESSION_STATE_KEY is now setu-ts:session, and the GitHub repository moved to setu-ts/setu-ts. Released sections below this one deliberately retain the @hono-enterprise names, because they record what those releases actually shipped.

Added

  • @setu-ts/static-plugin — static file serving as a capability (M55). Registers IStaticFiles under a new CAPABILITIES.STATIC_FILES token and mounts one handler on both GET and HEAD.

    The framework previously had exactly one static file server, inside react-router-plugin, written for content-hashed SSR bundles: an unconditional immutable Cache-Control on every response, no directory-index resolution, no conditional requests, and a whole-file read into memory. That handler is unchanged and still correct for its job; this package is for everything else.

    Ships configurable cacheControl (a string, or a function receiving the root-relative path, defaulting to immutable-for-hashed and must-revalidate otherwise), index and fallback as separate options, conditional requests, single-range 206/416, .br/.gz sidecar negotiation, a static-files health indicator, and streaming for files above maxBufferBytes.

    The SPA fallback fires only when Accept includes text/html. Without that guard a missing .js returns the HTML shell under a JavaScript content type, which browsers surface as an opaque syntax error.

    ETag is strong ("<size>-<mtimeMs>") when the runtime reports an mtime, and degrades to a weak size-only validator when it does not. This is load-bearing rather than cosmetic: If-Range MUST be ignored for a weak validator (RFC 9110 §13.1.5), so a weak ETag makes every interrupted download restart from byte zero. size+mtime is what nginx and Apache emit as strong for static files.

    On Cloudflare Workers runtime.fs is absent, so the plugin registers its capability, reports degraded, and mounts no route. Use Workers Assets or R2 via @setu-ts/cloudflare-plugin there.

  • IFileSystem.readStream?(path, { start, end }) in @setu-ts/common — optional and additive, so no existing implementor breaks and every current caller is untouched. end is inclusive, matching both node:fs and the Range wire format, so no off-by-one translation exists. Implemented by the Node, Deno, and Bun runtime adapters and omitted on Workers, where callers degrade to a whole-file read exactly as they already do for realPath.

  • contentTypeFor, isLexicallyContained, assertRealPathContained in @setu-ts/common — pure helpers shared by static-plugin and react-router-plugin, which now delegates to them. Its emitted headers are unchanged and pinned by a regression test.

  • @Optional constructor-parameter decorator in @setu-ts/decorator-plugin. Pairs with @Inject on the same parameter (either order) and injects undefined when that token has no provider, so a class can depend on a capability the application may not have registered without the author hand-writing a container lookup.

    It means the dependency is absent, not that construction may fail: a token that IS provided is resolved normally, so a circular dependency or a throwing factory still surfaces instead of becoming undefined. Both construction paths honor it identically — the DI container when one is registered, the kernel’s service registry otherwise.

    Three misuses are refused rather than silently misinjected: @Optional with no @Inject on the same parameter, @Optional combined with the deprecated class-level @Inject(...) list, and @Optional on a method parameter.

    On the container path a class carrying @Optional now registers as a useFactory provider instead of useClass, because ClassProvider.inject is a bare token list with nowhere to record optionality; its own scope is still honored, but its dependencies resolve from the registering container rather than the resolving scope. Classes without @Optional are unchanged. No common contract change and no new capability token.

  • apps/full-stack — a runnable React Router 8 SSR example (M37c). The framework’s full-stack story shipped in three places — the SSR plugin, the full-stack starter, and honoe new --template full-stack — and none of them had an application a reader could run. This one is served by the kernel through react-router-plugin, composed through createFullStackAppFromConfig, and its smoke task asserts that an SSR-rendered page contains rows written through the database capability — evidence that populateLoadContext bridges the kernel’s service registry into a React Router loader, rather than that a server started. It then signs in through a <Form>, so the session and its synchronizer CSRF token round-trip too.

    Its routes are / (a landing page that reports session state), /products and /login.

    The example also makes the framework’s distinguishing claim executable: test/removal.test.ts asserts that none of lib/{session,csrf,sse,kv,service-logger}.server.ts exists, and that app/config/services.server.ts holds no module-level cache — because the kernel’s service registry is that cache.

    The frontend build runs for real in CI, with no Node toolchain. deno install plus the @react-router/dev CLI run the identical Vite build under Deno’s own npm support (measured: ~4 s install, <1 s build), so no ServerBuild fixture is committed and full-stack is deliberately not in ALLOW_SKIP. No published package changed; the frontend build remains an app-level, build-time concern outside every published dependency graph (AI_GUIDELINES §12.2).

  • @setu-ts/messaging-plugin — GCP Pub/Sub (GcpPubSubBroker) and Azure Service Bus (ServiceBusBroker) backends implementing IMessageBroker with request-reply over a shared reply topic + per-instance subscription. MessagingPluginOptions is now a discriminated union on broker with a 'custom' arm (inject any IMessageBroker) and a default memory arm so MessagingPlugin() / MessagingPlugin({}) remain valid. MessagingBrokerType widened to 8 literals. Both cloud brokers throw CloudBrokerUnavailableError on Cloudflare Workers. Verified against the vendors’ own local emulators — Google’s Pub/Sub emulator and Microsoft’s Service Bus emulator — covering publish/subscribe over real gRPC/AMQP, ack/nack settlement producing genuine redelivery, receiver teardown, and on Pub/Sub the RPC reply subscription’s create/delete cycle. See docs/messaging-emulators.md. Service Bus RPC is unverified: that emulator supports no management operations, so the per-instance reply subscription cannot be created there — the suite asserts the refusal surfaces ReplyInboxUnavailableError instead. Neither backend has run against a live cloud account.

  • @setu-ts/queue-plugin — SQS SqsQueue adapter (QueueAdapter seam, wrapped by QueueService) with per-name queue URLs, receipt-handle bookkeeping, ApproximateReceiveCount attempt ladder, visibility-timeout backoff, and dead-letter ordering. SnsPublisher for SNS fan-out. QueueAdapterType widened to include 'sqs'. QueueBackendUnavailableError thrown on Cloudflare Workers. The SQS adapter is verified against ElasticMQ in CI (that suite drives SqsQueue directly; the QueuePlugin → QueueService wiring is covered separately by sqs-arm-integration.test.ts over a contract-honouring transport fake). SNS is fake-driven.

Changed

  • Node and Bun compatibility is now verified on every pull request, retiring the known limitation recorded in 0.1.0-alpha.1. The Node compatibility and Bun compatibility CI jobs were placeholders blocked on the first JSR publish; they now install the published packages through JSR’s npm compatibility layer and run compat/compat.test.mjs. All 46 published packages are installed and imported on each runtime — a package whose ESM output or transitive dependency does not resolve there is broken for every consumer and nothing else in CI would see it — and the suite then boots a kernel application, resolves a capability, and serves a request over a real socket. The expected package list is derived from the Deno workspace, so a new package that is never added to the compat suite fails the job rather than quietly shrinking coverage. The suite tracks the latest published release rather than HEAD, because Node and Bun cannot resolve this repo’s jsr: and npm: specifiers from source.
  • ⚠️ Breaking 1 of 1: MessagingPluginOptions is now a discriminated union. A caller holding a widened variable (e.g. let opts: MessagingPluginOptions = getOptions()) must narrow before passing to the factory. Single-arm literals, MessagingPlugin(), MessagingPlugin({}), and the factory’s own = {} default are unaffected. MessagingBrokerType includes 'pubsub', 'service-bus', and 'custom'.
  • @setu-ts/queue-plugin — the INTERNAL QueueAdapter seam gained a claimToken argument on ack/requeue/deadLetter, and StoredJob an optional claimToken?. It identifies one delivery, so an adapter can refuse a settle belonging to a superseded one — SQS needs this because a requeued message returns with a new ReceiptHandle. Adapters without a transport-level claim (memory, redis, rabbitmq) accept and ignore it. Neither type is barrel-exported, so no published surface changes; listed because it alters a contract shared by every adapter.

Fixed

  • @setu-ts/queue-plugin — the SQS backend settled nothing through QueuePlugin. The job runner passed the job id where the adapter expected the claimToken minted by reserve, so every ack/requeue/deadLetter failed its own claim check and returned without calling SQS: a processed job was never deleted and redelivered after each visibility timeout, forever. Adapter tests and the ElasticMQ e2e passed because both settle the adapter directly, supplying the token the real caller did not. Now covered through a real kernel application.

  • @setu-ts/messaging-plugin — ServiceBusBroker leaked an AMQP receiver link per unsubscribe(). Teardown closed only the subscriber handle returned by receiver.subscribe(...) and never the receiver itself; it also closed the most recently opened receiver rather than the one being unsubscribed, so cancelling one of two subscriptions on the same topic stopped the wrong delivery.

  • @setu-ts/messaging-plugin — GcpPubSubBroker and ServiceBusBroker constructed standalone with neither credentials nor an injected transport now fail at connect() naming the missing option, instead of building a client on an empty projectId / connection string and failing later inside the SDK.

  • Redis-backed cache, queue, and messaging plugins now create ioredis clients with lazyConnect. Their explicit startup connect() call no longer fails because ioredis connected eagerly during construction.

  • @setu-ts/queue-plugin — RedisQueue.reserve() now sends the mandatory LIMIT keyword in the ZRANGEBYSCORE command, so reserve works against a real Redis server. Previously the call sent positional offset/count arguments without the keyword, which caused Redis to return ERR syntax error on every reserve attempt.

  • @setu-ts/messaging-plugin — RedisStreamsBroker now hands a timer handle back to clearInterval exactly as setInterval returned it. It previously stored the handle as a number, and TimerHandle is deliberately opaque (unknown), so a runtime returning an object-shaped handle had it coerced to NaN — making the cancel a silent no-op and leaking a poll loop that kept issuing commands after unsubscribe() and disconnect(). The bundled Node, Deno, and Bun runtimes were unaffected, because their handles happen to coerce to a numeric id; a custom IRuntimeServices whose handle does not coerce leaked outright.

0.1.0-alpha.4 — 2026-08-04

The largest release so far: eight packages publish for the first time, bringing the scope to 46. The three starters (rest-starter, microservice-starter, full-stack-starter) ship at last, along with session-plugin, service-discovery-plugin, grpc-plugin, graphql-plugin, and cloudflare-plugin. Every other package is version-bumped so the scope stays on one version — the CLI requires this, because honoe new stamps generated projects with its own version as the range for every package it wires.

Two changes can alter behavior you already depend on. Both are narrow, and neither is a capability regression, but the first is silent at compile time.

⚠️ Breaking 1 of 2: @Cookie and parseCookies return different values. Cookie values are now percent-decoded, one layer of RFC 6265 quoting is stripped, and a repeated cookie name resolves to the first occurrence rather than the last. Nothing stops compiling, so this changes at runtime rather than at build time — if you were decoding values yourself after calling parseCookies, remove that step, because double-decoding will corrupt any value containing a literal %. Each difference is a defect fix; see Changed for why they are being corrected during 0.1.x rather than frozen.

⚠️ Breaking 2 of 2: IGraphqlService gains a required subscribe method. Source-compatible for every caller and breaking only for anyone who implements the interface. The framework’s own GraphqlService is the only implementor in this repository, so if you have not written your own GraphQL service, nothing here applies to you.

Cloudflare is now a first-class target rather than merely a runtime that boots. Four milestones add KV, R2, D1, Queues, Cron Triggers, the Cache API, and Durable Objects behind typed accessors that name a missing binding instead of handing you undefined — and they fixed the reason runtime.env was empty on Workers, which had left ConfigPlugin and the secrets EnvProvider reading nothing on the edge.

Alongside that: gRPC, Connect, and gRPC-Web co-served on the same port as ordinary routes; a GraphQL plugin with subscriptions over both WebSocket and SSE; service discovery over Consul, Kubernetes, DNS-SRV and static configuration, with load balancing and outlier ejection; cookie sessions with form CSRF; and the starter and template work that makes all of it composable in one call.

Added

  • GraphQL subscriptions, batching, and persisted queries (Milestone 51b). @hono-enterprise/graphql-plugin gains two subscription transports — the graphql-transport-ws protocol over the OPTIONAL CAPABILITIES.WEBSOCKET, and GraphQL-over-SSE in distinct-connections mode over M42’s IResponse.stream(), which needs no other plugin — plus request batching, Automatic Persisted Queries with server-side hash verification, and custom scalar resolvers in the schema-first arm.

    Every new behaviour is opt-in. subscriptions, apq, and maxBatchSize all default to off, so an application that upgrades without changing its options registers exactly the routes it did before and answers byte-identically. In particular the HTTP endpoint still refuses a subscription with 400 SUBSCRIPTIONS_NOT_SUPPORTED_OVER_HTTP; subscriptions are reachable only on the dedicated WebSocket and SSE routes, which default to the endpoint path plus /ws and /stream.

    A subscription is declared in the schema-first resolver map as { subscribe, resolve? } — the new exported SubscriptionResolver arm on ResolverMap. APQ verifies that a submitted hash matches the submitted document before persisting it, so a shared ICacheStore cannot be poisoned with a document under another client’s hash; a mismatch answers PERSISTED_QUERY_HASH_MISMATCH. Resolver errors raised inside a live subscription are masked by the same maskInternalErrors path the HTTP transport uses.

    Three @hono-enterprise/common widenings: GraphqlRequestParams.extensions (read by APQ), IGraphqlService.subscribe with GraphqlSubscriptionOutcome / GraphqlOperationContext / GraphqlConnectionInfo, and WebSocketRouteOptions.heartbeat (below).

Changed

  • IGraphqlService gains a required subscribe method (Milestone 51b) — source-compatible for every CALLER, and breaking for anyone who implements the interface. The framework’s own GraphqlService is the only implementor in this repository. An external implementation adds:

    subscribe(
      params: GraphqlRequestParams,
      context?: GraphqlOperationContext,
    ): Promise<GraphqlSubscriptionOutcome>;

    The second parameter is deliberately NOT an IRequestContext: a WebSocket connection has none, and reusing execute’s parameter is what would hand a subscription resolver an empty service registry.

  • WebSocketRouteOptions.heartbeat (Milestone 51b). A route may now opt out of websocket-plugin’s shared heartbeat sweep with heartbeat: false, which excludes its connections from both the payload send and the idle eviction. Defaults to true, so no existing route changes. This exists because the sweeper sends a raw text frame to every connection on every route: a graphql-transport-ws client that receives one must close with 4400, so WebSocketPlugin({ heartbeatMs }) would otherwise have broken every GraphQL subscription in the application. The GraphQL WS route claims the opt-out and runs its own protocol ping/pong.

  • Durable Objects: a realtime backplane and a distributed lock (Milestone 52d). @hono-enterprise/cloudflare-plugin gains a durableObject arm registering DurableObjectBackplane under the committed CAPABILITIES.REALTIME_BACKPLANE, so websocket-plugin and sse-plugin reach clients on other replicas with no application change — and DurableObjectLock, which structurally satisfies scheduler-plugin’s IDistributedLock and is handed to SchedulerPlugin({ distributedLock: { lock } }) (an injected lock wins outright; enabled: true is not required). No common change and no new capability token: both contracts were already committed. Register either this arm or RealtimeBackplanePlugin, never both — the kernel rejects two providers of one token.

    Both need a Durable Object class the application exports, plus a wrangler stanza; the package ships the behaviour as two plain cores (RealtimeBackplaneObjectCore, DistributedLockObjectCore) that the exported class delegates to. A mixin taking the base class would read better but cannot be typed without any, and delegation additionally keeps cloudflare:workers — unresolvable off a Worker toolchain — out of the package.

    Two platform facts shaped the implementation rather than being worked around. Sockets are accepted with ctx.acceptWebSocket, the hibernation API, which lets the runtime evict the object and re-run its constructor while connections stay open; the fan-out core therefore holds zero in-memory state and treats getWebSockets() as the only membership, because a Set in a field would empty itself on the first hibernation while every non-hibernating test still passed. And a Worker isolate cannot be relied on to hold a long-lived outbound WebSocket, so the socket opens lazily and reopens after any failure; the guarantee is stated rather than overstated — a subscription lives exactly as long as the isolate holding the members it serves, and since those members are client sockets in the same isolate, losing one loses both together. The lock persists its holder in the object’s storage, never a field, because an object is evicted after 70–140 seconds idle; correctness comes from the platform’s input gate (“while a storage operation is executing, no events shall be delivered to the object”), which makes the read-compare-write atomic with no transaction. A non-2xx from the lock object throws rather than reporting “not acquired”, since a 404 means the binding names the wrong class and folding that into contention would silently disable every scheduled job.

    Also closes the last hole in the binding-guard family: BindingRegistry.durableObject cast its binding unvalidated, so a missing durable_objects stanza or a mistyped class_name let an application boot clean and fail on the first idFromName with a bare TypeError — the defect M52c’s review found on D1. Adds the exported isDurableObjectNamespace guard and constructor validation. Verified against real workerd via wrangler dev (12/12 checks), which also settled the design question the milestone could not answer from docs: a plain Durable Object class without extends DurableObject is accepted, so the delegation design is correct and not merely convenient. Not verified against a deployed Worker — CI holds no Cloudflare account.

Fixed

  • A listen-only replica received nothing from a realtime backplane. IRealtimeBackplane.connect() had exactly one caller — RealtimeBackplanePlugin.register() — and websocket-plugin / sse-plugin relied on the provider having connected before they subscribed. subscribe() registers a handler; it does not open a transport. Any provider that cannot connect at registration therefore left every replica that only listens silently receiving nothing, which a Cloudflare Durable Object backplane is the first transport to hit: a Worker runs register() at module scope, where the platform forbids the I/O connect() performs.

    Both consumers now open the transport on first local use, inside a request context on every runtime — WebSocketService when a connection joins its first room, SseService when a client connects. The call is fire-and-forget so an upgrade never waits on the transport, idempotent per the committed contract, and retried on the next join if it fails. Applications registering RealtimeBackplanePlugin are unaffected: its provider still connects at registration, and the extra call is a no-op.

  • Cloudflare D1 as a first-class database backend, and the common data-access promotion that made it possible (Milestone 52c). The seam a database backend implements was IDatabaseAdapter, declared inside @hono-enterprise/database-plugin and never exported, while common shipped only the lifecycle-shaped IOrmAdapter — so a backend living in any other package was literally inexpressible, because AI_GUIDELINES §2.2 forbids one plugin importing another. @hono-enterprise/common now exports IDatabaseAdapter, IAdapterTransaction, IDataSource, NormalizedQuery and OrderDirection. The promoted port is the old shape plus one member — a non-transactional createDataSource(entity) — and that addition is the substance of the change: the plugin previously reached each adapter’s data-source factory by casting to the concrete class, which is what actually kept the seam closed. That cast is gone, all three built-in adapters carry createDataSource, and createDataSourceForEntity is deprecated, not removed (§9.2). DatabasePluginOptions is now a union discriminated on type with a 'custom' arm requiring an adapter, so registering an external backend without one is a compile error rather than a startup throw; every existing registration compiles unchanged. DataSource is retained as a deprecated alias of IDataSource. The promotion also repairs a latent public-API defect: the barrel exported DataSource, whose findAll parameter is NormalizedQuery, while NormalizedQuery itself was not exported — no consumer could name the type.

    @hono-enterprise/cloudflare-plugin gains D1Adapter (plus D1AdapterOptions, D1EntityMapping), constructed by the application from its D1 binding and handed to DatabasePlugin({ type: 'custom', adapter }) — the KvSessionStore precedent, since those plugin options are read before any application exists. D1 has no interactive transaction: it rejects BEGIN TRANSACTION outright, and batch() is its only unit of atomicity. beginTransaction() therefore buffers every write and flushes the whole buffer as one batch() at commit; rollback() discards it and sends nothing. Atomicity is genuine, and the two costs are documented and tested rather than left to discovery: there is no read-your-own-writes inside a transaction (reads run against committed state), and an in-transaction create() requires an explicit primary key, throwing CloudflareUnsupportedError when absent — a deferred INSERT cannot report a generated key to a caller that awaits create() before the flush. Outside a transaction create() uses RETURNING * and returns the real persisted row. Values are always bound (?N); identifiers cannot be, so table and column names are validated against [A-Za-z_][A-Za-z0-9_]* and double-quoted, and every builder refuses a statement that would exceed D1’s documented 100-bound-parameter limit. Not verified against live D1 — CI holds no Cloudflare account — though the whole surface is driven against a real SQLite engine, the engine D1 runs, including batch rollback.

  • Cloudflare Queues, Cron Triggers, and the Cache API in @hono-enterprise/cloudflare-plugin (Milestone 52b) — the three platform features that need a module-level handler export from the application’s Worker rather than anything reachable through fetch. No common change and no new capability token. WorkersQueue satisfies the committed IQueue over a Queues producer binding, opt-in through a queue arm and registered under CAPABILITIES.QUEUE (or queue.<name>); the job’s name and id travel in a { v, name, id, data, maxAttempts? } envelope, because a Cloudflare message body is arbitrary JSON carrying neither and producer.send() resolves to void, so the id add returns is the id the processor sees as job.id. createQueueHandler(app) builds the queue export. A message whose body is not a readable envelope, or whose name has no processor, is retried rather than acked — acking would discard it permanently and silently, the failure a queue exists to prevent — and AddJobOptions.maxAttempts is enforced at dispatch, since Cloudflare’s max_retries is queue-wide configuration rather than per message. addRecurring throws, naming Cron Triggers as the platform’s own mechanism. Cron Triggers ship as WorkersCron plus createScheduledHandler(cron), and deliberately do not register CAPABILITIES.SCHEDULER: of IScheduler’s eight methods only cron is expressible on Workers — every and delay arm timers across an isolate eviction (the same reason scheduler-plugin cannot run there), pause/resume/remove need state that does not survive an invocation, and getNextRun is owned by the wrangler.toml [triggers] block. An implementation where six of eight methods throw would violate Liskov substitution, so a small honest surface was chosen instead. An expression is matched against ScheduledController.cron exactly, and expressions() exists so an application can assert its own coverage against wrangler.toml, which no code in the process can read. cacheApiMiddleware caches responses in caches.default. It is a different layer from cache-plugin’s cacheMiddleware and composes with it, so it reports under X-Cache-Api rather than X-Cache. The platform’s own refusals — non-GET, status 206, Vary: *, and an uncleared Set-Cookie — are checked first through the pure exported assessCacheability rather than discovered from a thrown put; the 206 and Vary: * rules are unconditional, because an operator may legitimately configure cacheableStatuses: [200, 206] and only the explicit rule then stops the platform throwing. The write rides ICloudflareBindings.waitUntil when the plugin is registered and is awaited inline when it is not, so it is never simply abandoned; with no cache handle at all the middleware passes through rather than throwing, so an application composed for several targets still serves off Workers. A HIT is replayed with IResponse.stream, so a cached response of any size reaches the client unbuffered — which means app.inject() cannot read it and cached routes are tested with app.fetch. caches.default is per-datacenter: a latency optimisation, not a shared store. D1 as a database backend moved to Milestone 52c (it needs the IDatabaseAdapter seam promoted from database-plugin into common, plus reconciling ITransaction with D1’s batch-only atomicity) and Durable Objects to Milestone 52d (both the realtime backplane and the distributed lock need the application to export a DO class, and Durable Objects expose no pub/sub primitive, so a backplane means each replica holding a WebSocket to the object).

  • @hono-enterprise/cloudflare-plugin (Milestone 52) — a new package registering ICloudflareBindings under a new CAPABILITIES.CLOUDFLARE token. The framework has served traffic on Workers since the Hono migration but could not reach a single platform binding; this publishes them as one typed accessor (kv, r2, d1, queue, service, durableObject, get<T>, vars, waitUntil), and optionally serves the committed cache and storage capabilities from KV and R2. Zero npm dependencies, and nothing in the package imports cloudflare:workers — the application passes env (and waitUntil) in, which keeps the package type-checkable on every runtime. KvCacheStore reconciles ICacheStore’s unbounded TTL with KV’s 60-second expirationTtl floor by carrying a logical deadline inside the value, so a 5-second entry expires in 5 seconds rather than surviving a minute. The decoder reports three outcomes rather than two — live, this store’s expired entry, and neither — so a read never deletes a key the store does not own and a deliberately cached null survives; clear() additionally requires a key prefix, because the binding has no bulk delete and an unprefixed sweep would remove foreign keys. R2Storage implements the optional getStream, heads before delete so its committed Promise<boolean> is honest, and throws from getSignedUrl — the R2 Workers binding has no presign operation. KvSessionStore is constructed by the application and handed to SessionPlugin({ store }), since that option is read before any application exists. No binding I/O happens at registration, where the platform forbids it, and the cloudflare health indicator performs none either.

  • splitWorkerEnv and SplitWorkerEnv in @hono-enterprise/common (Milestone 52) — the pure partition of a Workers env record into string variables and object bindings. In common because both runtime and cloudflare-plugin need the identical rule and no plugin may import another.

  • @hono-enterprise/service-discovery-plugin (Milestone 50) — a new package registering an IServiceDiscovery under a new CAPABILITIES.SERVICE_DISCOVERY token, so an application can turn a logical service name into a reachable address. Five provider arms — 'static', 'consul', 'kubernetes' (EndpointSlices), 'dns' (SRV and address records), and 'custom' — behind one DiscoveryProvider port, with the option type a union discriminated on provider so a missing per-arm credential is a compile error rather than a startup throw. Zero npm dependencies: the HTTP providers run on web-standard fetch and the DNS provider on the new optional IRuntimeServices.dns. Adds a monotonic-clock read-through cache with per-service in-flight coalescing and stale-on-failure; push-based watch() over Consul blocking queries (both documented index hazards handled — a backwards index resets to zero, an index of 0 becomes 1 to avoid busy-looping older servers) and Kubernetes watch streams (used as a change signal rather than a delta log, with 410 Gone resync); three balancing strategies over IRuntimeServices.randomBytes; and outlier ejection with a panic-threshold cap and an all-ejected fallback. Ejection is deliberately not a second circuit breaker: wrap breaks a call site, ejection removes a pool member while the call site stays open.

  • IServiceDiscovery, ServiceInstance, PickOptions, LoadBalanceStrategy, ServiceOutcome and CAPABILITIES.SERVICE_DISCOVERY in @hono-enterprise/common (Milestone 50) — the contract a consumer types the resolved capability as, without importing the plugin.

  • IRuntimeServices.dns?: IDnsResolver and SrvRecord in @hono-enterprise/common, with createNodeDnsResolver (Node + Bun, over node:dns/promises) and createDenoDnsResolver (over Deno.resolveDns) exported from @hono-enterprise/runtime (Milestone 50). Purely additive, following the fs? / workers? precedent; Cloudflare Workers omits the key entirely, since its network access is fetch, which resolves names internally and exposes no lookup surface. SrvRecord.host is a normalized name on purpose — Deno spells the field target, Node spells it name, and passing either through unchanged would type-check on both runtimes while producing undefined hostnames on one.

  • ILifecycleApi.onStopping in @hono-enterprise/common and @hono-enterprise/kernel (Milestone 50) — a new lifecycle phase running at the very start of stop(), before the application begins refusing new requests and before the socket closes. It is the only hook that fires while the application is still serving normally, which is what makes it correct for deregistering from a service registry: doing that in onShutdown leaves callers routed at a closed port for up to one health-check interval on every rolling deploy. Listed under Added rather than Changed because no existing behavior moves — Application.#doStop() skips the phase entirely when no hook is registered, so stop() is byte-for-byte unchanged for every application that does not opt in. (Awaiting an already-resolved promise instead would still defer when the shutting-down flag flips, handing a 404 to a request that used to get a 503 — a pre-existing kernel test caught exactly that.)

  • honoe new --template full-stack (Milestone 36c) — scaffolds a React Router 8 SSR application: the routes → features → services → models layering, flatRoutes _app/_auth layout groups, the ~/* alias, the .server.ts convention, one worked feature, and the Vite build files. What it deliberately does not emit is as important as what it does: no lib/session.server.ts, lib/csrf.server.ts, lib/sse.server.ts, lib/kv.server.ts or lib/service-logger.server.ts, because those are the session, SSE, secrets and logger capabilities, reached through the service registry the SSR plugin attaches to every request. The session reaches loaders through a context key the application declares and populateLoadContext fills, so no plugin imports another. Every runtime target is supported; Cloudflare Workers omits assetsDir and leaves assets to the platform binding. This is the only template that composes through a starter rather than inline wiring — its plugin set is twenty-two, and a generated file a human is meant to edit should not open with twenty-two imports they did not choose.

  • contextKeyFor in @hono-enterprise/react-router-plugin (Milestone 36c) — creates React Router context keys by name, memoised, so the same name always yields the same object. Keys are matched by identity, and in a framework-mode application the module declaring them exists twice: Vite inlines application modules into the server build, while the runtime loads honoe.config.ts from source. Two hand-written { defaultValue } literals then match nothing, and every read silently returns the default — a session that is always null, a CSRF token that is always empty, with no error raised. Requires the server build to treat @hono-enterprise/* as external (environments.ssr.build.rollupOptions.external), which the full-stack template configures. The serverBuildPath JSDoc now also states that the path must be absolute: the loader does await import(serverBuildPath), so a relative specifier resolves against the plugin’s own module and can never find the application’s build.

  • createFullStackAppFromConfig in @hono-enterprise/full-stack-starter (Milestone 36c) — (build: (config: IConfig) => FullStackStarterOptions, options?: FromConfigOptions) => Promise<IKernelApplication>, where FromConfigOptions carries config (loading options) and env. env is required on Cloudflare Workers, where bindings arrive per request rather than process-wide, so without it the application composes from an empty configuration and fails on every request; the full-stack template threads the handler’s env through automatically. Plugin options must be decided before the plugins are constructed, which is before ConfigPlugin has registered anything; this loads configuration once, hands the snapshot to the resolver, and passes that same object into the application, so the values the composition branched on are the values handlers read. It applies to every option uniformly, which is why no plugin option carries a urlFromConfig-style config-key field — such a field would need its value at the same impossible moment. Secrets remain out of reach by construction: they are served by a plugin that exists only after registration.

  • loadConfig and ConfigPluginOptions.instance in @hono-enterprise/config-plugin (Milestone 36c) — loadConfig(runtime, options?) is the same implementation ConfigPlugin registers, reachable without an application; instance registers a supplied snapshot verbatim, reading nothing from the environment. ConfigPlugin.register now delegates to loadConfig, so merging, expansion, and validation cannot drift between the two paths.

  • createRuntimeServices in @hono-enterprise/runtime (Milestone 36c) — builds IRuntimeServices for the detected platform without an application. The barrel previously exported detectRuntime and four per-platform factories but nothing joining them, so the platform → adapter map was unreachable outside RuntimePlugin.register; that method now delegates here, leaving one implementation behind two entry points. RuntimeAdapterFactories and CreateRuntimeServicesOptions are exported alongside it.

  • Gated session arm on the three starters (Milestone 36c) — RestStarterOptions.session registers SessionPlugin, inherited by the microservice and full-stack tiers. M48 shipped after the starters, so no tier could previously register a session at all. Gated because the plugin throws during register() without an adequate secret; no default changes.

  • Parameter-level @Inject in @hono-enterprise/decorator-plugin (Milestone 36b) — Inject now works on a constructor parameter as well as on the class, binding one token to that argument by position, which is the form a developer arriving from NestJS expects: constructor(@Inject(CAPABILITIES.DATABASE) private db: IDatabase) {}. The class-level positional list is deprecated, not removed (AI_GUIDELINES §9.2) and keeps working for the whole 0.x line. A token is still always required — inferring it from the parameter’s type needs emitDecoratorMetadata, which Deno does not support — so three ambiguous cases throw at startup rather than misinjecting silently: mixing the two forms on one class, leaving a constructor parameter undecorated below the last injected one, and applying @Inject to a method parameter.

  • Gated realtime and di arms on the three starters (Milestone 36b) — realtime groups websocket, sse, and backplane sub-arms; di adds DiPlugin. Added to RestStarterOptions, so the microservice and full-stack tiers inherit them. No default changes: with no arm supplied the plugin set of all three tiers is byte-identical to the previous release. Supplying di does change how decorated services are constructed (DecoratorPlugin switches to its container path), which is why it is opt-in. RealtimeArm is exported from all three starter barrels.

  • honoe new --template nest (Milestone 36b) — the REST plugin set plus DiPlugin, an @Injectable service, and a @Controller using parameter-level @Inject. Emits inline wiring like the other templates, and refuses no runtime target.

  • Wiring.args, TemplateDefinition.localImports, and TemplateDefinition.files in @hono-enterprise/cli — the template contract could previously express neither a plugin call argument nor an extra emitted source file. All three are optional and every existing template renders byte-identically (args absent → Symbol()).

  • @hono-enterprise/session-plugin (Milestone 48) — cookie-backed sessions and session-backed form CSRF, registering an ISessionService under the new CAPABILITIES.SESSION token. The default is a self-contained encrypted cookie (AES-256-GCM under an HKDF-SHA256 derived key, entirely through runtime.subtle), so the package has zero npm dependencies and works on Cloudflare Workers. Setting store ('memory', 'cache', or a custom ISessionStore) moves the payload server-side and leaves an opaque id in the cookie, which is what makes immediate revocation possible. Secret rotation goes through a key list — index 0 seals, every entry opens — with an HKDF-derived non-secret kid in the envelope so opening is a lookup rather than trial decryption. Ships getSession, getCsrfToken, verifyCsrfToken, csrfFormMiddleware, sessionMiddleware, both stores, and four error types. Note that mode: 'sign' protects integrity only and leaves the payload readable by the client; 'encrypt' is the default so that choice is never accidental.

  • parseCookie / serializeCookie / CookieAttributes in @hono-enterprise/common — the framework’s single cookie codec. It lives in common because the session plugin and the decorator plugin’s @Cookie both need it and no plugin may import another (the encodeFrameData precedent).

  • ISessionService / ISession / ISessionStore / SessionData contracts and CAPABILITIES.SESSION in @hono-enterprise/common. No IRequest widening was needed: the session middleware parks the session in ctx.state, so a cookies field with no consumer was declined.

  • scalingNotice option on WebSocketPluginOptions and SsePluginOptions (boolean, default true) — set false to silence the startup notice described below, for a deployment where you have decided single-replica fan-out is correct and do not want the line on every boot. It suppresses the message only: room and channel delivery are identical either way, and the notice never appears once a backplane is registered.

Changed

  • runtime.env is now populated on Cloudflare Workers (Milestone 52). RuntimePlugin and createRuntimeServices gain an env option; passing the Worker’s env makes ConfigPlugin and the secrets EnvProvider work on the edge, where previously they read an empty record. Only string entries reach runtime.env, which is contracted as a string record — object bindings are filtered out rather than stringified to [object Object]. Behaviour on Deno, Node, and Bun is unchanged; the option is ignored there.

  • honoe new --runtime cloudflare-workers (Milestone 52) now threads env from the fetch handler into createApp(env) and renders RuntimePlugin({ env }) on that target, bumps the scaffolded compatibility_date to 2025-09-01 (import { waitUntil } from 'cloudflare:workers' shipped 2025-08-08, so the previous 2024-09-23 could not import it), and emits commented [[kv_namespaces]] / [[r2_buckets]] stanzas in wrangler.toml. Generated output for the Deno, Node, and Bun targets is unchanged.

  • DecoratorPlugin now prefers the DI container for any class registered in it, with or without @Injectable. instantiate() required service metadata before consulting the container, so a @Controller — which carries no @Injectable — took the service-registry path even in an application with DiPlugin, where its dependencies live in the container and not the registry, and construction failed outright with “No service registered for capability”. The guard contradicted the function’s own documented behavior. Reachable before this release only for a controller whose constructor took arguments; parameter-level @Inject makes that composition ordinary, which is how it surfaced.

  • decorator-plugin’s exported parseCookies now delegates to common’s parseCookie, which changes its output in three cases. The signature is unchanged and no call site needs editing, but the values it returns can differ, so read this if you use @Cookie or call parseCookies directly. Each difference is a defect fix rather than a preference:

    • Values are percent-decoded. A cookie written by any standards-compliant server (including this framework’s own serializeCookie) was previously returned still-encoded — @Cookie('x') handed you a%20b where the value was a b. If you were decoding the result yourself, remove that step; double-decoding will now corrupt a value containing a literal %.
    • One layer of RFC 6265 quoting is stripped, so sid="abc" yields abc rather than "abc".
    • A repeated cookie name resolves to the first occurrence, not the last. Browsers send the most specific cookie first, so the first is the one that was meant.

    The alternative was two cookie parsers in the tree, which AI_GUIDELINES §11.1 forbids. Shipping the correction during 0.1.x pre-release rather than freezing the defect follows the precedent of the Milestone 14d wire change and the Milestone 30b FCM replacement.

  • websocket-plugin and sse-plugin now say at startup that rooms and channels are process-local when no realtime backplane is registered — one info line naming the limitation, the plugin that lifts it, and the transport it needs ('redis' or 'messaging'; the backplane plugin’s default 'memory' transport is a single-process bus, so registering it bare would silence the notice without fanning anything out). Cross-replica fan-out has shipped since 0.1.0-alpha.3, but a single-replica app and a three-replica app behave identically right up to the point where two thirds of your clients silently stop receiving broadcasts, with no error raised anywhere. Both READMEs gain a Scaling beyond one replica section for the same reason. If you run a single replica the line is informational and safe to ignore; registering a backplane under the REALTIME_BACKPLANE token removes it.

Fixed

  • DatabasePlugin({ options: { logQueries: true } }) threw on every repository call whenever a real logger was registered (found in Milestone 52c). resolveLogger extracted logger.debug into a local and invoked it detached, so this was undefined at the call. Both loggers logger-plugin ships — ConsoleLogger and PinoLogger — implement debug in terms of a private # field, and a private-field access on an unbound method throws TypeError, so the documented logQueries option could not be used at all with LoggerPlugin present. Every existing test injected a plain-object logger, where a detached method works fine, which is exactly why no gate saw it. cache-plugin carries a regression test for the identical bug; database-plugin now has one too, driving the real ConsoleLogger through a running kernel application.

  • Every application failed to boot on Cloudflare Workers — packages/kernel’s request-context factory built its never-aborting ctx.signal sentinel from a module-scope new AbortController(). workerd refuses that with Disallowed operation called within global scope, because an AbortController is bound to an I/O context, so the isolate threw at import time and no handler ever ran. Introduced with IRequestContext.signal in Milestone 42 and invisible to every gate: the whole suite runs on Deno, where a module-scope controller is legal, and the Workers path had only ever been exercised through app.fetch under Deno rather than under the real runtime. Found by driving the framework under wrangler dev (workerd) for the first time. The sentinel is now constructed per request; caching one lazily would not have been a fix either, since workerd then refuses to use a controller created for one request on behalf of another. A regression test pins that two contexts never share a fallback signal — it fails against the previous code.

  • @hono-enterprise/cloudflare-plugin queue reporting reaches a logger registered after the plugin (Milestone 52b) — WorkersQueueOptions.logger is a thunk rather than an ILogger, for the reason resolveWaitUntil already takes one: ctx.logger resolves lazily through a Proxy that answers undefined until a logger is registered, and a capability may be registered imperatively with no provides declaration for the resolver to order against. Capturing the value during register() would silence every dispatch report in an application whose logger registers later.

  • honoe new now refuses a project plan containing the same path twice (Milestone 36c). The overwrite check probes the filesystem, so it could not see a duplicate inside one plan: both files were written and the last silently won. A template emitting deno.json would have overwritten the framework manifest with no warning.

  • The CLI drift gate resolved starter packages to the wrong directory (Milestone 36c). It mapped @hono-enterprise/<name> to packages/<name>, but the three starters live under packages/starters/, so any template importing one could not be type-checked. It also rewrote every import-map entry, mangling a template’s project-local alias (~/) into a package path.

  • websocket-plugin’s README no longer claims cross-replica fan-out is unimplemented. It stated “fan-out across replicas is a follow-up milestone; today two instances behind a load balancer do not share rooms”, which stopped being true when realtime-backplane-plugin shipped in 0.1.0-alpha.3.

  • sse-plugin’s README named a method that does not exist. Its named-channels example called channel.broadcast(...); the committed SseChannel contract exposes publish(...) and no broadcast, so the snippet would not compile.

  • realtime-backplane-plugin: RedisBackplane.connect() no longer leaks a connection on a failed open, and is safe to call concurrently. The connected guard was only set after both connections had been constructed, so two overlapping calls each built their own pair — and if the second construction threw, the first connection was already live with nothing holding a reference to close it. The open is now memoized, so overlapping callers join one attempt and none of them returns before SUBSCRIBE has actually landed; a failed attempt quits whatever it built, removes its own listener from injected clients but does not close them (they belong to the caller), and clears the memo so a later call retries. A close() arriving mid-open now wins as well: the open retires whatever it built instead of publishing two live connections onto a backplane that has already shut down, which is what a shutdown during startup would otherwise strand. RealtimeBackplanePlugin calls connect() exactly once and close() only from onClose, so no application behavior changes — this closes the seam for callers driving the transport directly.

Known limitations

The Cloudflare surface has not been verified against a deployed Worker. CI holds no Cloudflare account, so nothing in the pipeline reaches the platform. It was driven against real workerd via wrangler dev during development — Queues with a real MessageBatch, a real ScheduledController, caches.default, KV, R2, D1, and both Durable Object classes including a real WebSocketPair upgrade and the storage input gate — and that harness is what caught a kernel defect that broke every application on Workers (fixed here). But those runs were manual and the harness is not committed, so treat the edge story as well-exercised rather than continuously gated, and verify against your own account before you depend on it.

FCM push still has not been exercised against live FCM, unchanged from 0.1.0-alpha.3. The HTTP v1 request is asserted field by field and its RS256 assertion is signed and verified with real Web Crypto, but no test reaches Google.

D1 transactions are deferred batches, with two consequences worth knowing before you use them. D1 rejects BEGIN TRANSACTION outright and batch() is its only unit of atomicity, so writes buffer until commit and flush as one batch. Inside a transaction there is no read-your-own-writes — reads see committed state — and create() requires an explicit primary key, because a deferred insert cannot report a generated key to a caller awaiting it before the flush. Both are documented in PUBLIC_API.md and covered by tests; neither applies outside a transaction.

All 46 packages are live on JSR at 0.1.0-alpha.4, published by CI from the tag.

Verified after publishing by querying every package on the registry, then installing kernel and runtime from JSR into a throwaway directory — not the workspace, whose import map resolves locally and would mask a broken published dependency — and serving a request (200 {"ok":true}). common resolved transitively at 0.1.0-alpha.4, which is the only real evidence that the cross-package specifier bump landed inside the published tarballs: a dry run resolves those from the workspace and so cannot show it.

Every package also carries a description and runtime-compat flags for the first time. Neither setting lives in a published version — deno publish never touches them — so all 46 pages had shown an empty description and “Compatibility unknown” through four releases. They are now set from scripts/jsr-metadata.ts and reapplied by deno task release:set-metadata, which is idempotent and refuses to run when a published package has no entry.

Installing

deno add jsr:@hono-enterprise/kernel@^0.1.0-alpha.4
deno install -g -A -n honoe jsr:@hono-enterprise/cli@^0.1.0-alpha.4/main

Within 24 hours of a release, Deno’s minimum-dependency-age policy refuses the version unless you pass --min-dep-age 0.

0.1.0-alpha.3 — 2026-07-30

Two breaking changes ship in this release. Both are narrow, but you meet them in production rather than in this file, so they are stated here in full and again under Changed.

⚠️ Breaking 1 of 2: brokered request-reply changes on the wire. request()/respond() move from <topic> to a derived rr.req.<topic> channel, so a responder running 0.1.0-alpha.2 and a caller running this version will not talk to each other. RPC callers and responders must be restarted together, not rolled one at a time. Fire-and-forget publish/subscribe are unaffected, as is every other plugin. If you do not use request/respond, nothing here applies to you.

⚠️ Breaking 2 of 2: the FCM push channel takes service-account credentials. FcmProviderOptions.serverKey is replaced by { projectId, clientEmail, privateKey }, so existing config stops compiling. That is deliberate rather than a deprecation: serverKey addressed the legacy endpoint Google switched off in 2024, so every push sent through it already failed. A compile error is the only honest signal. If you do not configure a push channel, nothing here applies to you.

Kafka gains request-reply, and five of the known limitations recorded against 0.1.0-alpha.1 are closed. Every entry below was a real capability gap rather than a documentation problem, so each is fixed in code; the alpha.1 list annotates them in place rather than deleting them, because that section records what was true of that release.

Kafka was not the reason Kafka lacked request-reply: the shared request-reply core minted its own inbox topic and imposed it on every broker, which only works where topics are cheap and per-instance-addressable. Brokers now supply their own reply inbox, so Kafka can read a shared reply topic under a per-instance consumer group instead. The same seam is where a future native AMQP replyTo or NATS JetStream reply-subject transport would plug in. Two defects in the M14c implementation are fixed alongside it, both consequences of RPC sharing a topic with ordinary pub/sub.

Alongside that, WebSocket rooms and SSE channels gain cross-replica fan-out, feature-flags-plugin gains a LaunchDarkly provider, and resilience-plugin timeouts finally cancel the work they bound.

Added

  • Kafka now supports brokered request-reply. KafkaBroker.request/respond previously rejected outright; all five brokers are now reply-capable. Replies travel on a shared reply topic — the new replyTopic option, default 'messaging.replies' — read by a consumer group unique to each broker instance, so delivery is exclusive to the caller rather than load-balanced across the shared default group. The reply topic must already exist: IKafkaFactory exposes no admin surface, so the broker creates no topics. Every instance receives every reply and discards those it did not originate; give a high-traffic service its own replyTopic to bound that fan-out.

  • Each broker now supplies its own reply inbox through an internal seam, rather than having a topic string imposed on it by the shared request-reply core. The four brokers that were already reply-capable pass a shared helper and are behaviourally unchanged.

  • notification-plugin push delivery works again, on FCM HTTP v1. FcmProvider now posts to /v1/projects/{projectId}/messages:send with an OAuth2 bearer token minted from a service account: it signs an RS256 JWT assertion with runtime.subtle and caches the token until shortly before expiry, so a send costs one request in the steady state. Zero npm dependencies and Workers-portable, like the other HTTP providers. A new FcmTokenSource export lets you source tokens elsewhere (a GCP metadata server, a key-holding broker) instead of from a local key.

  • @hono-enterprise/realtime-backplane-plugin — cross-replica fan-out for WebSocket rooms and SSE channels. It registers an IRealtimeBackplane under the new CAPABILITIES.REALTIME_BACKPLANE token, which websocket-plugin and sse-plugin resolve optionally — so adding the plugin is the entire change needed to make ws.room('lobby') and sse.channel('news') reach clients on other replicas, and removing it restores in-process behavior with no application change. Four transports: 'memory' (the default, and a real single-process bus rather than a no-op), 'messaging' (over whatever broker is registered under CAPABILITIES.MESSAGING, reusing all five existing brokers with no new dependency), 'redis' (pub/sub over an inject-or-lazy ioredis), and 'custom'.

    Correction, made in 0.1.0-alpha.4. “Adding the plugin is the entire change needed” is wrong, and the sentence is left in place because this section records what the release said. RealtimeBackplanePlugin() defaults to transport: 'memory', a single-process bus, so registering it bare fans nothing out across replicas. You must also choose 'redis' or 'messaging'. The memory-default caveat did appear two sentences later, but the headline is what a reader acts on. The same looseness was corrected in the plugin log messages and both plugin READMEs by PR #102; this was the last remaining copy.

  • A LaunchDarkly provider for @hono-enterprise/feature-flags-plugin (provider: 'launchdarkly'), plus an optional IFeatureFlags.isEnabledAsync for callers that can await an answer carrying no cold-context caveat.

  • Real cancellation in @hono-enterprise/resilience-plugin: wrap hands the protected call an AbortSignal, and the returned callable accepts an optional caller-owned one.

  • @hono-enterprise/sdk — the client SDK publishes for the first time. Together with the realtime backplane above, that brings the published total to 38 packages. A portable, zero-npm-dependency HTTP client for consuming a Hono Enterprise API from a browser or a server: createClient() returns an IHttpClient with one request<TResponse, TBody>() method, plus bearer and API-key request-interceptor factories, request/response interceptors, retry with fixed/exponential backoff honoring a delta-seconds Retry-After, a rolling-window circuit breaker, and a sliding-window rate limiter. Both the transport (fetch) and time (IClientTiming) are injectable seams, so nothing needs a network or a real clock to test. It registers no plugin and resolves no capability token — its only in-repo import is type-level from @hono-enterprise/common, which re-exports RetryPolicy, CircuitBreakerPolicy, and BackoffStrategy through the SDK barrel so consumers need not depend on common directly. generateOpenApiClient(document, options?) is a pure function turning an OpenAPI 3.1 document into type-checked TypeScript client source; it throws OpenApiCodegenError with the offending path and method rather than emitting a client that misbehaves or will not compile.

Changed

  • BREAKING: FcmProviderOptions.serverKey is replaced by service-account fields. The push channel now takes { projectId, clientEmail, privateKey } (or a tokenSource) instead of serverKey. This is not a deprecation: serverKey addressed an endpoint Google switched off in 2024, so every send through it already failed. Existing config becomes a compile error, which is the intended signal.

    // Before — never reached a live endpoint
    push: { provider: 'fcm', options: { serverKey: config.get('FCM_SERVER_KEY') } }
    
    // After — values come from the service-account JSON
    push: {
      provider: 'fcm',
      options: {
        projectId: config.get('FCM_PROJECT_ID'),
        clientEmail: config.get('FCM_CLIENT_EMAIL'),
        privateKey: config.get('FCM_PRIVATE_KEY'),
      },
    }

    A push channel using the default signer now needs CAPABILITIES.RUNTIME (for Web Crypto and the clock) and throws during register without it, rather than failing on the first notification.

  • BREAKING (wire format): request-reply traffic moved to a derived channel. request(topic, …) now publishes to, and respond(topic, …) subscribes to, rr.req.<topic> instead of <topic>. A 0.1.0-alpha.2 responder and a later requester do not interoperate — during an upgrade, restart RPC responders and callers together rather than rolling them one at a time. Fire-and-forget publish/subscribe are unaffected.

  • IResilienceService.wrap and ICircuitBreaker.execute widened. wrap<T> now takes a ResilientCall<T> ((signal: AbortSignal) => Promise<T>) and returns a HardenedCall<T> ((signal?: AbortSignal) => Promise<T>). Source-compatible for callers — a zero-argument () => Promise<T> is still accepted and await guarded() still works — but breaking for implementors, because fn sits in a contravariant position, so an object literal declaring wrap<T>(fn: () => Promise<T>) no longer satisfies the interface. Implementors add the parameter.

  • websocket-plugin and sse-plugin register() are now async, awaiting the optional backplane subscription. The kernel already awaited an async register, so applications are unaffected; a test calling plugin.register(ctx) directly must now await it.

Fixed

  • Request envelopes leaked into plain subscribers. A responder shared the raw topic with pub/sub, so a subscribe('orders', …) handler received the raw rr-request envelope instead of the payload. Separate channels fix this at the routing layer.
  • A responder could swallow a competing consumer’s message. Where a responder and an ordinary subscriber shared a topic and a queue (competing-consumer delivery), the responder consumed its share of the round-robin and its envelope guard discarded anything that was not a request — the message vanished with no signal. Fan-out subscribers were unaffected.
  • The reply inbox subscribed without a queue name, so on a broker that falls back to a shared consumer group (Kafka) replies could be delivered to a different instance than the caller, which then discarded them by correlation-id lookup — surfacing as an unexplained timeout. Each inbox now claims its own queue.
  • Resilience timeouts cancel the work they bound. timeout raced the protected call against a timer and left it running; it now aborts the call’s signal with the same TimeoutError instance it rejects with, so a call that forwards the signal to its I/O genuinely stops. Retry stops looping on abort and wakes its backoff early — that sleep also no longer leaks a timer handle on every attempt — and a bulkhead waiter cancelled while queued leaves the queue and never runs its call.
  • Six package pages on jsr.io now show their README. cli, feature-flags-plugin, multi-tenancy-plugin, openapi-plugin, queue-plugin, and storage-plugin rendered a one-line blurb instead in 0.1.0-alpha.2. Not a packaging fault — /README.md was in every published tarball. JSR’s readmeSource defaults to jsdoc and falls back to README.md only when the entrypoint’s module doc has no description; deno_doc drops prose that follows a tag, so a block opening with @module has no description and the README renders, while one whose description comes first and ends with @module replaces the whole page. Those six were the only description-first entrypoints. deno task release:verify now enforces @module-first, because nothing else sees this: the README ships in the tarball, so every gate and deno publish --dry-run stay green and the loss shows up only on jsr.io.

Deprecated

  • MessagingNotSupportedError — no broker throws it now that Kafka implements request-reply. The export is retained so instanceof checks written against alpha.1/alpha.2 keep compiling, and will be removed in the next major. Nothing replaces it; delete the branch.

Notes

One real-time limitation remains, documented rather than silently approximated: Room.size / SseChannel.size report local membership. A cluster-wide count is inherently asynchronous — it needs a scatter-gather across replicas — so it cannot satisfy the synchronous committed size getter and wants a separate async method. That is a later milestone.

RoomBroadcastOptions.except is honored cluster-wide: connection IDs come from runtime.uuid() and are globally unique, so the frame carries the excluded ID and every replica skips it.

A call that ignores its AbortSignal still runs to completion; cancellation is cooperative, and the widened JSDoc says so.

FCM push has not been exercised against live FCM. The HTTP v1 rewrite is asserted field by field — request URL, headers, and body shape — and its RS256 assertion is signed and verified with real Web Crypto, but no test reaches Google: CI holds no Firebase project. The endpoint and auth scheme follow Google’s documented HTTP v1 contract, and the previous serverKey path was provably dead, so this is strictly an improvement — but if you depend on push, verify it against your own project before you rely on it, and please report what you find.

All 38 packages are live on JSR at 0.1.0-alpha.3.

Verified after publishing by querying every package on the registry, then installing kernel and runtime from JSR into a throwaway directory — not the workspace, whose import map resolves locally and would mask a broken published dependency — and serving a request (200 {"ok":true}). common resolved transitively at 0.1.0-alpha.3, which is the only real evidence that the cross-package specifier bump landed inside the published tarballs: a dry run resolves those from the workspace and so cannot show it.

The six package pages that shipped 0.1.0-alpha.2 without a visible README were re-checked and now render theirs. The same check run against their 0.1.0-alpha.2 pages still finds no README content, so it distinguishes the two rather than passing vacuously.

Installing

deno add jsr:@hono-enterprise/kernel@^0.1.0-alpha.3
deno install -g -A -n honoe jsr:@hono-enterprise/cli@^0.1.0-alpha.3/main

Within 24 hours of a release, Deno’s minimum-dependency-age policy refuses the version unless you pass --min-dep-age 0.

0.1.0-alpha.2 — 2026-07-28

Adds the CLI. @hono-enterprise/cli publishes for the first time, bringing the total to 36 packages. Every other package is version-bumped so the scope stays on one version — the CLI needs this, because honoe new stamps generated projects with its OWN version as the range for kernel, runtime, common, and every template plugin. A CLI at alpha.2 alongside a framework at alpha.1 would scaffold projects pinning versions that do not exist.

Added

  • @hono-enterprise/cli — the honoe command: project scaffolding (honoe new, with --template rest|microservice and --runtime deno|node|bun|cloudflare-workers), 13 plugin-aware code-generation schematics, custom schematics, and dispatch of plugin-registered commands (honoe commands, honoe db:migrate).

Fixed

  • Package READMEs linked PUBLIC_API.md relatively (../../PUBLIC_API.md). JSR resolves a README’s relative links against jsr.io/@hono-enterprise/, so every such link 400’d with “package name must contain only lowercase ascii alphanumeric characters and hyphens”. All 44 relative links across 28 package READMEs now use absolute GitHub URLs.
  • ICliApi’s JSDoc described a contract with no consumer; the CLI now reads it.

All 36 packages are live on JSR at 0.1.0-alpha.2.

Verified after publishing by installing honoe from JSR into a clean directory — not the workspace, whose import map resolves locally — scaffolding a rest project with it, generating a controller, type-checking the result against the published packages, then starting it and serving / (200), /health (status: up), and /metrics.

The release pipeline, which had never worked

This was the first release published by CI. 0.1.0-alpha.1 went out by hand from a terminal, because the tag-triggered workflow failed on every attempt. Three separate causes, each only visible by running it:

  1. The publish step lacked --allow-env. publish-packages.ts reads JSR_TOKEN at startup (left unset in CI so the runner’s OIDC identity authenticates instead) and died before touching a package. The root cause was duplication: deno.json’s release:publish task always carried the right permissions, but the workflow inlined its own deno run and that copy drifted. The workflow now calls the task.
  2. It also lacked --allow-net. The already-published check that makes a resumed release idempotent fetches jsr.io — and it is skipped under --dry-run, so a passing dry run proves nothing about a real run.
  3. No package was linked to the GitHub repository. JSR accepts a GitHub Actions OIDC identity only for a package it knows belongs to the repo; without the link, deno publish uploads and then fails with actorNotAuthorized. Token-based publishing does not need the link, which is why 0.1.0-alpha.1 never surfaced it. deno task release:link-repos now does all 36 through the API.

None of the three published anything, so the tag stayed re-runnable throughout.

Installing

deno add jsr:@hono-enterprise/kernel@^0.1.0-alpha.2
deno install -g -A -n honoe jsr:@hono-enterprise/cli@^0.1.0-alpha.2/main

Within 24 hours of a release, Deno’s minimum-dependency-age policy refuses the version unless you pass --min-dep-age 0.

0.1.0-alpha.1 — 2026-07-26

First public prerelease. The framework’s kernel, runtime layer, and 30 plugins are implemented and tested; they publish to JSR under the @hono-enterprise scope.

This is an alpha. The public API is not frozen, and breaking changes may land in any subsequent prerelease without a major-version bump. Do not use it in production.

All 35 packages are live on JSR at 0.1.0-alpha.1.

Verified after publishing by installing kernel, runtime, metrics-plugin, and telemetry-plugin from JSR into a clean project — not the workspace, whose import map resolves locally — starting an application, serving a request (200), and scraping the /metrics endpoint.

The release took two attempts. A JSR scope may create only 20 new packages per rolling 7-day window by default; the first run created 20 and stopped. JSR raised the quota to 40 on request, and the remaining 15 followed. Both halves carry the same version, and the publish order guarantees common and kernel land before anything that depends on them, so the intermediate state was never inconsistent.

Installing a prerelease

JSR does not tag a prerelease as latest, so every specifier must be version-pinned:

deno add jsr:@hono-enterprise/kernel@^0.1.0-alpha.1

A bare deno add jsr:@hono-enterprise/kernel fails with “has only pre-release versions available”. Within 24 hours of a release, Deno’s minimum-dependency-age policy additionally refuses the version unless you pass --min-dep-age 0.

All packages in this release

35 packages, all at 0.1.0-alpha.1:

Core — common, kernel, runtime, exceptions, testing

Request path — logger-plugin, config-plugin, validation-plugin, http-security-plugin, auth-plugin

Data — database-plugin, cache-plugin, storage-plugin, multi-tenancy-plugin

Messaging & work — events-plugin, cqrs-plugin, messaging-plugin, queue-plugin, scheduler-plugin, worker-pool-plugin

Real-time — sse-plugin, websocket-plugin, react-router-plugin

Operations — metrics-plugin, health-plugin, telemetry-plugin, audit-plugin, resilience-plugin, secrets-plugin

Delivery — mail-plugin, notification-plugin, feature-flags-plugin

Optional ergonomics — di-plugin, decorator-plugin, openapi-plugin

Deliberately excluded

@hono-enterprise/cli, @hono-enterprise/sdk, and the three starter bundles (rest-starter, microservice-starter, full-stack-starter) are not part of this release. They are stubs that export nothing; publishing them would put empty pages on JSR, where versions are immutable. They ship when their milestones land (the CLI is Milestone 34).

Runtime support

Node.js, Deno, Bun, and Cloudflare Workers, via Hono’s fetch entry point and the runtime’s HTTP adapters. Individual plugins document their own constraints — SMTP needs raw sockets, worker pools need real threads, and neither exists on Workers.

Optional heavy dependencies (Prisma, ioredis, amqplib, kafkajs, nodemailer, the OTel SDK, ws, …) are never hard dependencies. Each is injected through plugin options or imported lazily via an npm: specifier, so an application only pays for what it configures.

Known limitations

Five entries in this list have since been closed; each is annotated in place rather than deleted, because this section records what was true of this release. See 0.1.0-alpha.3 for the work that closed them.

  • notification-plugin FCM push is non-functional. It implements the legacy FCM serverKey API, which Google decommissioned in 2024. FCM HTTP v1 with service-account JWT signing is a follow-up. (True of this release. Superseded — see 0.1.0-alpha.3, where the provider moves to HTTP v1 and push delivery works.)
  • LaunchDarkly is unsupported in feature-flags-plugin. The LaunchDarkly Node server SDK’s variation/allFlagsState are async and cannot satisfy the synchronous committed isEnabled contract. Use the provider’s 'custom' arm as a bridge. (True of this release. Superseded — see 0.1.0-alpha.3, which adds a 'launchdarkly' provider and an optional isEnabledAsync.)
  • KafkaBroker does not support request-reply. Kafka’s consumer-group and auto-commit model does not fit the pattern; request()/respond() throw MessagingNotSupportedError. (True of this release. Superseded — see 0.1.0-alpha.3, where Kafka becomes reply-capable; the limitation was in the shared request-reply core, not in Kafka.)
  • Rooms and channels are in-process. websocket-plugin rooms and sse-plugin channels are not shared across replicas; cross-instance fan-out is a later milestone. (True of this release. Superseded — see 0.1.0-alpha.3, which adds realtime-backplane-plugin. Room.size / SseChannel.size remain local-only.)
  • resilience-plugin timeouts do not cancel. timeout races the promise; the wrapped function keeps running. (True of this release. Superseded — see 0.1.0-alpha.3, where wrap hands the protected call an AbortSignal and the timeout aborts it.)
  • Node and Bun compatibility suites have not run. They consume the packages through JSR’s npm compatibility layer and were therefore blocked on this publish — they are unblocked by it, and will run before the first stable release. Milestone 40 owns that verification, alongside benchmarks and the security audit.

Milestones in this release

Milestones 0–33 and 41–46. See ROADMAP.md for scope per milestone and PUBLIC_API.md for the full exported surface.