# Changelog v2 Concise release history for `@classytic/arc` v2. ## 2.18.5 Adopt the mongokit 3.16.1 PATCH-safety fix, ship browser-SSE bearer-auth ergonomics, and harden the `arc init` scaffold. ### Added - **Browser `EventSource` / `WebSocket` bearer auth** — `promoteStreamTokenToHeader()` + `forwardedStreamHeaders()` (`@classytic/arc/utils`). The browser `EventSource` and `WebSocket` constructors cannot set request headers, so arc-next's `buildStreamUrl` (bearer mode) appends the JWT as `?token=`; this helper promotes that query token into the `Authorization` header in place so the standard `fastify.authenticate` decorator reads it — no per-stream auth fork. No-op when an `Authorization` header is already present (plain `fetch` is untouched), and the token still goes through full authentication, so it only relocates *where* the bearer is read from. Wired into the SSE and streamline stream routes. ### Changed - **`tsdown` externalization** — replace the hand-maintained `neverBundle` allowlist with `deps.skipNodeModulesBundle: true` (+ scoped roots for workspace symlinks). arc bundles nothing from `node_modules`; new peers are externalized automatically and the config can't drift from `package.json`. - **Bump `@classytic/mongokit` to `^3.16.1`** (devDep + `arc init` scaffold pin). 3.16.1 strips `default` keywords from the generated update-body schema, fixing a silent PATCH data-loss bug: arc's `createApp` runs AJV with `useDefaults: true`, so a nullable field carrying `default: null` (e.g. `publishedAt`) had `null` injected into any PATCH that omitted it and the repo `$set` it — nulling stored data on a content-only edit. New e2e regression `tests/e2e/update-default-injection.test.ts` drives the whole pipeline (route schema → AJV → BodySanitizer → `$set`) and is load-bearing (fails against 3.16.0, passes on 3.16.1). Hosts on mongokit ≥3.16.1 need no per-app `stripDefaults` workaround. - **`arc init` scaffold** (2.18.5): typed env via Zod (schema + fail-fast) in `src/config/index.ts`; DB readiness check wired into `arcPlugins.health` + `onClose` mongoose drain (arc's gracefulShutdown is already on by default — no hand-rolled SIGTERM); removed dead `loadResources` / `registerResources` imports (`createApp({ resources })` mounts everything); shipped `biome.json` + `.github/workflows/ci.yml` + lint/format/typecheck scripts, with a post-install `biome --write` pass so a fresh scaffold is house-style-clean and `npm run lint` is green on first run; trimmed `package.json#imports` to the dirs that exist; template lint hygiene (node: protocol, conditional Mongoose `Types` import, fixed the example test's broken import). The better-auth path already used the lazy-singleton `getAuth()` + `registerBetterAuthStubs` pattern; left as-is. ## 2.18.4 SSE lazy-auth fix — the SSE plugin resolves `fastify.authenticate` lazily at request time (asserted fail-closed in `onReady`) instead of capturing it at registration, so it works regardless of the fixed arcPlugins→auth boot order. ## 2.18.3 Ecosystem sync to repo-core 0.6.0 / mongokit 3.16.0 / sqlitekit 0.6.0, distributed-systems hardening, internal-debt refactor sweep, and three review fixes. Arc's peer floor stays `@classytic/repo-core >=0.5.0` — every 0.6 contract change is additive against arc's imports. ### Added - **`traceId` repo-option forwarding** — `buildTenantRepoOptions` now forwards the 32-hex W3C trace id (from the requestId plugin's validated `traceparent`) under repo-core 0.6's canonical `traceId` key, alongside `requestId`. Kits/audit plugins pick it up with zero host wiring. - **BullMQ repeatable-schedule reconciliation** — at boot, jobs whose `repeat` spec (pattern / every / tz / endDate) changed between deploys get their stale BullMQ scheduler removed before re-add, so the old cron no longer double-fires alongside the new one. Feature-detects `getJobSchedulers()` with `getRepeatableJobs()` fallback; only touches arc-registered job names; fail-open with a warn. - **Jittered poll-error backoff (Redis Streams)** — consumer poll errors now back off with capped exponential full-jitter (1s base, 30s cap, reset on healthy poll) instead of a fixed 1s sleep, preventing fleet-wide retry stampedes after a Redis blip. Stale-message recovery (`XPENDING`) also scales with `batchSize` instead of a hardcoded 10-entry window. - **Streamline tenant-ownership pre-flight** — per-run routes (get, resume, cancel, execute, wait) now 404 on runIds belonging to another tenant (identical to "not found", so cross-tenant probing leaks nothing) whenever the request carries tenant scope; tenantless / bypass deployments are unchanged. New `STREAMLINE_STREAM_EVENTS` export for streamline ≥2.6 `ctx.stream()` frames — SSE-only by design, never bridged onto the domain event transport. ### Fixed - **`configure()` partial-rebuild state loss** — construction-time `defaultSort: false` and `onFieldWriteDenied: 'strip'` survived only until the next `configure({ schemaOptions })`, which rebuilt QueryResolver / BodySanitizer with defaults (`-createdAt`, `reject`). Both are now retained instance state. Matters for SQL kits without a `createdAt` column and legacy strip-semantics hosts. - **Multi-node diagnostic-map leaks** — redis-stream `failureContext` (full stack traces) and outbox attempt counters leaked when a failing message was reclaimed and resolved by *another* consumer/worker; both are now bounded with oldest-evict (1,024 / 10,000) and cleared on close. - **`prepublishOnly` now runs `test:ci`** (main + isolated perf/leak lane) instead of `test:main`, matching the documented release gate. ### Changed - **devDeps/scaffold sync** — devDeps bumped to mongokit `^3.16.0`, repo-core `^0.6.0`, sqlitekit `^0.6.0`; `arc init` scaffold pins updated (repo-core `^0.6.0`, mongokit `^3.16.0`, arc `^2.18.3`) — fresh scaffolds no longer ERESOLVE against mongokit 3.16's `repo-core ^0.6.0` peer. - **`createMockRepository` ships `capabilities`** — repo-core 0.6 made `StandardRepo.capabilities` required; the mock declares an honest all-false matrix (override alongside any methods you mock). - **Internal refactor sweep (zero behavior/API change)** — `routerShared.ts` → 65-line shim over new `core/middlewares/`; `aggregation/validate.ts` → focused validators + pure `normalize.ts`; defineResource Phase-0 shorthands → `defineResource/normalizeConfig.ts`; `BaseCrudController` 1,225→~960 lines with pure helpers in `core/crud/`. Middleware order and error messages byte-identical. - **Default-export exception re-documented** as the class of `fp()`-wrapped plugin entries (19 files) instead of a stale 5-name list (CLAUDE.md / AGENTS.md / skill reference). ## 2.18.2 ### Fixed — streamline DELETE route 500s with `Cannot read properties of undefined (reading '_buildContext')` `deleteRepo.delete` and `deleteRepo.getById` were pulled off the repository object and called detached. `Repository` instance methods use `this` internally (`_buildContext`, tenant-filter plugin, cache), so calling them unbound made `this` undefined at call time and crashed the handler. Fixed by binding both to the repo before wiring the route. ## 2.18.1 ### Fixed — `disableCrud` type stub removed + `disableDefaultRoutes` doc clarified `disableCrud` was present in the `ResourceConfig` type but was never read by the router — it silently no-opped. Removed; `disableDefaultRoutes` (or `crud: false`) is the only kill-switch and is now documented clearly in the type. ### Fixed — `runtime: 'distributed'` error names exact store keys + fix hints `validateDistributedRuntime` previously threw a vague "Missing: events transport, cache store" list. Now each entry is `stores.` so the error points directly at what to configure and how. ### Added — `TERMINAL_RUN_STATUSES` exported from `@classytic/arc/integrations/streamline` Mirrors `@classytic/streamline`'s `isTerminalState()` — a run in one of these statuses emits no further bus events, so SSE handlers can close immediately instead of holding a connection open on a dead run. ## 2.18.0 Durable WebSocket layer + spatial subscriptions + DB Unit-of-Work hook + MCP realtime bridge. No breaking changes — every addition is opt-in. ### Added — durable WebSocket envelope (`pushRef`-registry, ack/replay, dead queue) WebSocket frames now ship with an opt-in `pushRef` envelope (UUID per outbound message). Hosts get: - **`pushref-registry`** — tracks in-flight messages per principal so reconnects can replay un-acked frames. - **`send-queue`** — per-connection outbound queue that backs replay + flow-control. Crash- and disconnect-safe. - **`dead-queue`** — messages that exceed the replay TTL or retry count land here instead of being silently dropped, so hosts can surface a reliable "you missed N messages" signal. - **`outbound-truncate`** — bounds per-connection memory growth under slow consumers; configurable cap with a single-shot host warning when the bound trips. - **`safe-async`** — wraps async handlers in the WS pipeline so a thrown promise can't crash the connection silently. ### Added — `@classytic/arc/integrations/websocket-pushref-redis` New subpath. `RedisPushRefStore` plugs into `websocketPlugin` to make the envelope + dead-queue + principal binding **cross-instance** — a client that reconnects to a different node still gets its un-acked replay. Ships with a smoke script (`scripts/smoke-ws-redis.mjs`) and `docker-compose.smoke.yml` for local verification. ### Added — geo-room (H3 spatial subscriptions) The Uber / Lyft / Grab pattern for "drivers near rider" feeds: tile the world into H3 hexagons and use each cell as a WebSocket room. Publishers fan out to their cell + k-ring neighbors; subscribers listen to the same set; intersection = "within ~radius" with no per-message math. - New peer: **`h3-js >=4.0.0`** (only required when the geo-room API is imported; standard `websocketPlugin` use is unaffected). ### Added — `runInTransaction()` Unit-of-Work via AsyncLocalStorage `src/context/transactionContext.ts`. Any code in the call stack can retrieve the active DB session without it being threaded through every function parameter. Arc stays DB-agnostic — session type is `unknown` so Mongoose / Prisma / Drizzle / pg sessions all plug in identically. Kit adapters call `runInTransaction(session, fn)` once at the boundary; repositories pick the session up implicitly. ### Added — MCP realtime tool bridge `src/integrations/mcp/realtimeBridge.ts`. Accepts `LiveServerToolCall`-shaped frames (LiveKit Agents, Gemini Live, OpenAI Realtime) and dispatches them through arc's existing MCP tool registry — same Zod input validation, same handler invocation, same session binding. Realtime AI sessions speak a slightly different wire format but the semantics are identical. ### Added — request-id trace-context propagation `requestId` plugin now honors W3C `traceparent` / `tracestate` so request ids flow into distributed tracing systems without a separate correlation header convention. ### Added — outbox provider tests + jobs status + MCP filter coverage `tests/events/outbox-providers.test.ts`, `tests/integrations/jobs-status.test.ts`, `tests/integrations/mcp/filter-resources.test.ts` extended. ### Changed — websocket test layout Per-feature tests moved into `tests/integrations/websocket/`; the old flat `tests/integrations/websocket*.test.ts` files were consolidated. Behavior unchanged. ## 2.17.1 ### Fixed — `pipeUIMessageStreamToReply` runs Fastify's `onSend` hook chain `pipeUIMessageStreamToReply` was setting SSE headers via `reply.raw.setHeader()` + `flushHeaders()`, which bypasses Fastify's response lifecycle. That silently dropped cross-origin / auth / cookie headers contributed by plugins like `@fastify/cors`, `@fastify/jwt`, and any `onSend` hook the host wires. Now headers go through `reply.header(key, value)` so the full hook chain runs — CORS, cookies, signed cookies, every `onSend` plugin merges its headers in before the response is flushed. Behavior unchanged for hosts without those plugins. Internal: stream wired through `Readable.fromWeb()` so Fastify's serializer sees a Node stream and threads it through the lifecycle properly. Lock-in: `tests/utils/streaming.test.ts` extended with a CORS plugin scenario asserting `Access-Control-Allow-Origin` survives. ## 2.17.0 Stabilization + security release. Closes five silent-drop classes (each previously cost a host hours to debug), shuts the prototype-pollution door on every untrusted JSON boundary arc decodes, bounds the idempotency fingerprint hot path against deep-nesting DoS, ships an exported `buildScope` + scope-threading fix that MCP-driven hosts have been hot-patching locally, and adds `GET /jobs/:id/status` + per-job `maxConcurrent` semaphore on the jobs integration. No breaking changes — every behavior change is either a new guard, a new diagnostic, or a fix to a silently-broken contract. ### Security — prototype pollution on untrusted JSON Arc already declared `secure-json-parse@^4.1.0` as a direct dep but only used it in `createApp.ts`'s content-type parser override. Five other trust-boundary sites decoded JSON with raw `JSON.parse`, leaving Object.prototype poisoning open to anyone who could put bytes on those boundaries: - `src/auth/redis-session.ts` — session payloads read back from Redis (anyone with Redis write access could pollute every consumer's prototype on the next session load). - `src/events/transports/redis.ts` — cross-service Redis pub/sub event payloads. - `src/events/transports/redis-stream.ts` — cross-service Redis Streams entries. - `src/integrations/websocket/connection.ts` — untrusted WebSocket client frames. - `src/middleware/multipartBody.ts` — attacker-controlled multipart text fields parsed opportunistically as JSON. All five now route through `sjson.parse` (with reviver preserved on the two transports that use one for `Date` revival). Lock-in: `tests/security/prototype-pollution-untrusted-json.test.ts` — exercises every boundary with a `{"__proto__":{"polluted":"yes"}}` payload and asserts `Object.prototype.polluted` remains undefined after. ### Security — idempotency fingerprint depth-bomb `normalizeBody()` in `src/idempotency/idempotencyPlugin.ts` recursed unbounded over the request body to produce a key-stable fingerprint. Fastify's 1 MiB `bodyLimit` bounds total payload size, but a 1 MiB JSON body shaped as `{"a":{"a":{...250K levels...}}}` still nested deep enough to blow the V8 stack mid-hot-path. Pre-fix that landed as an unhandled `RangeError: Maximum call stack size exceeded` inside the idempotency preHandler — the worker died mid-request. Post-fix: a `MAX_FINGERPRINT_DEPTH = 32` cap substitutes a `""` sentinel past the cap so the hash still differs from siblings, recursion is bounded, and real APIs (which essentially never exceed depth 10) are untouched. Lock-in: `tests/idempotency/fingerprint-depth-bomb.test.ts` (1000-deep payload through the full plugin path). ### MCP — exported `buildScope` + `evaluatePermission` scope threading Two related bugs that hosts (notably fajr-be-arc) had been hot-patching against the published dist: 1. **`buildScope` was not exported** from `src/integrations/mcp/buildRequestContext.ts`. Now exported as a public-stable helper for MCP tool authors that need to resolve an `McpAuthResult` into a canonical `RequestScope` (e.g. when constructing the permission-check context themselves). 2. **`evaluatePermission` in `src/integrations/mcp/tool-helpers.ts` did not populate `request.scope` on the fake request it built for the permission check.** HTTP paths populate BOTH `request.scope` (auth adapter) AND `request.metadata._scope` (`applyPermissionResult`) — and helpers like `requireOrgMembership()` read from `request.scope` via `getRequestScope(req) = req.scope ?? PUBLIC_SCOPE`. With neither populated on the MCP fake-request, every authenticated MCP call rejected as "Organization membership required" even when `session.organizationId` was present. Post-fix calls `buildScope(session)` and threads the result through both slots. Lock-in: `tests/integrations/mcp/mcp-fake-request-scope.test.ts` — 7 cases pinning `requireOrgMembership` + `requireOrgRole` on authenticated and anonymous MCP sessions, plus the `buildScope` export contract. ### DX — boot diagnostics for silent-drop misconfigurations Three new first-mount warnings for declared-but-not-wired resource features. Each previously cost a host hours of "why isn't my X working?" debugging because the feature flag silently did nothing. - **`cache: { invalidateOn: ... }` declared without `queryCachePlugin`** — pre-fix `src/core/defineResource/plugin.ts:421` silently dropped the invalidation rules when `fastify.registerCacheInvalidationRule` was absent. - **`audit: true` declared without `auditPlugin`** (or `auditPlugin` registered with `enabled: false`, i.e. the noop logger) — the flag was dead config; audit events never recorded. Detection uses a new `_noop: true` marker on the noop logger from `auditPlugin.ts:createNoopLogger()` so both "plugin absent" and "plugin disabled" surface the warning. - **`events: { ... }` declared without `eventPlugin`** — `arcCorePlugin`'s post-hook already short-circuited emission with `if (!hasEvents(fastify)) return`, so a resource declaring `events: { created: {} }` silently emitted nothing. Warning lists the declared event names so the host sees exactly what won't fire. Each warning names the resource, the missing plugin, and the literal config to either add it or remove the dead flag. Lock-in: `tests/core/boot-diagnostics-silent-drops.test.ts` (6 cases) + `tests/core/events-without-plugin-diagnostic.test.ts` (5 cases). ### DX — `FST_ERR_DUPLICATED_ROUTE` rewrap with `disabledRoutes` hint Boot-time `validateRouteCrudCollisions()` (shipped 2.16.1) catches the common custom-route ↔ auto-CRUD overlap before Fastify ever runs, but a few cases still slip through to Fastify itself: two presets emitting the same `routes` entry, two resources mounting at the same prefix, a custom plugin pre-registering the URL. Fastify's default message ("Method 'GET' already declared for route '/'") gives no hint at the resource-shaped fix — `disabledRoutes: ['']`. Arc now wraps `fastify.route(...)` at every registration site through a new shared helper `tryRegisterRoute()` (in `src/core/routerShared.ts`) that intercepts `FST_ERR_DUPLICATED_ROUTE`, preserves the `code` and `cause` for callers that branch on `err.code`, and rewraps the message with the resource name + the literal `disabledRoutes: ['']` fix when the op is a CRUD slot (generic enum hint for other ops). Wired into `createCrudRouter` (auto-CRUD + custom routes) and `createActionRouter` (action endpoints). Lock-in: `tests/core/route-duplicate-hint.test.ts` (5 cases). ### DX — auto-apply field-write permissions on custom routes Auto-CRUD's create/update routes have always run user-declared `fields: { x: fields.writableBy(['admin']) }` rules through `BodySanitizer` inside `BaseController`. Custom routes — `config.routes`, presets — never went through that path. A host that declared `fields: { role: fields.writableBy(['admin']) }` on a resource and added a custom `POST /users/promote` happily accepted `{ role: 'admin' }` from any caller; the field-write gate was bypassed. 2.17 closes that gap. `createCustomRoutes` now appends a `buildFieldWritePreHandler()` to body-bearing custom routes (POST/PUT/PATCH) when the resource declares `fields`, honoring the resource-level `onFieldWriteDenied: 'reject' | 'strip'` policy that's already in place for auto-CRUD. New per-route escape hatch `RouteDefinition.fieldWrite: false` opts a specific route out; `raw: true` routes bypass entirely (they've opted out of the pipeline already). The helper applies ONLY `applyFieldWritePermissions` — no `SYSTEM_FIELDS` or `fieldRules` strip — because custom-route bodies don't necessarily mirror the resource's adapter shape, and enforcing rules tied to that shape would generate surprising 403s. Lock-in: `tests/core/custom-route-field-write.test.ts` (14 unit cases) + `tests/core/custom-route-field-write-integration.test.ts` (7 end-to-end cases through `defineResource` + Fastify inject). ### Jobs — `GET /jobs/:id/status` + `maxConcurrent` semaphore `@classytic/arc/integrations/jobs` gains two host-facing additions: - **`GET /jobs/:id/status` endpoint** (and a programmatic `fastify.jobs.getStatus(jobId)` mirror) — searches all registered queues via BullMQ's `queue.getJob(id)` and returns `{ id, name, state, progress, throttled?, timestamp?, processedOn?, finishedOn?, failedReason?, returnValue? }`. 404 when the job doesn't exist or has been removed by retention policy. Lightweight polling for hosts that don't want to wire BullMQ events themselves. - **`JobDefinition.maxConcurrent`** — arc-level semaphore on the worker's handler. Jobs that find all slots full wait (their BullMQ state stays `active`) and surface as `throttled: true` on the status endpoint. Distinct from BullMQ's `concurrency` (per-worker dequeue parallelism) — use `maxConcurrent` when the constraint is a downstream resource (AI model rate limit, DB connection pool, external API). Slot is released in a `finally` so timeouts and handler throws can't leak. The new helpers live in the existing `jobs.ts` (no new subpath); the ambient `bullmq` declaration in `src/optional-peers.d.ts` gains `Queue.getJob(id)`. Lock-in: `tests/integrations/jobs-status.test.ts` (8 cases — 404, return-by-id, `throttled` flag, semaphore queueing + release-on-throw). ### DX — `TenantPurgeStrategy` exposure in audit (full discriminated union) `getCascadingResourcesWithMetadata()`, `TenantDataLeak.strategy`, `PurgeResourceOutcome.strategy`, and `CascadeResourceReport.strategy` already returned the full `TenantPurgeStrategy` discriminated union in 2.16.1 — this release keeps that contract stable. No change. ### DX — public `warnings` getter on `ResourceDefinition` `_diagnostics` is an internal collection populated by define-time validation (redundant field-rule flags, ambiguous preset combinations). Hosts wiring CI gates (lint, pre-commit, "fail on warnings") needed a stable, public read API. `ResourceDefinition.warnings` exposes a frozen view of the same diagnostics — same shape, no `_` prefix — so hosts can: ```ts import { orderResource } from './resources/order.resource.js'; if (orderResource.warnings.length > 0) process.exit(1); ``` Covers define-time only; first-mount diagnostics surface through `fastify.log.warn` since they require a Fastify instance to detect. Lock-in: `tests/core/resource-warnings-getter.test.ts` (4 cases). ### DX — `defineResource` actions-type narrowing `defineResource(config): ResourceDefinition` only generic'd over `TDoc` (document type). The `actions?: ActionsMap` field on the return type stayed `ActionsMap | undefined` regardless of whether the host declared a non-empty `actions: { ... }` literal at the call site. Every host that accessed `resource.actions.send(...)` got TS error `"'resource.actions' is possibly 'undefined'"` — surfaced by Prism's partner team in `media-sfx-music.test.ts:64` after an SDK bump tightened the transitive types. 2.17 adds a narrow overload `defineResource(config & { actions: TActions }): ResourceDefinition & { readonly actions: TActions }` that captures the literal map shape and narrows the return type to the exact captured actions. TS infers `TActions` from the object literal — no explicit generic needed at the call site. The wide overload (no `actions` declared) keeps the pre-2.17 signature intact so existing consumers don't break. Runtime body of `defineResource` is unchanged. Lock-in: `tests/core/define-resource-actions-narrowing.test.ts` (4 cases). ### Security — `mcpPlugin` loud WARN when `auth: false` Real-world incident pattern from the Prism team: 1. Dev sets `auth: false` to skip OAuth setup during local development. 2. The same Fastify app gets exposed through a public tunnel (Cloudflare Tunnel / ngrok / Tailscale Funnel) for demos or sharing. 3. The MCP endpoint becomes a fully-anonymous remote CRUD + action surface across every resource registered — provider keys, tenant data, workflows, all reachable by anyone with the URL. Pre-fix, `mcpPlugin` registered the route silently when `options.auth === false`. Post-fix, one unmissable WARN fires at every boot per-registration, naming the prefix, the tool count, and the first 5 tool names so the host sees the magnitude of exposure in its log stream. Hosts who legitimately want auth-less MCP (stdio transports, explicit public read-only APIs) can silence via their logger configuration. Lock-in: `tests/integrations/mcp/mcp-auth-disabled-warning.test.ts` (5 cases — fires on `auth: false`, contains prefix + tool names, suppressed when `auth` is a function, single firing per registration, separate warnings for multi-registration setups). ### arc-ai host DX — UI message stream pipe `streamResponse: true` routes that returned a Web `ReadableStream` (Vercel AI SDK's `result.toUIMessageStream()`, `convertToUIMessageStream()`, raw `fetch().body`) crashed Fastify with `chunk must be a string or Buffer` because the framework's reply writer can't serialise structured chunks. Every arc-ai host (sniffer, spawn, downstream) had reimplemented the same `JsonToSseTransformStream` + `UI_MESSAGE_STREAM_HEADERS` boilerplate. Two-part fix: - `src/utils/streaming.ts` — new `pipeUIMessageStreamToReply(reply, stream, opts?)` + exported `UI_MESSAGE_STREAM_HEADERS` constant (including the `x-vercel-ai-ui-message-stream: v1` protocol marker the AI SDK client uses to detect v1 streams). Sets headers, JSON-encodes each chunk into an SSE `data:` frame, wires `request.raw.close` → `stream.cancel()` so upstream LLM calls stop on client disconnect (no zombie spend), backpressures on `raw.write` returning false, and emits a terminal `event: error` frame on failures so the client sees a structured close instead of a half-open socket. - `src/core/createCrudRouter.ts` — `streamResponse: true` handlers that return a Web ReadableStream now auto-route through the helper. Handlers that write to `reply.raw` directly (the historical contract) keep working unchanged — detected via the new `isReadableStream()` duck-check on the handler return value. Hosts that previously hand-rolled the SSE boilerplate can delete it and return the stream directly. Exported from `@classytic/arc/utils` (`pipeUIMessageStreamToReply`, `UI_MESSAGE_STREAM_HEADERS`, `isReadableStream`, `PipeUIMessageStreamOptions`). Lock-in: `tests/utils/streaming.test.ts` (7 cases — pipe contract, default headers, custom serialiser, error frame, isReadableStream) + `tests/core/stream-response-autopipe.test.ts` (2 e2e cases — returned stream and reply.raw both work on the same code path). ### MCP — tool-name collision detection with source attribution The `restore_` collision between the `softDelete` preset's restore route and a user-declared `actions.restore` raised an opaque `Tool already registered` from inside `McpServer.registerTool` with no hint that arc was the source. Every host that hit it ground through the same debugging session. Three changes: - `ToolDefinition` (`src/integrations/mcp/types.ts`) gained an optional `source?: string` field so collision diagnostics can attribute both sides (`'crud:post:list'`, `'action:post:approve'`, `'route:post:GET /export'`, `'preset:softDelete:post:restore'`). - `src/integrations/mcp/resourceToTools.ts` stamps `source` on every generated tool (CRUD, custom routes, actions). Preset-emitted routes carry a sidecar `_presetSource` marker (added to `RouteDefinition` as an internal field, stamped by `src/presets/softDelete.ts`) so the resolver can tell preset routes apart from user-authored ones. - `src/integrations/mcp/createMcpServer.ts` — new `resolveToolCollisions()` runs before any `srv.registerTool()` call. Outcomes: (1) **auto-namespace** when the colliding pair is exactly one preset + one user tool — the preset side becomes `_` (e.g. `softdelete_restore_post`), the user's tool keeps its name (its intent is more specific). (2) **Typed throw** (`ArcError('arc.mcp.tool_name_collision')`) for every other shape (two user actions, two custom routes, two presets, three-way+), naming **both sources** in the message so the stack trace points at the resource definitions, not the MCP SDK internals. The resolver is exported so the testing harness can exercise collisions without a server boot. Lock-in: `tests/integrations/mcp/tool-collision.test.ts` (6 cases — unique pass-through, auto-namespace, user name preserved, typed throw with both sources, three-way collision, `(unknown)` label for missing source). ### MCP — Better Auth API-key resolver factory `createMcpAuthFromBetterAuthApiKey(auth, opts?)` (new file `src/integrations/mcp/betterAuthApiKey.ts`) wraps the `auth.api.verifyApiKey(...)` call every host wired by hand into an `McpAuthResolver`. Handles the two field-name traps the manual wiring kept hitting: - `key.referenceId` vs `key.userId` — falls back through `userId → referenceId → undefined`; a key with neither but with `key.id` is treated as a service principal (`clientId`), matching arc's `kind: 'service'` scope. - Organization scope lives in `key.metadata.organizationId` (Better Auth's API-key plugin has no `organizationId` column — hosts stamp it into metadata at creation). The helper extracts that field by default; `{ orgFromMetadata: false }` opts out for single-tenant deployments and `{ orgFromMetadata: (meta, key) => string }` supports custom extractors. `{ metadataOrgField: 'tenantId' }` covers hosts that named the column differently. Header precedence matches arc's REST auth: `Authorization: Bearer` first, then `x-api-key`. Disabled / expired keys return null (second-line check — Better Auth's verifier should catch these but the v1.6.x plugin has a known race where a key rotated mid-request still validates against the prior cache slot). `verifyApiKey` exceptions are swallowed to null so the caller's fail-closed policy applies uniformly. JSON-stringified `metadata`/`permissions` columns (SQL kits) are round-tripped through `JSON.parse` before extraction. Exported from `@classytic/arc/mcp` (`createMcpAuthFromBetterAuthApiKey`, `CreateMcpAuthFromBetterAuthApiKeyOptions`). Lock-in: `tests/integrations/mcp/better-auth-api-key.test.ts` (14 cases — construction-time guard, header precedence, referenceId fallback, service-principal path, JSON metadata, custom field, function extractor, disabled key, expired key, throw → null, scopes flattening). ### DX — pagination-cap aware 400 envelope `?limit=200` against a `maxLimit=100` resource used to surface only `"Bad Request"` — every host had to dig into `details` to learn the cap. `src/plugins/errorHandler.ts` now enriches Fastify validation responses in two places: - `fastifyValidationDetails()` lifts AJV's `params.limit` (the threshold for `maximum`/`minimum`/`exclusiveMaximum`/`exclusiveMinimum`) onto `detail.meta.bound`, and `params.allowedValues` for `enum` onto `detail.meta.allowedValues`. Programmatic callers no longer have to parse the human-readable message to learn the constraint. - New `describePaginationCap()` hoists the common pagination case: when AJV rejects `/limit` or `/page` on a `maximum`/`minimum` keyword, the top-level wire envelope's `message` becomes `Query parameter 'limit' must be <= 100 (got 200) (cap is 100)` and `meta` carries `{ field: 'limit', cap: 100 }` so callers can self-correct without string-scraping. Lock-in: `tests/plugins/error-handler-pagination-cap.test.ts` (4 cases — hoist for `?limit=`, hoist for `?page=`, non-pagination `maximum` keeps generic message but stamps `meta.bound`, `enum` stamps `meta.allowedValues`). ### DX — `referenceData: true` shorthand + resource-level pagination knobs on `defineResource` The recurring "small static catalogue" shape (currencies, countries, plans, pipeline stages, credential types) had hosts hand-wiring `defaultLimit` + `maxLimit` + a custom queryParser + a `cache:` block on every resource. Two related additions: - `ResourceConfig.referenceData?: boolean` (new). When `true`, expands in `src/core/defineResource.ts` Phase 0 to `crud: { list: true, get: true }` (read-only — reference data is mutated via migrations/admin tools, not the public REST surface) + `defaultLimit: 1000` + `maxLimit: 1000` + `cache: { staleTime: 300, gcTime: 600 }`. Explicit narrow flags always win, so `referenceData: true, crud: { list: true, get: true, create: true }` opts INTO mutations and `referenceData: true, cache: { staleTime: 60 }` overrides the default cache window. The marker also surfaces on `ResourceDefinition.referenceData` so introspection (registry/MCP/OpenAPI) can label the resource. - `ResourceConfig.defaultLimit?: number` + `ResourceConfig.maxLimit?: number` (new). Resource-level knobs that flow directly into the OpenAPI listQuery schema via a new `applyResourcePaginationCaps()` step in `src/core/defineResource/schemas.ts` AND into the auto-built `BaseController`'s QueryResolver. Resource-level caps win over both the framework default (100) and any parser-derived value because they're the most-explicit declaration. Means a custom queryParser is no longer required just to declare "this resource returns up to 500 rows per page". Lock-in: `tests/core/reference-data-shorthand.test.ts` (7 cases — marker on the definition, read-only crud default, explicit crud override, cache defaults, cache override, listQuery cap lifted to 1000, explicit `defaultLimit`/`maxLimit` win over the shorthand). ### DX — `customRoutesOnly: true` shorthand on `defineResource` The "service resource" pattern (custom routes only, no adapter, no auto-CRUD) required hosts to set `disableDefaultRoutes: true` + `skipValidation: true` + `skipRegistry: true` in lockstep — forgetting any one produced confusing errors ("controller required when CRUD routes are enabled" for a resource that ships none). New shorthand expands to all three in `src/core/defineResource.ts` (`expandCustomRoutesOnly()`, runs as Phase 0a before the validator and the `crud:` allow-list resolver so both see the expanded shape). Explicit narrow flags always win — `customRoutesOnly: true` + `skipRegistry: false` keeps OpenAPI docs for a custom-routes-only resource that still wants introspection. The three primitive flags stay public for escape-hatch use. Lock-in: `tests/core/custom-routes-only-shorthand.test.ts` (4 cases — expansion, narrow override, end-to-end boot of a service resource, no effect when shorthand omitted). ### Internals - `src/optional-peers.d.ts` — `Queue.getJob(id)` declaration added so the new `getStatus` path typechecks without bashing through `unknown`. - `src/audit/auditPlugin.ts:createNoopLogger()` — adds a non-API `_noop: true` marker so `buildResourcePlugin` can distinguish "audit decorator absent" from "audit decorator present but no-op" and emit the warning for both. - `src/core/routerShared.ts` — `tryRegisterRoute()` + `buildFieldWritePreHandler()` + `methodCarriesBody()` helpers consolidated here. CRUD router + action router use the same primitives so divergence between auto-CRUD and action endpoints is a compile-time type mismatch, not a silent runtime hole. ### Partner-flagged items deliberately NOT shipped - **`/healthz` route** — already shipped as `healthPlugin` in `src/plugins/health.ts` (`/_health/live`, `/_health/ready`, `/_health/metrics`, default prefix `/_health`). The partner didn't discover it — docs gap, not a code gap. - **`media-kit` cascade hook** — lives in `@classytic/media-kit`, not arc. Forwarded. - **`streamline` parent-doc failure-bridge** — lives in `@classytic/streamline`; the host-side handler is too app-specific to fold into the library cleanly. ### Tests - Net +59 tests added across the touched paths (security, MCP, boot diagnostics, custom-route enforcement, jobs status, actions-type narrowing, auth-disabled warn). - Full suite: **440 files, 5350 tests, 0 failures**. - knip: 0 dead files / exports / types. ## 2.16.1 DX-tightening patch. No breaking changes; every behaviour change closes a previously-broken case or exposes a previously-dropped audit field. ### Boot-time validation — custom routes vs auto-CRUD collisions `defineResource()` now detects when a user-declared `routes:` entry collides with an auto-CRUD path (e.g. `POST /` colliding with `create`, `GET /:id` colliding with `get`). Throws inline at definition time with the exact `disabledRoutes: ['']` line to add — replaces Fastify's opaque `FST_ERR_DUPLICATED_ROUTE` at register time which never mentioned arc's `disabledRoutes` option. Honors `updateMethod` (PUT/PATCH/both), `disabledRoutes`, `disableDefaultRoutes`. Surfaces every collision in a single throw, not one-at-a-time. Lock-in: `tests/core/route-crud-collision.test.ts` (11 cases). ### Auto-envelope of bare handler returns `sendControllerResponse` now wraps handler returns missing the `.data` slot into `{ data: value, status: 200 }` before the field-permission / pagination pipeline runs. Previously, a custom route handler that returned raw `T` (array, plain object, primitive) produced an empty response body — `response.data` was undefined, `reply.send(undefined)` shipped nothing, and any declared `fields:` permission map was bypassed. 2.16.1 wraps so `fields:` applies to ALL custom routes whether the handler envelopes its return value or not. The `IControllerResponse` envelope is still the canonical contract; auto-envelope is a safety net. `raw: true` routes opt out of the pipeline entirely (unchanged). Lock-in: `tests/core/route-handler-auto-envelope.test.ts` (5 cases). ### Audit metadata exposes the full `TenantPurgeStrategy` `getCascadingResourcesWithMetadata()`, `TenantDataLeak.strategy`, `PurgeResourceOutcome.strategy`, and `CascadeResourceReport.strategy` now return the full `TenantPurgeStrategy` discriminated union instead of just the `.type` tag. Auditors get: - `skip` variant's mandatory `reason: string` (GDPR / SOX sign-off forcing function — pre-2.16.1 the field was dropped on the wire) - `anonymize` variant's `fields` map (which columns got anonymized) - `custom` variant's handler descriptor Narrow on `.strategy.type` for typed access. No back-compat shim — the runtime always carried the full object internally; we just stopped narrowing it on the way out. Lock-in: `tests/registry/audit-strategy-exposure.test.ts` + updated `tests/registry/onTenantDelete.test.ts`. ### `preloadResources` discoverable from `/factory` `preloadResources` + `preloadResourcesAsync` (the production-shaped sibling of `loadResources`, used when Vite's `import.meta.glob` is needed for compliance smokes under tsx / vitest with Node `#path` subpath imports or transitive `.js→.ts` resolution) are now re-exported from `@classytic/arc/factory`. Naming under `@classytic/arc/testing` previously suggested "unit tests only" and devs writing compliance smokes hit a dead end. Single source of truth still lives in `src/testing/preloadResources.ts`; identity-equal between the two import paths (lock-in: `tests/factory/preload-resources-export.test.ts`). ### Aggregation routes — URL-driven `?limit` (DoS-bound) Auto-generated `/aggregations/:name` had no upper bound on response size — a `groupBy: 'userId'` aggregation on a 10M-row table returned 10M rows. `defineAggregation()` now accepts `defaultLimit` + `maxLimit`; when `defaultLimit` is set, the route reads `?limit=N`, parses + caps at `maxLimit` (framework default 1000), falls back to `defaultLimit` on absent / invalid input. Mutually exclusive with the existing static `limit` — pick one. Boot validation refuses both-set, ceiling-below-default, non-integer / zero / negative values. OpenAPI emission adds the `?limit` parameter with `default` + `maximum` when declared. Aggregations defined before 2.16.1 are unchanged — neither field set means no limit, exactly as before. Cursor (`?after=`) deliberately not wired: cursor agg pagination IS supported by `aggregatePaginate({ pagination: 'keyset' })` but the typical dashboard shape (≤100 grouped rows) doesn't need it, and the index-discipline tradeoffs are worth a deliberate custom-route wire-up rather than auto-route surface. Lock-in: `tests/core/aggregation/aggregation-routes.test.ts` (6 cases) + `tests/core/aggregation/validate.test.ts` (9 boot-validation cases). ### `FastifyInstance.arc?` augmentation reach `declare module "fastify" { interface FastifyInstance { arc?: ArcCore } }` is now hoisted into `src/types/fastify-augmentation.ts` (pure type-only file) and side-effect-imported from the five entry-point barrels hosts actually reach for: root (`@classytic/arc`), `/factory`, `/core`, `/plugins`, `/registry`. Pre-2.16.1 the augmentation lived inside `arcCorePlugin.ts` and was only reachable when consumers explicitly imported from `/plugins` — a host that did `import { ResourceRegistry } from '@classytic/arc/registry'` saw `Property 'arc' does not exist on type 'FastifyInstance'`. Now any of the common subpaths activates the type. Lock-in: `tests/types/fastify-arc-augmentation.test.ts`. ### Internals - `src/registry/cascadeOrgDelete.ts` — `ctx.onProgress!` → `ctx.onProgress?.` (non-null assertion replaced with optional chain). - `src/core/defineResource/plugin.ts` — dropped two type-system bashes: `(fastify as { server?: object }).server ?? fastify` → typed `fastify.server`; untyped `Record` cast around `registerCacheInvalidationRule` → typed access (the declaration was already on `FastifyInstance` via module augmentation in `queryCachePlugin.ts`). - All `framework-agnostic` / `framework-independent` / `any framework` comment language removed from `src/`. Arc is Fastify-only and that's the documented stance; abstractions are now justified by their real purpose (pipeline-internal contract, curated dev-facing facade, status/meta threading) — not portability. - `examples/_consumer-smoke/` — bumped `@classytic/mongokit` pin from `^3.5.2` to `^3.14.0` (was stuck pre-`/adapter` subpath via lockfile drift); updated response-shape assertions to current no-envelope default. - `tests/cli/generated-app-verification.test.ts` + `tests/cli/scaffolded-app-e2e.test.ts` — per-file `vi.setConfig({ hookTimeout: 60_000 })`. These two CLI scaffolding files write ~50 template files per describe; under parallel vitest load on Windows (slow concurrent `fs.writeFile` + AV scanning), the default 30s hook timeout occasionally tripped and skipped whole describes. Surgical per-file bump — the other 426 test files keep the 30s default so real regressions still fail at the threshold. ### Dev dependency bumps - `@classytic/sqlitekit` devDep: `^0.4.0` → **`^0.5.0`** (latest npm). 0.5.0 brings `ttlPlugin` construction-time validation for `expireAfterSeconds` (rejects non-integers / NaN / Infinity / strings at plugin construction — the exact footgun arc flagged when evaluating sqlitekit's TTL design), loudly documented trigger-mode footguns (transaction coupling, per-insert table scan without `createTtlPartialIndex`), and the additive `aggregatePipeline(build, options)` raw-Drizzle escape hatch for queries the portable `aggregate(req)` IR doesn't express (CTEs, window functions, lateral subqueries, FTS5). No API breaks; arc imports from `/adapter`, `/repository`, `/schema/crud`, and `/plugins/*` — all preserved. - `tests/adapters/mongoose-adapter-signatures.test.ts` — widened `/repository is required/` regex to `/repository.*required/i`. The previous version over-specified mongokit's error wording — when mongokit clarified the message to insert "argument" between "repository" and "is required", the test false-failed despite the runtime behaving exactly as intended. The intent assertion (error names `repository` and flags it as required) is unchanged. ### Tests - Net +37 tests added across the touched paths (collision validation, auto-envelope, audit strategy exposure, factory re-export, augmentation reach, mongokit soft-delete integration with TTL, aggregation `?limit` boot-validation + end-to-end). - Full suite: **428 files, 5280 tests, 0 failures**. - Smoke (`SMOKE_CONSUMER=1`): full consumer install + e2e flow green. ## 2.16.0 The "cleanup-and-tighten" release. Removes long-dead public surface, hardens the validation pipeline, fixes silent DX traps in the streamline + query-parser + MCP paths, and bumps the peer-dep floor in lock-step with the kit ecosystem. Breaking changes are bundled but every removed item has zero verified consumers across the classytic workspaces — the migration column is the safety net for the rare host that may have wired the removed surface directly. ### Breaking changes | Removed | Migration | |---------|-----------| | `@classytic/arc/org` subpath (entire module) — `organizationPlugin`, `orgGuard`, `requireOrg`, `requireOrgRole` (Fastify preHandler variant), `OrgAdapter`, `OrgDoc`, `MemberDoc`, `InvitationDoc`, `InvitationAdapter`, `OrgRole`, `OrgPermissionStatement`, `OrganizationPluginOptions`, `orgMembershipCheck`, `getUserOrgRoles`, `hasOrgRole` | Two equivalent paths depending on auth: (a) Better Auth's `organization` plugin (handles user/member/team tables, invitations, OAuth) — arc reads BA's tables through the standard `defineResource` flow; (b) hand-rolled `defineResource()` for `Organization` / `Member` / hierarchy entities with `flexibleMultiTenantPreset({ tenantField })`. For permission checks, the `requireOrgRole` from `@classytic/arc/permissions` (a `PermissionCheck`, NOT a preHandler) replaces the deleted preHandler variant and integrates with `defineResource({ permissions })`. | | Root re-export of `getUserId` from `@classytic/arc` | Import from the subpath that matches the input shape: `getUserId(user)` from `@classytic/arc/utils` (raw user object), `getUserId(scope)` from `@classytic/arc/scope` (canonical scope accessor). The two had different signatures and were a footgun at the root barrel. | | `@classytic/primitives` peer floor `>=0.5.0` → **`>=0.6.0`** | `npm install @classytic/primitives@^0.6.0`. arc itself develops against `0.6.x` — the dev/peer mismatch that pre-2.16 let local checks pass against an API consumers couldn't use is now closed. | | `@classytic/repo-core` peer floor `>=0.4.1` → **`>=0.4.2`** | `npm install @classytic/repo-core@^0.4.2`. Brings the widened `StandardRepo.aggregate?(req, options?)` contract introduced in 2.15.2 to its formal floor. | The org-plugin removal is the biggest single change. Audit: - `src/org/` carried five files (~800 LOC) plus a JSDoc-only set of types. - Across arc, no host registered `organizationPlugin`; the only "tests" were symbol-presence assertions in `tests/core/public-api-contract.test.ts` and `tests/smoke/exports.test.ts` — neither exercised behavior. - Across the largest consumer workspaces (sniffer-orchestrator with 10+ arc subpath imports, plus shajghor / fitverse / brihot / algoclan), zero `@classytic/arc/org` imports. The only "organization plugin" hit was a hand-rolled `createCrudRouter`-based plugin that happened to share the file name. - The flat `Organization 1—* Member` model with a single role per `(org, user)` pair didn't fit any real multi-tenant deployment (orgs need branches/teams/sub-units). Hosts that wanted hierarchy reached for Better Auth's `organization` plugin or built their own resources — never `@classytic/arc/org`. - The deleted module also exported a second `requireOrgRole` that collided by name with the canonical `requireOrgRole` from `@classytic/arc/permissions/scope.ts` (same DX bug fixed by removing the root `getUserId` re-export). The migration is mechanical when a consumer does surface — see the auth reference ([`skills/arc/references/auth.md`](skills/arc/references/auth.md) — "Org-Scoped Permission Checks") for the canonical pattern. ### `defineResource({ mcp: false })` — per-resource MCP opt-out New optional field on `ResourceConfig` / `ResourceDefinition` ([`src/types/resource.ts`](src/types/resource.ts), [`src/core/defineResource/ResourceDefinition.ts`](src/core/defineResource/ResourceDefinition.ts)). Opts a single resource out of MCP tool generation regardless of the plugin's `expose` / `include` allowlist — local opt-out is authoritative, evaluated FIRST inside `filterResourcesForMcp` ([`src/integrations/mcp/mcpPlugin.ts`](src/integrations/mcp/mcpPlugin.ts)). Before: hosts maintained an `EXCLUDED_RESOURCES` set in `mcp/index.ts`. Every new "never-expose" resource needed a two-file change (definition + central blocklist) — which drifted because the blocklist isn't where developers look when defining a new resource. Now: ```ts defineResource({ name: 'internal-job-log', mcp: false, ... }); ``` The flag keeps the opt-out colocated with the resource definition. The plugin-level `expose` / `include` / `exclude` still work for cross-cutting cuts; the per-resource flag composes (opted-out resources are dropped before plugin-level filtering even runs). ### Streamline `/start` body-envelope validation `POST /:workflowId/start` now validates the body envelope at the route boundary and throws a 422 with an actionable message instead of letting the request reach the engine and surface as a cryptic `"Invalid Date"` 400 from a downstream validator ([`src/integrations/streamline.ts`](src/integrations/streamline.ts)). Three new error codes: | Code | Trigger | Status | |------|---------|--------| | `arc.streamline.invalid_body` | Non-object body (string, array, number) | 422 | | `arc.streamline.missing_input_envelope` | Top-level workflow fields without an `input` wrapper (`{ orderId: '123' }` instead of `{ input: { orderId: '123' } }`) | 422 | | `arc.streamline.unknown_envelope_keys` | Extra keys alongside `input` outside the canonical envelope (`input` / `meta` / `idempotencyKey` / `priority`) | 422 | The `missing_input_envelope` case suggests the wrapped form verbatim, e.g.: ``` [Arc/Streamline] '/order/start' expects '{ input: {...} }'. Got top-level keys [orderId, amount] but no 'input' key. Wrap your workflow payload: { "input": { "orderId": ..., "amount": ... } }. ``` Empty-body submissions (parameterless workflows) pass through unchanged. ### QueryParser — bracket-envelope filter form `ArcQueryParser` now accepts BOTH filter notations ([`src/utils/queryParser.ts`](src/utils/queryParser.ts), [`src/constants.ts`](src/constants.ts)): | Form | Example | Use when | |------|---------|----------| | Bare (legacy, default) | `?status=active&price[gte]=40` | Short queries; matches arc's pre-2.16 behaviour | | Bracket envelope | `?filter[status]=active&filter[price][gte]=40` | REST-conventional (JSON:API / Stripe / Plaid style); safe on busy URLs that might collide with reserved meta keys (`page`, `sort`, `select`, ...) | Both forms produce identical `filters: { status: 'active', price: { $gte: 40 } }`. Operator notation (`[gte]`, `[lte]`, `[ne]`, `[in]`, `[contains]`, ...) works inside either form. Operator whitelist + field whitelist + ReDoS protection apply equally. Precedence on key clash (mixing both forms): **bare wins** (`?status=closed&filter[status]=active` → `{ status: 'closed' }`). Deterministic, documented; mixing should be rare. **Parser portability note.** Other parser implementations (`@classytic/mongokit`'s `QueryParser`, custom hosts-side parsers) may accept only the bare form. When swapping `queryParser` to a different implementation, double-check which forms it understands before publishing URLs that mix the two — this is also documented in the `ArcQueryParser` JSDoc. ### Boot-time validation diagnostics `defineResource()` no longer reaches for `console.warn` directly ([`src/core/defineResource/validate.ts`](src/core/defineResource/validate.ts), [`src/core/defineResource/diagnostics.ts`](src/core/defineResource/diagnostics.ts), [`src/core/defineResource/plugin.ts`](src/core/defineResource/plugin.ts)). Field-rule misconfigurations (`immutable + immutableAfterCreate`, `systemManaged + readonly`, `hidden + aggregable: false`) are now collected as structured `ResourceDiagnostic[]` and flushed through the host's `fastify.log.warn` on first mount inside `buildResourcePlugin`. Hosts retain full control over framework output — silencing, redirecting to a structured sink, or treating as errors via pino's `customLevels`. The diagnostic shape: ```ts interface ResourceDiagnostic { severity: 'warn' | 'info'; code: string; // e.g. 'field-rule-redundant-immutable' message: string; // resource name pre-interpolated } ``` Stable `code` field lets hosts match deterministically without parsing the human-readable message. Hard errors (invalid permission shapes, action/CRUD name collisions, malformed route permissions) continue to throw synchronously — the boot-time pipeline still fails fast on "this will never work" config. This was the last `console.*` call in `src/` outside of `src/cli/` — the rule from CLAUDE.md is now machine-checkable. ### `errorHandler` — Fastify 5.8+ validation code leak Fastify 5.8 started populating `error.code = 'FST_ERR_VALIDATION'` AND a verbose `error.message = "body must have required property 'name'"` on every schema-validation failure. Pre-2.16 the response envelope leaked both — callers saw `code: "FST_ERR_VALIDATION"` instead of `arc.validation_error`, and the field-level Ajv complaint replaced the user-facing "Validation failed" line. The handler now ([`src/plugins/errorHandler.ts`](src/plugins/errorHandler.ts)): - Uses the thrown `error.code` only when it starts with `arc.` (the contract arc internals follow for context-aware messages — e.g. the action router's scoped-validation formatter). Native Fastify codes (`FST_ERR_*`) collapse to `arc.validation_error`. - Maps the message similarly: `arc.*`-coded errors keep their author-crafted message; native Fastify validation collapses to `"Validation failed"`, with field-level detail in `details[]`. ### `src/` lint contract tightened `noExplicitAny` and `noNonNullAssertion` are enforced at `warn` with documented `biome-ignore` at specific call-site boundaries where the type-system can't see what the runtime guarantees (Fastify decorate seams, BullMQ generic constraints, the `@modelcontextprotocol/sdk` ambient declaration). Net reduction across this release: **101 → 0** stray `any`/`!` instances in `src/`. ### CLI scaffold — `arc init` split into modular files `src/cli/commands/init.ts` went from a single 3,661-line file to a thin 14-line re-export shim plus 17 focused modules under `src/cli/commands/init/`: ``` init/ index.ts orchestrator (init() + lifecycle) types.ts InitOptions / ProjectConfig / DependencyManifest options.ts gatherConfig + readline prompts dependency-plan.ts SCAFFOLD_DEP_VERSIONS + resolveScaffoldDependencies postinstall.ts detectPackageManager + installDependencies + runCommand file-writer.ts createProjectStructure + printSuccessMessage templates/ config.ts packageJson / tsconfig / vitest / gitignore / env / readme / config docker.ts dockerignore / Dockerfile / docker-compose / wrangler app.ts entry + edge-entry + app factory + env-loader plugins.ts src/plugins/index.ts scaffold resources.ts src/resources/index.ts + src/shared/index.ts adapter.ts createAdapter (mongokit / custom variants) presets.ts multi-tenant / single-tenant / flexible-multi-tenant permissions.ts src/shared/permissions.ts (conditional on auth + tenant) example.ts example resource (model / repository / resource / controller / schemas / test) user.ts user resource (JWT-only) auth.ts auth resource + Better Auth setup + handlers + schemas + test ``` Each file owns one slice (lifecycle, types, prompts, dep planning, file I/O, or a domain-scoped set of templates) and is independently readable. Tests, CLI dispatch, and external plugin authors keep importing `init` from `@classytic/arc/cli/commands/init.js` — the 14-line shim re-exports verbatim. ### Other fixes - **`@classytic/arc/plugins` JSDoc** — example imports no longer reference `tracingPlugin` from the wrong subpath. Tracing lives at `@classytic/arc/plugins/tracing` (dedicated subpath that pulls `@opentelemetry/*` only when registered) — the example now mirrors the real import shape ([`src/plugins/index.ts`](src/plugins/index.ts)). - **`@classytic/arc/utils` schema converter** — Zod `toJSONSchema` invocation no longer leaks `any` at the boundary; the cast routes through Zod's own `Parameters` shape so neither the input schema nor the options bag escape as `any`. - **`fastify` is now a direct devDependency** of `@classytic/arc` (matching its `^5.8.5` peer). Previously installs relied on transitive resolution from `@classytic/primitives`, which broke when the consumer used a different lockfile resolver. - **`@classytic/mongokit` / `sqlitekit` / `streamline` devDeps** track latest patch (`3.13.4` / `0.3.3` / `2.3.3`). `@biomejs/biome 2.4.15`, `bullmq 5.76.9`, `knip 6.14.1`, `mongoose 9.6.2`, `qs 6.15.2`, `ws 8.20.1`, `@types/node 22.19.19` — all minor / patch upgrades within their existing major. - **Plugin loader overloads** — `loadPlugin` ([`src/factory/registerSecurity.ts`](src/factory/registerSecurity.ts)) now has narrowed overloads for required plugins (`helmet` / `cors` / `rateLimit` / `underPressure` / `sensible` return `FastifyPlugin`, never `null`) vs optional plugins (`multipart` / `rawBody` return `FastifyPlugin | null`). The previous unified `Promise` return forced every required-plugin call site to chain a non-null assertion. ### Migration checklist 1. Audit for `@classytic/arc/org` imports in your codebase. If any hit: - **REST org routes**: switch to Better Auth's `organization` plugin (handles user / member / team / invitation tables + OAuth) OR build `Organization` / `Member` / `Team` as plain `defineResource()` calls with `flexibleMultiTenantPreset`. - **`orgGuard` / `requireOrg` / `requireOrgRole` (Fastify preHandler form)**: switch to `requireOrgRole(...)` from `@classytic/arc/permissions` (PermissionCheck form, plugs into `defineResource({ permissions })`). - **`orgMembershipCheck` / `getUserOrgRoles` / `hasOrgRole`**: switch to `getOrgRoles(scope)` from `@classytic/arc/scope`. Same shape, works across BA / JWT / custom auth. 2. Audit for root `getUserId` imports from `@classytic/arc`. Move to `@classytic/arc/utils` (user-object) or `@classytic/arc/scope` (scope-based) — pick the one that matches your input shape. 3. Bump kit peers: `@classytic/primitives@^0.6.0`, `@classytic/repo-core@^0.4.2`. 4. Optional: drop `EXCLUDED_RESOURCES` blocklists in your MCP plugin registration in favour of `defineResource({ mcp: false })` per-resource. The plugin-level lists keep working. 5. Optional: migrate URLs to the bracket-envelope filter form (`?filter[foo]=X`) if you want REST-conventional shape. Bare form continues to work. ### Verified - Typecheck: clean. - Lint: clean (0 errors, 0 warnings under the project's `biome.json`). - Tests: **5221 passing, 17 skipped** across 421 test files. Net delta vs 2.15.4: +87 new behavior tests covering the streamline envelope, bracket-filter envelope, MCP per-resource opt-out, defineResource boot-time diagnostics, AND the new tenant-cleanup surface (cascade orchestration, concurrency, checkpoint, smoke harness). -2 symbol-presence assertions for the deleted org subpath. - Smoke: 50 subpath entries resolve against the built dist/, npm-pack dry run succeeds. - Knip: no dead code, no unused imports. - Build: 178 files, ~1.79 MB. ### Added — compliance-grade tenant cleanup (org delete) `cascadeDeleteForOrganization` was published in 2.15.5 but never worked end-to-end — `ResourceRegistry.register` stripped the adapter to `{ type, name }`, so the runner's `r.adapter?.repository` always resolved to `undefined`. Every multi-tenant host kept hand-rolling `org-cleanup.ts`. 2.16.0 ships the working end-to-end surface with the full compliance matrix (GDPR / SOX / HIPAA / PCI). **Per-resource strategy declaration** — `onTenantDelete: OnTenantDeleteConfig` on `ResourceConfig`: ```ts defineResource({ name: 'invoice', tenantField: 'organizationId', onTenantDelete: { strategy: { type: 'anonymize', fields: { customerName: '[REDACTED]', email: null } }, priority: 50, // lower runs first; barrier across priority groups batchSize: 1000, }, }); ``` Strategies: `hard` / `soft` / `anonymize` (static or `(doc) => value` per-field) / `skip` (mandatory `reason` — compliance forcing function against silent leaks). Resolved at boot via the pure `resolveTenantPurge` function (`src/registry/resolveTenantPurge.ts`); the resolution is exposed as `ResourceDefinition.resolvedTenantPurge` and surfaced in the registry entry for audit tooling. **Runner** — `cascadeDeleteForOrganization(registry, opts)`: - **Priority-ordered, concurrency-aware**. Resources sorted ascending by priority; priority groups are barriers; within a group, runs up to `concurrency` resources in parallel (default `1`). - **Routes through `purgeByField`** (the new kit primitive in `@classytic/repo-core` 0.5.0 / mongokit 3.14 / sqlitekit 0.4) — chunked, plugin-composed, abort-aware. Falls back to legacy `deleteMany` only for `hard` strategy on older adapters; `soft` / `anonymize` require the new primitive and report `path: 'unsupported'` if missing. - **Checkpoint resume** — `options.checkpoint: { read, write }` persists completed-resources state across crashes. Per-chunk checkpointing intentionally NOT offered — idempotency makes resume-by-resource sufficient. - **AbortSignal, progress callback, batch-size override, skip/only filters**. **Compliance smoke test** — `assertNoTenantData(registry, opts)`: - Walks every cascading resource, runs `count({ tenantField: orgId })`, returns structured `{ ok, leaks, skipped, checked }`. Hosts call in their compliance suite to verify no org data leaks after a cascade. **Introspection** — `getCascadingResourcesWithMetadata(registry)`: - Returns `[{ name, tenantField, strategy, source, priority }, …]` — answers "what cascades on org-delete?" in one call. Audit teams sign off on this list; arc enforces it at runtime. **Registry fix** — `ResourceRegistry` now keeps a private `_adapters` map with live `DataAdapter` instances + exposes `registry.getAdapter(name)`. The public `entry.adapter` stays narrow-and-JSON-serializable; cascade / cleanup / migration helpers reach the live `.repository` via the new accessor. Fixes the 2.15.5 shipped-broken bug. ### Removed — `cascadeOnOrgDelete: boolean` (was non-functional) The boolean flag from 2.15.5 is removed. Migration is one line — replace it with the canonical declaration: ```ts // Before (2.15.5; never worked): defineResource({ name: 'event', cascadeOnOrgDelete: true }); // After (2.16.0): defineResource({ name: 'event', onTenantDelete: { strategy: { type: 'hard' } } }); ``` The boolean is also stripped from `RegistryEntry`, `ResourceDefinition`, the CLI's `arc describe` output (`tenancy.cascadeOnOrgDelete` → `tenancy.purgeStrategy`), and the scaffold template. Hosts that had it set will see a TypeScript error on upgrade — fixing it is the migration above. ### Docs - New canonical guide: [docs/compliance/tenant-cleanup.md](../docs/compliance/tenant-cleanup.md) — full strategy matrix, decision tree, indexing requirements, failure semantics. - Skill (`skills/arc/SKILL.md` + global user skill) gets a concise "Tenant cleanup (org delete)" section. ### Peer-dep range bumps - `@classytic/repo-core` peer floor `>=0.4.2` → **`>=0.5.0`** (consumes `purgeByField` / `TenantPurgeStrategy` / `runChunkedPurge`). - `@classytic/mongokit` dev range `^3.13.4` → `^3.14.0`. - `@classytic/sqlitekit` dev range `^0.3.3` → `^0.4.0`. ## 2.15.3 ### Fix — `multiTenantPreset` now wires `/aggregations/:name` routes Pre-2.15.3 a resource declared with `presets: [multiTenantPreset({ tenantField: 'organizationId' })]` had its CRUD list correctly tenant-scoped but its auto-generated `/aggregations/:name` routes **leaked aggregated rows across orgs** for any caller whose `scope.kind !== 'member'`. Root cause was a missing wiring slot — four additive changes across three files: 1. **[`src/presets/multiTenant.ts`](src/presets/multiTenant.ts)** — preset now emits `aggregations: [strictTenantFilter]` alongside the five CRUD slots. Always strict (no flexible variant — aggregations have no public-list semantic). 2. **[`src/core/aggregation/createAggregationRouter.ts`](src/core/aggregation/createAggregationRouter.ts)** — config widened with `middlewares?: ReadonlyArray`. Threaded into `buildPreHandlerChain` as `customMws`, running AFTER auth + permission and BEFORE the aggregation handler so the tenant filter sees an authenticated scope and `_tenantFields` / `_policyFilters` are populated when `tenantRepoOptions` reads them. 3. **[`src/core/defineResource/plugin.ts`](src/core/defineResource/plugin.ts)** — passes `resource.middlewares?.aggregations` through to `createAggregationRouter`. 4. **[`src/core/BaseCrudController.ts`](src/core/BaseCrudController.ts)** — `tenantRepoOptions` now emits `bypassTenant: true` when the scope is `kind: 'elevated'` AND no specific tenant target was resolved. Without this, elevated cross-org reads (platform-admin dashboards aggregating across all tenants) 500'd with `[mongokit] Multi-tenant: Missing 'organizationId' in context` once the kit's multi-tenant plugin started getting non-empty options. Elevated callers WITH a target org keep the org filter — impersonation semantics are still scoped; only true cross-tenant queries bypass. ### Why CRUD list worked but aggregation didn't (pre-fix) `multiTenantPreset`'s strict / flexible filters write BOTH `request._policyFilters` AND `request._tenantFields`. `BaseCrudController.list` merges `_policyFilters` into the query before delegating to `repo.getAll`. The aggregation router has no equivalent — it consults `tenantRepoOptions` which only reads `metadata._scope` (path 1) + `_tenantFields` (path 2). Without the preset's preHandler running on aggregation routes, `_tenantFields` was never set; for callers without `scope.kind === 'member'`, neither path resolved an `organizationId` and the AggRequest reached the kit unscoped. ### Verified - **mongokit / repo-core unchanged** — they're correct. mongokit 3.13.2 already accepts `aggregate(req, options?)` and `_injectPolicyScopeIntoAgg` correctly merges `context.organizationId` into the filter when the multi-tenant plugin is enabled. The chain only failed because arc was passing empty options. - **+8 lock-in tests** in [`tests/core/aggregation/aggregation-routes.test.ts`](tests/core/aggregation/aggregation-routes.test.ts): - "preset-driven multi-tenant scoping" (3 tests) — member of org A → orgId in tenantOptions; authenticated-without-org → 401/403; elevated WITH target org → orgId passes through; elevated WITHOUT target org → `bypassTenant: true`. - "Org A vs Org B HTTP isolation (fitverse-style)" (4 tests) — mirrors fitverse's `verify-aggregation-tenancy.ts` but exercises the HTTP `/aggregations/:name` seam (the part 2.15.3 actually fixes). Asserts back-to-back A/B/A calls each see only their own data, totals differ, and elevated-without-target sees the cross-org sum. - **Shajghor real-world verification** — Org A scope (member) returns `byFlow: { inflow: 199900/4 }`, no Org B rows; platform-elevated returns `byFlow: { inflow: 374900/6 + outflow: 25000/1 }` across both orgs; revenue dashboard renders correct per-tenant numbers (`৳1,999 verified inflow / 4 txns / Top method bKash`). - **Full main suite**: 5083 / 5100 passing, 0 failed across 404 test files. ### Migration Zero for arc consumers. Fully additive — preset users get correct aggregation scoping; hosts using `tenantField` directly on the controller (no preset) keep the existing 2.14.3 path. Hosts that hand-rolled custom `/aggregate/byX` routes as a workaround (fitverse, shajghor) can delete those handlers and rely on `defineResource({ aggregations: { ... } })` instead. **One adjacent note for hosts using `@classytic/revenue`** (or any kit with a multi-tenant plugin): make sure `scope` is not set to `false` on the engine config. With `scope: false` the kit's tenant plugin is OFF and aggregations don't filter even when arc passes options correctly. Set `scope: true` (default) or `scope: { fieldType: 'objectId' | 'string' }` to enable. The pre-2.15.2 advice to use `scope: false` "to avoid double-scoping with arc" is no longer correct — arc 2.15.2+ deliberately leaves `aggReq.filter` clean and relies on the kit. Fitverse had this right (`scope: { fieldType: 'objectId' }`); shajghor's `scope: false` was the second half of the leak. ## 2.15.2 ### Fixes - **Aggregations multi-tenant scope** — `compileAggRequest` no longer injects tenant fields into `aggReq.filter`. Tenant context flows ONLY through `tenantOptions` (the second arg of `repo.aggregate(req, options)`), letting the kit's multi-tenant plugin handle type-coercion correctly (string → ObjectId for mongokit `fieldType: 'objectId'`, etc.). Pre-2.15.2 dual-injected and produced a string vs ObjectId clash that AND-ed to zero matches in MongoDB. Arc is DB-agnostic — type-coercion belongs in the kit. - **Boot warnings silenced** — `allowErrorHandlerOverride: true` and `ajv.strictTypes: false` declared at app construction. Arc's canonical error handler is intentional; mongokit's `additionalProperties: true` for `Schema.Types.Mixed` (without `type: 'object'`) is intentional. Both are now framework-supported escape hatches, not warnings. ### Required peers - `@classytic/repo-core >= 0.4.1` — widened `StandardRepo.aggregate?(req, options?)` contract - `@classytic/mongokit >= 3.13.2` — `aggregate(req, options)` accepts options bag - `@classytic/sqlitekit >= 0.3.1` — same fix ### Migration None required for hosts. Aggregations that previously returned `{rows: []}` under multi-tenant scope will now return real data. ## 2.15.1 Three small additive `createApp` polish items requested by hosts shipping production workloads. All zero-breaking-change. ### `CreateAppOptions.bodyLimit` — first-class JSON body-size knob - **New `bodyLimit?: number` on `CreateAppOptions`** ([`src/factory/types.ts`](src/factory/types.ts), [`src/factory/createApp.ts`](src/factory/createApp.ts)). Pass-through to Fastify's server-level `bodyLimit` option in bytes; default is Fastify's 1 MiB. Hosts shipping bulk-import endpoints (CSV ingest, JSON-RPC batches, batch settle/refund) previously had no first-class way to raise it — they hit `FST_ERR_CTP_BODY_TOO_LARGE` (413) before any handler ran and either lived with 1 MiB or hand-rolled a Fastify wrapper. File uploads on `multipart` routes are still governed separately by `multipart.limits.fileSize`. ### `arcPlugins.health` accepts `HealthOptions` inline - **`arcPlugins.health` widened from `boolean` to `boolean | HealthOptions`** ([`src/factory/types.ts`](src/factory/types.ts), [`src/factory/registerArcPlugins.ts`](src/factory/registerArcPlugins.ts)). Pre-2.15.1 the only way to add readiness probes (Mongo connectivity, engine warmup, queue health) was `arcPlugins: { health: false }` followed by manual `healthPlugin` re-registration in the host's plugin chain — fragile, easy to mis-order. Now `arcPlugins: { health: { checks: [...] } }` works inline. The three forms are: `true` (default — no extra checks), `false` (disable arc's plugin), or `HealthOptions` (register arc's plugin AND attach the supplied checks to `/_health/ready`). ### Logger redact safe defaults - **`createApp` layers default pino `redact` paths when the host doesn't supply one** ([`src/factory/createApp.ts`](src/factory/createApp.ts) — `resolveLoggerConfig` + `DEFAULT_LOGGER_REDACT_PATHS`). Default paths cover the common token / cookie / password leak surfaces — `req.headers.authorization`, `req.headers.cookie`, `req.headers["x-api-key"]`, `req.headers["x-internal-api-key"]`, `*.password`, `*.passwordHash`, `*.token`, `*.accessToken`, `*.refreshToken`, `*.secret`, `*.apiKey`. **Host-supplied `logger.redact` always wins** — if you set `redact` explicitly, arc respects it (no merge). Set `logger: { redact: [] }` to opt out entirely (test fixtures, security audits). The helper `resolveLoggerConfig` is also exported for hosts that want to reuse arc's defaults in a custom Fastify wrapper. ### Tests - **+12 tests** in [`tests/factory/create-app-2-15-1.test.ts`](tests/factory/create-app-2-15-1.test.ts) covering: bodyLimit (rejects oversized + accepts above 1 MiB when raised), health checks (forwarded inline, 503 on failed check, legacy `true`/`false` still work), redact defaults (`undefined` / `true` / `false` / object with and without explicit redact), and a createApp integration smoke proving the resolved logger config reaches Fastify. ## 2.15.0 Five DX wins on the `defineResource()` + `BaseController` seam, all bundled with the **2.14.3 aggregation tenant-scope fix** that originally shipped on its own. Net change for hosts: less bookkeeping, fewer false-positive warnings, and a typed seam between resource-level and controller-level options. ### Migration — one behavior change to watch `setQueryParser` was a soft warn pre-2.15 and is a registration-time **throw** in 2.15.0. If you have a custom controller (one that doesn't extend `BaseController` / `BaseCrudController`) AND set `queryParser` at the resource level, arc refuses to register the resource at boot. **Two fixes:** 1. Extend `BaseController` / `BaseCrudController` — both implement `setQueryParser` natively. 2. Add a `setQueryParser(qp): void` method on your controller that **wires the parser into your query resolution path**. A no-op is not a fix — it lies to arc about a real misconfig and re-creates the silent-drift bug the throw exists to prevent. There is no escape hatch by design. If `queryParser` is set at the resource level, arc guarantees it reaches the controller; if it can't, the resource doesn't register. This is the same fail-closed posture arc takes for `isRevoked`, security headers, and field-write denial. **No other behavior change is breaking** — `_declaredKeys`, `configure()`, and the layer-split types are purely additive; the dropped-options warn now fires LESS often (only on user-declared keys), never more. ### `controller.configure(opts)` — lifecycle hook for user-supplied controllers - **`BaseController` / `BaseCrudController` now expose `configure(options: ControllerConfigurableOptions): void`** ([`src/core/BaseCrudController.ts`](src/core/BaseCrudController.ts), [`src/core/BaseController.ts`](src/core/BaseController.ts)). `defineResource()` calls it automatically after `resolveOrAutoCreateController()` runs ([`src/core/defineResource/controller.ts`](src/core/defineResource/controller.ts)). Resource-level options (`tenantField`, `schemaOptions`, `idField`, `defaultSort`, `cache`, `onFieldWriteDenied`, `queryParser`, `matchesFilter`, `presetFields`) flow into a user-supplied controller without the host having to thread them through `super(repo, { ... })`. Configure-aware controllers also bypass the dropped-options warn — options aren't actually dropped. Each known key rebuilds the affected sub-component (`AccessControl` / `BodySanitizer` / `QueryResolver`) so referentially-stable consumers don't see stale state. Custom controllers that don't extend `BaseController` opt in by adding the method. - **`accessControl` / `bodySanitizer` are no longer `readonly`** so `configure()` can rebuild them when tenant/idField/schemaOptions change. Same model as `queryResolver` after `setQueryParser` shipped in 2.10.9. ### `_declaredKeys` provenance — kills inferred-value false-positive warns - **`applyPresetsAndAutoInject` now snapshots the keys the user literally passed before any preset / inference / auto-inject runs** ([`src/core/defineResource/presets.ts`](src/core/defineResource/presets.ts)). Stashed as `_declaredKeys: ReadonlySet` on the resolved config. `warnOnDroppedAuthorOptions` consults it so the warn fires only on keys the USER set — not values arc itself injected. Pre-2.15 a resource using a custom controller with a model lacking `organizationId` got a noisy "tenantField is being dropped" warn for a value the user never declared (arc's own `inferTenantFieldFromAdapter` had set it to `false` automatically). Any future inference benefits from this provenance for free. ### `setQueryParser` fails loudly at registration - **`threadQueryParser` now throws when a custom controller declares `queryParser` but lacks `setQueryParser(qp)`** ([`src/core/defineResource/controller.ts`](src/core/defineResource/controller.ts)). Pre-2.15 it warned and shipped a controller using its internal default — silent semantic drift on `[contains]` / `[like]` filters, drifting from the OpenAPI schema's advertised shape. Hardening to a registration-time throw surfaces the misconfig immediately with the same fix-it message in the error. ### `BaseControllerOptions` split into construction-only + configurable surfaces - **`ControllerConstructionOptions` (resourceName) and `ControllerConfigurableOptions` (everything else) are now distinct exported types** ([`src/core/BaseCrudController.ts`](src/core/BaseCrudController.ts)). The constructor still accepts the union `BaseControllerOptions = ControllerConstructionOptions & ControllerConfigurableOptions` (no breaking change for hosts). `configure()` accepts only `ControllerConfigurableOptions` so accidental "rename the resource at runtime" calls fail to typecheck. Documents the layer split that pre-2.15 was implicit in JSDoc only. ### Aggregation routes — tenant scope flows for `tenantField`-only resources (folded from 2.14.3) - **Aggregation `buildOptions` wraps the FastifyRequest with `createRequestContext()` before calling `tenantRepoOptions`** ([`src/core/defineResource/plugin.ts`](src/core/defineResource/plugin.ts)). Pre-fix, the aggregation router passed the raw `FastifyRequest` straight through to `controller.tenantRepoOptions(req)`. That method reads `req.metadata?._scope`, which is only projected from `req.scope` by `createRequestContext()` in the CRUD path — the aggregation path skipped the conversion. Resources declaring `tenantField` directly on `defineResource()` / `BaseController` **without** `multiTenantPreset` (the preset independently stashes `_tenantFields` on the raw request) had neither tenant path populated and saw `organizationId` drop out of `tenantOptions`; downstream kits failed with `Missing 'organizationId' in context`. The fix applies the same conversion at the aggregation seam so both surfaces produce identical `IRequestContext` shapes. Lock-in tests sweep `member`, `service`, `elevated`, and `public` scopes plus audit-attribution leakage in [`tests/core/aggregation/aggregation-routes.test.ts`](tests/core/aggregation/aggregation-routes.test.ts). ### Documentation - **New [`wiki/engine-backed-resources.md`](wiki/engine-backed-resources.md) recipe page** promotes the factory-export pattern for async-booted engines (catalog / flow / revenue / order kits) from a buried subsection of `factory.md` to a dedicated page. Worked example covers the resource file (default-export factory), the `app.ts` wiring (`resources: async () =>` + `loadResources({ context })`), the resolution-order contract (path 4 in `loadResources.ts:282-316`), and "when NOT to use a factory export." Linked from `wiki/index.md#Core`. ### Tests - **+14 new tests** across two files. [`tests/core/controller-configure-2-15.test.ts`](tests/core/controller-configure-2-15.test.ts) — 10 tests covering `setQueryParser` throw, configure-aware-no-warn, configure-rebuilds-AccessControl/BodySanitizer/QueryResolver, idempotent partial configure, legacy controllers still warn, `_declaredKeys` warn provenance (inferred vs user-declared). [`tests/core/aggregation/aggregation-routes.test.ts`](tests/core/aggregation/aggregation-routes.test.ts) — 4 new scope-variant tests under "scope-variant coverage (2.15.0 sweep)" covering `service`, `elevated`, `public`, and audit-attribution-doesn't-leak-into-filter scenarios. ## 2.14.3 ### Aggregation routes — tenant scope now flows for `tenantField`-only resources - **Aggregation `buildOptions` now wraps the FastifyRequest with `createRequestContext()` before calling `tenantRepoOptions`** ([`src/core/defineResource/plugin.ts`](src/core/defineResource/plugin.ts)). Pre-fix, the aggregation router passed the raw `FastifyRequest` straight through to `controller.tenantRepoOptions(req)`. That method reads `req.metadata?._scope`, which is only projected from `req.scope` by `createRequestContext()` in the CRUD path — the aggregation path skipped the conversion. The controller then fell through to its `_tenantFields` fallback (path 2), which is populated by `multiTenantPreset`'s preHandler. Resources declaring `tenantField` directly on `defineResource()`/`BaseController({ tenantField })` **without** also applying `multiTenantPreset` had neither path populated and saw `organizationId` drop out of `tenantOptions`; downstream kits failed with `Missing 'organizationId' in context`. The fix applies the same conversion at the aggregation seam so both surfaces produce identical `IRequestContext` shapes. Resources already on `multiTenantPreset` (or any preset that stashes `_tenantFields`) were unaffected and continue to work unchanged. Lock-in test in [`tests/core/aggregation/aggregation-routes.test.ts`](tests/core/aggregation/aggregation-routes.test.ts) — describe block `aggregation routes — tenant scope threading (2.14.3 regression)` injects a request with `req.scope = { kind: 'member', organizationId: 'org-test-123', ... }` and asserts the value lands in `AggRequest.filter.organizationId` even when no preset is in play. ## 2.14.2 ### Aggregation routes — bracket-shorthand operators normalized to canonical Mongo-shape - **`extractCallerFilter` now translates bracket-syntax operator shorthand into canonical `$`-prefixed keys** ([`src/core/aggregation/buildHandler.ts`](src/core/aggregation/buildHandler.ts)). Pre-fix, `?createdAt[gte]=...&createdAt[lte]=...` was shallow-merged into `AggRequest.filter` as the qs-parsed object `{ createdAt: { gte, lte } }`. Downstream kits' filter compilers expect canonical `{ $gte, $lte }` keys (the same shape `ArcQueryParser` produces for the CRUD list route), so older mongokit versions matched the bare object as a literal `$eq` document — silent broken behavior. Arc owns URL→IR for aggregations (CRUD goes via QueryParser which already does this translation), so the expansion belongs in arc itself; kits stay portable. ISO-date strings on range operators (`gt/gte/lt/lte`) are coerced to `Date` before forwarding. Non-operator nested objects (e.g. `?address[city]=NYC` for embedded-doc equality) are untouched — the expander only fires when EVERY key is a known operator. Lock-in tests in [`tests/core/aggregation/aggregation-routes.test.ts`](tests/core/aggregation/aggregation-routes.test.ts). ### Testing harness — flat-payload assertions - **`HttpTestHarness` updated to the 2.13+ flat wire shape** ([`src/testing/HttpTestHarness.ts`](src/testing/HttpTestHarness.ts)). The auto-generated CRUD assertion suite still asserted `body.success === true` and `body.data._id` — leftovers from the pre-2.13 `{success, data}` envelope. Reads now check the entity at the top level (`body._id`), list responses at `body.data` (no `success` field), and error responses against `body.status`. No public API change; harness consumers picking up 2.13+ get green tests without rewriting expectations. ### Hygiene - **`.playwright-mcp/` added to `.gitignore`**. Browser-driver session artifacts were leaking into commits. ## 2.13.0 ### BREAKING — wire envelope removed (no `{success, data}` wrapper) Arc no longer wraps responses in `{success: true, data: ...}` / `{success: false, error: ...}`. The wire shape is now what the controller returns directly; HTTP status discriminates success vs error. This unifies arc with Stripe / GitHub / Shopify-style REST conventions and removes the JSON-RPC-era envelope that was the only impurity in arc + arc-next + repo-core's contract chain. #### What changed on the wire | Endpoint | Before (≤2.12) | After (2.13) | |---|---|---| | `GET /users/:id` | `{success: true, data: {_id, name, ...}}` | `{_id, name, ...}` | | `POST /users` | `{success: true, data: {...}, status: 201}` | `{_id, name, ...}` (status 201) | | `GET /users` (paginated) | `{success: true, docs: [...], page, ...}` | `{method: 'offset', data: [...], page, limit, total, pages, hasNext, hasPrev}` | | `GET /users` (bare) | `{success: true, docs: [...]}` | `{data: [...]}` | | `DELETE /users/:id` | `{success: true, data: {message, id, soft?}}` | `{message, id, soft?}` | | Any 4xx / 5xx | `{success: false, error: '...', code: 'NOT_FOUND', timestamp, requestId, details}` | `ErrorContract` from `@classytic/repo-core/errors`: `{code, message, status, details?, meta?, correlationId?}` | #### What changed on the field name - Pagination shape field renamed `docs` → `data`. All four pagination shapes (`OffsetPaginationResult`, `KeysetPaginationResult`, `AggregatePaginationResult`, `BareListResult`) carry `data: TDoc[]` in repo-core 0.5+. - `BareListResponse` → `BareListResult`. `*PaginationResponse` types removed (use `*PaginationResult` directly — they were structurally identical aliases). `PaginatedResponse` → `PaginatedResult` (now includes the bare-list arm). - `DeleteResult` drops the `success: boolean` field — kits return `Promise` (`null` on miss, matching `update()`'s convention). Same shape arc emits on the wire. #### What changed for handler authors - **Errors throw, never return.** Replace `return { success: false, error: '...', status: 404 }` with `throw new NotFoundError(...)` (or any `ArcError` subclass). The global error handler catches and emits the canonical `ErrorContract`. - **Success returns drop `success: true`.** `return { success: true, data: doc, status: 201 }` → `return { data: doc, status: 201 }`. - `IControllerResponse` now just `{ data: T; status?; headers?; meta? }`. - `BaseCrudController.notFoundResponse(...)` removed — throw `new NotFoundError(...)` instead. - Hierarchical error codes: `'NOT_FOUND'` → `'arc.not_found'`, `'VALIDATION_ERROR'` → `'arc.validation_error'`, `'FORBIDDEN'` → `'arc.forbidden'`, `'CONFLICT'` → `'arc.conflict'`, etc. (Cross-cutting canonical codes live in `@classytic/repo-core/errors`'s `ERROR_CODES`.) - `envelope()` helper, `reply.ok()` / `reply.fail()` / `reply.paginated()` removed. `reply.sendList(input)` (canonical pagination) and `reply.stream(...)` (downloads) stay. Single-doc handlers just `return data` or `reply.send(data)`. - `ArcError.toJSON()` removed — handler owns serialization. #### Single source of truth across the ecosystem | Package | Owns | |---|---| | `@classytic/repo-core/pagination` | `OffsetPaginationResult`, `KeysetPaginationResult`, `AggregatePaginationResult`, `BareListResult`, `PaginatedResult`, `toCanonicalList` | | `@classytic/repo-core/repository` | `DeleteResult`, `DeleteManyResult`, `UpdateManyResult`, `BulkCreateResult`, `BulkWriteResult` | | `@classytic/repo-core/errors` | `HttpError`, `ErrorContract`, `ErrorDetail`, `toErrorContract`, `isHttpError`, `ERROR_CODES` | | `@classytic/arc/utils/errors` | `ArcError` + 8 subclasses (all implement `HttpError`) | Mongokit / sqlitekit / prismakit return these canonical shapes from `getAll` / `delete` / `bulkCreate` / etc. Arc emits them on the wire unchanged. Arc-next types its return values against the same shapes. **One declaration, three layers, zero drift.** #### Consumer migration - **HTTP/UI consumers** (apps reading `res.json()`): rename `body.docs` → `body.data` for paginated lists; drop the `body.data` unwrap for single-doc reads (the doc is now top-level); switch error checks from `body.success === false` to `!res.ok` (status discriminates); error code matchers from `body.code === 'NOT_FOUND'` to `body.code === 'arc.not_found'`. - **`arc-next` typed client** consumers: `await api.users.getById(id)` returns `User` directly (was `ApiResponse`); `await api.users.delete(id)` returns `DeleteResult` directly. Errors throw `ArcApiError` (was returned as `{success: false, error}`). - **Direct kit consumers** (server packages calling `repo.delete()` directly): change `if (!result.success)` to `if (!result)` for miss handling. ### Better Auth integration is now kit-owned end-to-end Each kit ships a `./better-auth` subpath that produces a `DataAdapter` over BA's own collections so arc resources get full repository semantics (pagination, queryparser, filters, sort, OpenAPI, audit, permissions, multi-tenant scope) on `user`, `organization`, `member`, `invitation`, `apikey`, etc. Arc itself stays kit-agnostic and only ships the request-side bridge. ### `createBetterAuthOverlay` — per-kit factories with symmetric API ```ts // Mongo / mongokit import { createBetterAuthOverlay } from '@classytic/mongokit/better-auth'; const orgAdapter = await createBetterAuthOverlay({ auth, mongoose, collection: 'organization' }); // Sqlite / sqlitekit import { createBetterAuthOverlay } from '@classytic/sqlitekit/better-auth'; const orgAdapter = await createBetterAuthOverlay({ auth, db, collection: 'organization' }); defineResource({ name: 'organization', adapter: orgAdapter }); ``` Same `DataAdapter` contract (`@classytic/repo-core/adapter`), same host code — only the kit import differs. Both factories read `auth.$context.tables` at boot, so `additionalFields`, `modelName` overrides, and plugin schemas (organization, twoFactor, apiKey, admin, oidcProvider, ...) all flow through automatically. Async because `auth.$context` is a Promise; resolves once at boot, no per-request cost. `@classytic/mongokit/better-auth` also exports `registerBetterAuthStubs(mongoose, { plugins })` for bulk stub registration when you only need `populate('user')` / `ref: 'organization'` resolution app-wide (no per-resource overlay). `@classytic/repo-core/better-auth` (new subpath) hosts the kit-agnostic registry: `BA_COLLECTIONS_BY_PLUGIN`, `resolveBetterAuthCollections()`, `pluralizeBetterAuthCollection()`. Every kit's overlay imports from here so the plugin → collection list lives in one place. ### `createBetterAuthAdapter` — direct-API only The Fastify ↔ BA bridge calls `auth.api.*` (the in-process method map every `betterAuth()` instance exposes) for sessions and org/team lookups. No HTTP round-trips. If `auth.api.getSession` is missing, the adapter throws `ArcError(BETTER_AUTH_API_MISSING)` so misconfigured stubs surface immediately. Reads the flat shape real BA emits (`auth.api.getActiveMember`, `auth.api.getActiveMemberRole`) with a nested-shape fallback for hand-rolled test stubs. ### Better Auth peer Peer dep `>=1.6.0`; tested against `1.6.9`. Both `mongokit/better-auth` and `sqlitekit/better-auth` declare `better-auth` as an optional peer — silent for non-BA users, runtime resolution failure for BA users who forget to install. ### Reference playgrounds - [`playground/better-auth/mongo/`](../playground/better-auth/mongo/) — real `mongodb-memory-server` + BA mongo-adapter + arc resources via `mongokit/better-auth` - [`playground/better-auth/sqlite/`](../playground/better-auth/sqlite/) — real `better-sqlite3` + BA Kysely adapter + arc resources via `sqlitekit/better-auth` Same 17-scenario smoke (sign-up → org-create → set-active → multi-tenant CRUD → BA-overlay queryparser/pagination/sort → OpenAPI auto-gen) passes identically against both kits. ### Skill docs [`skills/arc/references/auth.md`](../skills/arc/references/auth.md) covers the **three auth shapes**: arc JWT (you own the data), Clerk / SaaS (provider owns the data, authenticate-callback maps claims to scope), Better Auth (BA owns writes to your DB, kit overlay exposes them as resources). Two-tier overlay pattern documented per kit (factory + hand-roll). ### Internal — playground reorganized + skill docs updated [`playground/better-auth/mongo/`](../playground/better-auth/mongo/) and [`playground/better-auth/sqlite/`](../playground/better-auth/sqlite/) — runnable end-to-end smokes (17 scenarios each) covering sign-up → org-create → set-active → multi-tenant CRUD → BA-overlay queryparser → OpenAPI auto-gen. Same scenarios pass identically against both kits, demonstrating the architectural promise. [`skills/arc/references/auth.md`](../skills/arc/references/auth.md) updated with the **Three auth shapes** decision tree (arc JWT / SaaS callback / BA overlay) and the two-tier overlay pattern (factory + hand-roll). ### Enterprise auth — three opt-in surfaces, no parallel infrastructure Closes the procurement-gate gaps (SCIM provisioning, agent-led action authorization, auth-event audit) without forcing parallel session storage or wrapping Better Auth. Sessions / refresh / OAuth / MFA / SSO providers stay in BA's hands. #### SCIM 2.0 plugin — `@classytic/arc/scim` Auto-derives `/scim/v2/Users` + `/scim/v2/Groups` REST endpoints from existing arc resources. No shadow tables. Okta / Azure AD / Google Workspace / JumpCloud / OneLogin connect with a bearer token. ```typescript import { scimPlugin } from '@classytic/arc/scim'; await app.register(scimPlugin, { users: { resource: userResource }, // your existing arc user resource groups: { resource: orgResource }, // optional — omit for user-only bearer: process.env.SCIM_TOKEN, // or: verify: async (req) => … }); ``` Mounted endpoints: `GET/POST/PUT/PATCH/DELETE /scim/v2/Users[/:id]` (same for `Groups`) + `ServiceProviderConfig` / `ResourceTypes` / `Schemas` discovery. SCIM filter language → arc query DSL (`userName eq "..." and active eq true`, `name co "Smith"`, `meta.lastModified gt "..."`). PATCH translates RFC 7644 PatchOp into canonical operators (`$set` / `$unset` / `$push` / `$pull`) routed through `repo.findOneAndUpdate(...)`; PUT goes through `repo.bulkWrite([{ replaceOne }])`. **Architecture honesty**: SCIM does NOT run arc's HTTP controller pipeline — audit / multi-tenant / field-policy compose at the kit-plugin layer (`repo.use(...)`) and fire identically for arc REST + SCIM because both surfaces call the same repository methods. Mapping defaults to BA-aligned shape (`userName ↔ email`, `displayName ↔ name`, `active ↔ isActive`, …); override per-attribute when divergent. Response Content-Type: `application/scim+json` (parser auto-registered, idempotent on redeclare). `@classytic/arc/scim` exports: `scimPlugin`, `parseScimFilter`, `parseScimPatch`, `DEFAULT_USER_MAPPING`, `DEFAULT_GROUP_MAPPING`, `SCIM_USER_SCHEMA`, `SCIM_GROUP_SCHEMA`, `ScimError`. Full guide → [`skills/arc/references/scim.md`](../skills/arc/references/scim.md). #### Agent-auth helpers — DPoP + capability mandates For AI-agent flows on protected resources (AP2 / Stripe x402 / MCP authorization). Three new helpers in `@classytic/arc/permissions`: ```typescript import { requireAgentScope, requireMandate, requireDPoP } from '@classytic/arc/permissions'; defineResource({ name: 'invoice', actions: { pay: { handler: payInvoice, permissions: requireAgentScope({ capability: 'payment.charge', scopes: ['payment.write'], requireDPoP: true, // RFC 9449 sender-constrained audience: (ctx) => `invoice:${ctx.params?.id}`, // mandate must bind to this resource validateAmount: (ctx, m) => (ctx.data as { amount: number }).amount <= (m.cap ?? 0), }), }, }, }); ``` `RequestScope.service` gains optional `mandate?: Mandate` + `dpopJkt?: string` fields (additive — no breaking change). New `Mandate` type carries `id`, `capability`, `cap`, `currency`, `expiresAt`, `parent`, `audience`, `meta`. New `getMandate(scope)` / `getDPoPJkt(scope)` accessors in `@classytic/arc/scope`. Your `authenticate` callback verifies the mandate (one `jose.jwtVerify()` call) + the DPoP proof (one `jose.dpop.verify()` call) and populates the scope. Arc validates **what's already proved** against the action being attempted — no peer-deps on `jose`, no parser embedded. `requireDPoP()` — sender-constrained credential check. `requireMandate(capability, opts)` — capability + ttl + audience + amount validation. `requireAgentScope({ capability, scopes, requireDPoP, audience, validateAmount, noElevatedBypass })` — the composite (use this; one metadata tag downstream tools can read). Full guide → [`skills/arc/references/agent-auth.md`](../skills/arc/references/agent-auth.md). #### Better Auth → arc audit bridge — `@classytic/arc/auth/audit` Routes BA lifecycle events (sign-in, sign-up, sign-out, MFA, org invitations, password reset, API-key issuance, …) through the existing `auditPlugin` — same wire shape, same store, same query API as resource audit rows. Single store query for "everything user X did". ```typescript import { wireBetterAuthAudit } from '@classytic/arc/auth/audit'; const audit = wireBetterAuthAudit({ events: ['session.*', 'user.*', 'mfa.*', 'org.invite.*'], }); const auth = betterAuth({ hooks: audit.hooks, // endpoint hooks (MFA, OAuth, password reset) databaseHooks: audit.databaseHooks, // sign-in/up/out via session.create/delete // ... }); const app = await createApp({ ... }); audit.attach(app); // drains boot-time buffer + connects live logger ``` Glob event filtering (`session.*`, `mfa.*`, `**`); `transform: (event) => event | null` for redaction / renaming; `bridge.emit({ name, subjectId, payload })` for non-BA flows (webhook signature failures, custom MFA challenges). Buffered until `attach(app)` is called — works for hosts that build BA before Fastify and never loses an early event. `@classytic/arc/auth/audit` exports: `wireBetterAuthAudit`, `AuthEvent`, `AuthEventName`, `WireBetterAuthAuditOptions`, `BetterAuthAuditBridge`. #### What's NOT in arc 2.13 (deliberate) | Capability | Reason | What to use | |---|---|---| | First-party SAML | BA's SAML plugin path; arc can't compete with IdP edge cases | BA SAML plugin or `@node-saml/passport-saml` in `authenticate` callback | | Session storage / Redis sessions | BA's `secondaryStorage` covers it — duplicating fragments truth | BA `secondaryStorage: { get, set, delete }` with `ioredis` directly | | Refresh-token rotation | BA owns the session model | BA's session rotation + arc's `isRevoked` for JWT auth | | DPoP cryptographic proof verification | One `jose.dpop.verify()` in your authenticator — arc would force `jose` peer-dep | `jose` (already a peer for OAuth/JWT apps) | | Mandate JWT/VC parsing | Format varies per IdP; arc validates *what's verified* | `jose` (JWT) or `did-jwt-vc` (Verifiable Credentials) | | Device trust / risk scoring | Out of framework scope | Castle, Stytch, Auth0 Risk, Persona | | SOC2 / HIPAA attestations | Arc gives controls; certification is per-deployment | [`docs/compliance/{soc2,hipaa}.md`](../docs/compliance/) maps each clause to the arc primitive | #### Storage — none new - Audit events → existing `auditPlugin` store (host-supplied `RepositoryLike` or `customStores`) - Sessions / refresh → BA's `secondaryStorage` (host's call: Redis, Mongo, custom) - SCIM users / groups → no new tables; reads/writes go through the existing arc resource (no shadow `scim_users`) - Mandates → stateless JWT-VC; verified per request, audit-row metadata captures `mandate.id` / `audience` / `dpopJkt` #### Tested + smoked Unit + integration tests across `tests/scope/agent-mandate.test.ts` (5), `tests/permissions/agent-helpers.test.ts` (27), `tests/scim/{filter,patch,plugin}.test.ts` (49), `tests/auth/audit-bridge.test.ts` (11). [`playground/enterprise-auth/`](../playground/enterprise-auth/) — runnable smoke proving SCIM provisioning + AP2 mandate + DPoP binding + audit bridge end-to-end (11/11 pass). ### Unified cache layer — one plugin across kits + arc Arc no longer maintains an aggregation-side SWR wrap. The kit's `cachePlugin` (now canonical at `@classytic/repo-core/cache`) handles every read op — `getById`, `getAll`, `aggregate`, `count`, `distinct`, `exists` — through one TanStack-shaped per-call options bag. Arc's job at the boundary is one translation: `defineAggregation({ cache: { staleTime, gcTime, swr, tags } })` → `aggReq.cache: CacheOptions` → kit handles SWR + tag invalidation + version-bump on writes. #### Per-call shape (across CRUD + aggregate) ```ts { staleTime?: number; // seconds fresh gcTime?: number; // seconds retained past stale (default 60) swr?: boolean; // serve-stale + bg refresh (arc default true for aggregations) tags?: readonly string[]; bypass?: boolean; // force fresh fetch + write (Refresh button) enabled?: boolean; // skip cache entirely key?: string; // explicit override } ``` #### What arc dropped - `runWithAggregationCache` SWR wrap in `buildAggregationHandler` — replaced by passthrough to `repo.aggregate(req)`. The kit plugin's `before:aggregate` hook does the SWR work. - Arc's `x-cache: HIT|STALE|MISS` header on aggregation routes — observability flows through the kit plugin's `log: { onHit, onStale, onMiss }` callback instead. - The arc-side `aggregationCacheParams` / `aggregationCacheScope` helpers — scope tags auto-extract from `context.filter.organizationId` / `userId` inside the plugin (cross-tenant safety) without arc rebuilding the key. #### What you gain - **One Redis instance** serves the whole app — repo-bound paths (CRUD + aggregate) share keys with action results / custom routes / MCP / Express / Nest hosts. - **Cross-cutting tag invalidation** — `repo.cache?.invalidateByTags(['orders'])` after a write wipes both `repo.getAll(...)` AND `aggregate({ groupBy: 'status' })` if both declared the `'orders'` tag. Plus model-tag is automatic — every write to `orders` auto-invalidates anything tagged `'order'`. - **TanStack Query-shaped vocabulary** — `staleTime` / `gcTime` / `swr` / `bypass` / `enabled` matches the React idiom devs already know. Fewer concepts. - **`AggCacheOptions` is now a type alias for `CacheOptions`** — same shape across CRUD + aggregate. The old `ttl` / `staleWhileRevalidate` field names are removed; migrate to `staleTime` / `swr`. - **Strictly-monotonic version bumps** — `bumpModelVersion` uses `max(Date.now(), previous + 1)` so same-millisecond writes don't collide and leak stale entries. #### Migration ```ts // Before (2.12 and earlier) defineAggregation({ cache: { staleTime: 30, gcTime: 60, tags: ['orders'] }, ... }) // Wired against arc's QueryCache via arcPlugins.queryCache: true. // After (2.13) — same declaration, kit-canonical wiring defineAggregation({ cache: { staleTime: 30, gcTime: 60, swr: true, tags: ['orders'] }, ... }) // Wired by installing cachePlugin on the repo: new Repository(model, [ multiTenantPlugin({ tenantField: 'orgId' }), cachePlugin({ adapter: redisAdapter, defaults: { gcTime: 300 } }), ]) ``` Hosts who don't install `cachePlugin` get a non-cached call path — declaration is harmless. Same `cache:` shape works for `getById` / `getAll` / `aggregate` / etc. via `options.cache` (CRUD) or `req.cache` (aggregate). `arc/src/cache/QueryCache.ts` stays as the host-level cache for non-repo paths (custom routes, action results) but is no longer wired by arc onto repo-bound paths. ## 2.12.0 Closes four real-world bugs surfaced during ergonomics review and **completes the adapter split** — every kit-specific adapter (`createMongooseAdapter`, `createDrizzleAdapter`, `createPrismaAdapter`) now ships from its own kit's `/adapter` subpath, and arc consumes only the framework-agnostic `DataAdapter` contract from `@classytic/repo-core/adapter`. Arc 2.12 is fully DB-agnostic — zero kit-specific adapters in `src/`. Also aligns arc with `@classytic/repo-core@0.4.0`, `@classytic/mongokit@3.13.0`, `@classytic/sqlitekit@0.3.0`, and the new `@classytic/prismakit@0.1.0` — canonical pagination / wire envelope / adapter vocabulary lives in repo-core and arc consumes it directly (no re-exports anywhere in the org). ### Fixed — wire envelope drift between arc server and arc-next client (`fastifyAdapter`) [`src/core/fastifyAdapter.ts`](src/core/fastifyAdapter.ts) inline-flattened paginated responses without the `method` discriminant and only handled offset shape. Keyset and aggregate results fell through to the `{ success, data: { ... } }` branch, producing a different wire shape than arc-next's typed `OffsetPaginationResponse | KeysetPaginationResponse | AggregatePaginationResponse` union expected. Server and client narrowed on different shapes — narrowing failed silently in the client. The path now routes through `toCanonicalList()` from `@classytic/repo-core/pagination`. The `method` field carries through, keyset/aggregate fields (`hasMore`, `next` / `pages`, `hasNext`) survive, and the `success: true` literal is stamped after the spread (a stale `success: false` on the input cannot override). Bare `{ docs }` shapes route to `BareListResponse`. ### Added — `reply.sendList()` (`replyHelpers`) `reply.sendList(input)` accepts `T[] | OffsetPaginationResult | KeysetPaginationResult | AggregatePaginationResult` and emits the canonical wire shape via `toCanonicalList()` from `@classytic/repo-core/pagination`. Replaces the legacy `reply.paginated()` which only handled offset and silently dropped non-offset fields. `paginated()` was removed alongside `reply.ok()` / `reply.fail()` in the wire-envelope cleanup (see the BREAKING entry above) — `replyHelpers` now decorates `sendList` + `stream` only. See [`src/plugins/replyHelpers.ts`](src/plugins/replyHelpers.ts). ### Added — `tenantField: false` auto-inference (`defineResource`) When a resource adapter implements the optional `hasFieldPath(name): boolean | undefined` hook (Mongoose adapter does, custom adapters opt in), `defineResource()` now infers `tenantField: false` for company-wide tables (lookup tables, platform settings, single-tenant apps) where the model has no `organizationId` path. Previously: hosts who forgot `tenantField: false` got queries silently scoped to a missing field and saw empty results forever. Now: `arcLog("defineResource").info(...)` notes the inference at boot. When `tenantField` is set explicitly to a path that doesn't exist on the model, `arcLog("defineResource").warn(...)` fires (likely typo or stale config). The configured value is preserved so error messages surface the configured name. See [`src/core/defineResource.ts`](src/core/defineResource.ts) (`inferTenantFieldFromAdapter`). ### Added — `requireOrgId` / `requireUserId` / `requireClientId` / `requireTeamId` (`@classytic/arc/scope`) Symmetric throwing variants of `getOrgId` / `getUserId` / `getClientId` / `getTeamId`. Throw `OrgRequiredError` (HTTP 403, code `ORG_SELECTION_REQUIRED`) for missing org/team context, `UnauthorizedError` (HTTP 401) for missing user/client context. Optional `hint` argument surfaces in the error message for traceability: ```ts import { requireOrgId } from '@classytic/arc/scope'; const orgId = requireOrgId(req.scope, 'POST /orders'); ``` Closes the drift surface where every handler hand-rolled `if (!orgId) throw new ForbiddenError(...)` with a different shape. See [`src/scope/types.ts`](src/scope/types.ts). ### Added — opt-in dual-publish detector (`eventPlugin`) `arcPlugins: { events: { warnOnDuplicate: true } }` enables a 5-second LRU on `(eventType, correlationId)` that warns when the same logical event is published twice within the window. Default: on in non-production, off in production. The duplicate is still published — detector is observability, not enforcement (use the outbox for at-most-once). Catches the dual-publish trap where a domain service holds both a publisher and a notification helper that internally publishes to the same bus, doubling every subscriber's invocation. Documented in `wiki/gotchas.md` (#20). See [`src/events/eventPlugin.ts`](src/events/eventPlugin.ts). ### Removed — duplicated event-type declarations (BREAKING for hosts importing from `@classytic/arc/events`) Arc no longer ships its own copies of `EventMeta`, `DomainEvent`, `EventHandler`, `EventLogger`, `EventTransport`, `DeadLetteredEvent`, `PublishManyResult`, `createEvent`, or `createChildEvent`. These types are owned by `@classytic/primitives/events` (the cross-package contract layer; pure types, zero runtime, zero deps). Same architectural pattern as the pagination move to repo-core in Phase A: contract types live in their canonical home and arc consumes from there. Net delete in arc: 271 LOC (`src/events/EventTransport.ts` 424 → 153). Pattern matching that was inlined inside `MemoryEventTransport.publish` now delegates to primitives' `matchEventPattern` helper — same glob rules every transport in the org speaks. #### Migration ```diff - import type { EventMeta, DomainEvent, EventHandler } from '@classytic/arc/events'; - import { createEvent, createChildEvent } from '@classytic/arc/events'; + import type { EventMeta, DomainEvent, EventHandler } from '@classytic/primitives/events'; + import { createEvent, createChildEvent } from '@classytic/primitives/events'; ``` `MemoryEventTransport` and `MemoryEventTransportOptions` remain in arc (process-local handler-set state — wrong layer for primitives). `RedisEventTransport`, `RedisStreamTransport`, `EventOutbox`, `eventPlugin`, dual-publish detector, retry/DLQ helpers all stay in arc unchanged. #### Why now Pre-2.12, primitives' `events.ts` carried this header: *"These shapes MIRROR `@classytic/arc`'s event types verbatim. Arc owns the contract — primitives tracks. Keep this file bit-identical."* That's the dependency direction backwards. Inverting it eliminates the manual-mirror drift surface and lets future kits + services (sqlitekit audit, billing service, prismakit) consume the event contract without coupling to arc's HTTP-coupled stack. #### Peer dep added `@classytic/primitives: >=0.3.0` is now a **required** peer of arc (events are core to every arc app). Side-by-side with `@classytic/repo-core: >=0.3.0` (optional — DB-agnostic resources can use any kit). ### Aligned — adapters' `schemaGenerator` field types as `SchemaGenerator` from repo-core `MongooseAdapter.schemaGenerator` and `DrizzleAdapter.schemaGenerator` are now typed as `SchemaGenerator>` and `SchemaGenerator` respectively from `@classytic/repo-core/schema`. The previous inline function signatures in each adapter — duplicated, hand-coordinated with whatever shape mongokit / sqlitekit happened to produce — replaced by the canonical org-wide contract. Future kits implementing `SchemaGenerator` plug in by structural typing with no glue. No behaviour change: the runtime call site is unchanged; `mergeFieldRuleConstraints` post-processing still applies. The output-type widening from `CrudSchemas | Record` (kit return) to `OpenApiSchemas | Record` (adapter return) goes through one documented `as unknown as` cast at each call site — both runtime shapes are JSON-Schema bundles keyed by slot, the cast bridges TS interface-vs-Record without runtime overhead. ### Aligned — `ArcError` implements canonical `HttpError` contract from repo-core `ArcError` now declares `implements HttpError` from `@classytic/repo-core/errors`, with two new contract-compliance getters: - `status: number` — mirrors `statusCode` (canonical contract uses `status`; arc historically uses `statusCode`; both names point at the same value). - `meta: Record | undefined` — mirrors `details`. No breaking change for arc consumers: the wire envelope (`{ success: false, error, code, ... }`) is unchanged, `statusCode` and `details` instance properties stay, the entire `ArcError` / `NotFoundError` / `ValidationError` / `OrgRequiredError` / etc. hierarchy keeps its constructors and behaviour. The change is purely additive — arc errors now satisfy the org-wide `HttpError` interface so any code typed against `HttpError` (kits, services, future packages) accepts arc errors directly. Hosts who want the canonical Stripe-shaped wire envelope (`ErrorContract` from repo-core) call `toErrorContract(arcError)` from `@classytic/repo-core/errors`. Arc keeps its UPPER_SNAKE codes (`'NOT_FOUND'`, `'VALIDATION_ERROR'`) for back-compat; canonical lowercase codes (`'not_found'`, `'validation_error'`) are documented in `ERROR_CODES` for new packages. ### Removed — built-in column-introspection fallback in `DrizzleAdapter` (BREAKING) [`src/adapters/drizzle.ts`](src/adapters/drizzle.ts) shipped the same fallback anti-pattern as the mongoose adapter — a 75-LOC `columnToJsonSchema` walker + an inline branch in `generateSchemas` that overlapped with sqlitekit's `buildCrudSchemasFromTable`. Cut for the same reasons. Net delete: 299 → 237 LOC. Wire the kit generator explicitly: ```diff createDrizzleAdapter({ table: products, repository: productRepo, + schemaGenerator: buildCrudSchemasFromTable, // import from '@classytic/sqlitekit/schema/crud' }) ``` `getSchemaMetadata` (arc's own `SchemaMetadata` introspection format) and `columnToFieldMetadata` stay — different from OpenAPI, used by the introspection plugin / registry. ### Removed — built-in mongoose-schema fallback in `MongooseAdapter` (BREAKING) [`src/adapters/mongoose.ts`](src/adapters/mongoose.ts) shipped a 230-LOC mongoose-paths → OpenAPI converter as a fallback when no `schemaGenerator` was provided. That logic duplicated `@classytic/mongokit`'s `buildCrudSchemasFromModel` (and was a strict subset — mongokit emits `params` and `listQuery` plus richer field-rule handling). Cut entirely; mongokit's generator is the canonical wiring. ```diff createMongooseAdapter({ model: ProductModel, repository: productRepo, + schemaGenerator: buildCrudSchemasFromModel, // import from '@classytic/mongokit' }) ``` CLI scaffolds (`arc init --mongokit`, `arc generate resource`) wire the mongokit generator automatically. Hand-rolled host code without the field gets `generateSchemas() → null` and no auto-OpenAPI for that resource. `MongooseAdapter#getSchemaMetadata` (arc's own introspection format) and `MongooseAdapter#hasFieldPath` (#6 inference) stay — those are arc-internal, not duplicated by mongokit. Net: `src/adapters/mongoose.ts` 569 → 356 LOC. ### Removed — kit-specific adapters move to their kits (BREAKING) Every `createXxxAdapter` factory and adapter class moves out of arc into its owning kit. Arc 2.12 ships **zero** kit-specific adapter code. The `src/adapters/` directory is gone entirely; its only internal helper (`store-helpers.ts`, used by audit / outbox / idempotency) moved to `src/utils/store-helpers.ts`. The framework-agnostic adapter contract (`DataAdapter`, `RepositoryLike`, `AdapterRepositoryInput`, `asRepositoryLike`, `isRepository`, `OpenApiSchemas`, `SchemaMetadata`, `FieldMetadata`, `RelationMetadata`, `AdapterFactory`, `AdapterValidationResult`, `AdapterSchemaContext`) now lives in `@classytic/repo-core/adapter`. Any kit that ships this contract — mongokit, sqlitekit, prismakit, or a custom kit — plugs into arc's `defineResource({ adapter, ... })` identically. New kits land without an arc release. Migration: ```diff - import { createMongooseAdapter } from '@classytic/arc'; + import { createMongooseAdapter } from '@classytic/mongokit/adapter'; - import { createDrizzleAdapter } from '@classytic/arc/adapters'; + import { createDrizzleAdapter } from '@classytic/sqlitekit/adapter'; - import { createPrismaAdapter } from '@classytic/arc/adapters'; + import { createPrismaAdapter } from '@classytic/prismakit/adapter'; - import type { DataAdapter, RepositoryLike } from '@classytic/arc'; - import type { DataAdapter, RepositoryLike } from '@classytic/arc/adapters'; + import type { DataAdapter, RepositoryLike } from '@classytic/repo-core/adapter'; - import { mergeFieldRuleConstraints } from '@classytic/arc/adapters'; + import { mergeFieldRuleConstraints } from '@classytic/repo-core/schema'; - import type { InferMongooseDoc } from '@classytic/arc/adapters'; + import type { InferMongooseDoc } from '@classytic/mongokit/adapter'; ``` Files removed from arc: - `src/adapters/mongoose.ts` → `@classytic/mongokit@3.13.0/adapter` (`createMongooseAdapter`, `MongooseAdapter`, `MongooseAdapterOptions`, mongoose helpers `InferMongooseDoc`, `MongooseDocument`, `MatchingModel`, `isMongooseModel`, `CleanDoc`). - `src/adapters/drizzle.ts` → `@classytic/sqlitekit@0.3.0/adapter` (`createDrizzleAdapter`, `DrizzleAdapter`, `DrizzleAdapterOptions`, `DrizzleColumnLike`, `DrizzleTableLike`). - `src/adapters/prisma.ts` → `@classytic/prismakit@0.1.0/adapter` (`createPrismaAdapter`, `PrismaAdapter`, `PrismaQueryParser`, `PrismaAdapterOptions`, `PrismaQueryOptions`, `PrismaQueryParserOptions`). - `src/adapters/interface.ts` → `@classytic/repo-core@0.4.0/adapter` (the contract types). - `src/adapters/types.ts` → `@classytic/mongokit@3.13.0/adapter` (Mongoose-specific helpers). - `src/adapters/field-rule-helpers.ts` → `@classytic/repo-core@0.4.0/schema` (`mergeFieldRuleConstraints`, `applyNullable`). - `src/adapters/repo-core-compat.ts` — pointless once `RepositoryLike` lives in repo-core itself. - `src/adapters/index.ts` and the `@classytic/arc/adapters` subpath export — both removed; arc 2.12 has no `/adapters` subpath. Top-level barrel exports removed from `@classytic/arc`: `createMongooseAdapter`, `MongooseAdapter`, `MongooseAdapterOptions`, `createPrismaAdapter`, `PrismaAdapter`, `DataAdapter`, `RepositoryLike`, `AdapterRepositoryInput`, `FieldMetadata`, `RelationMetadata`, `SchemaMetadata`, `AdapterValidationResult`. Hosts import every adapter from its canonical home (the kits + repo-core). ### Changed — peer-dep bumps (BREAKING for hosts on older versions) - `@classytic/repo-core: >=0.2.0` → `>=0.4.0`. Adds `AggregatePaginationResult`, wire envelopes, `toCanonicalList()`, `isPaginatedResult()` (0.3.0); plus `/adapter` subpath + `mergeFieldRuleConstraints` / `applyNullable` in `/schema` (0.3.1). - `@classytic/mongokit` — **dropped from arc's peer dependencies**. Hosts depend on `@classytic/mongokit@>=3.13.0` directly when they want the Mongoose adapter. Mongokit 3.13 ships the new `/adapter` subpath; mongokit 3.12 deletes its local `OffsetPaginationResult` / `KeysetPaginationResult` / `AggregatePaginationResult` / `PaginationResult` exports (use `@classytic/repo-core/pagination`). - `@classytic/sqlitekit` — same shape: not an arc peer; hosts depend on `@classytic/sqlitekit@>=0.3.0` directly. 0.3.0 ships the `/adapter` subpath. - `@classytic/prismakit` — new kit; not an arc peer. Hosts depend on `@classytic/prismakit@>=0.1.0` directly when they want the Prisma adapter. - `mongoose`, `@prisma/client` — no longer arc peers. Each kit owns its own driver peer. ### Hardened — `StandardRepo.getOrCreate` returns `{ doc, created }` (cross-kit contract change) `@classytic/repo-core` 0.4.0 originally shipped `StandardRepo.getOrCreate?(filter, data, options)` returning `Promise`. That return shape can't disambiguate the two outcomes the method exists to distinguish — "I just inserted this row" vs "this row already existed" — which is the load-bearing signal for race-detection in idempotency stores, lock acquisition, and "ensure-exists" flows. Hardened to: ```ts StandardRepo.getOrCreate?( filter: FilterInput, data: Partial, options?: WriteOptions, ): Promise<{ doc: TDoc; created: boolean }>; ``` Implementations in `@classytic/mongokit` 3.13 (`actions/read.ts` — uses `findOneAndUpdate`'s `lastErrorObject.upserted` to derive `created`) and `@classytic/sqlitekit` 0.3 (`actions/read.ts` — `created: true` on the INSERT-RETURNING path, `false` on the SELECT-hit path) both updated. Conformance test in `@classytic/repo-core/testing` asserts the discriminator for any kit running the suite. ### Adoption evaluation — `claim` and `getOrCreate` in arc internals Both new primitives were evaluated against arc's repository-backed `EventOutbox` and `IdempotencyStore`. **Neither adopted; both decisions documented in source with regression tests.** - **Outbox** ([`src/events/repository-outbox-adapter.ts`](src/events/repository-outbox-adapter.ts)): the FIFO claim-next loop needs a *filter*, not an id, so `claim(id, ...)` would force a 2-call shape and a TOCTOU window. `acknowledge` uses a broader-than-`from` filter (`ne('status', 'delivered')`); `fail` has no source-state predicate at all. Each is intentionally more permissive than `claim`'s exact-`from` contract. Locked in by `tests/events/repository-outbox-claim-noop.test.ts`. - **Idempotency** ([`src/idempotency/repository-idempotency-adapter.ts`](src/idempotency/repository-idempotency-adapter.ts)): `tryLock` covers TWO race-detection paths in one `findOneAndUpdate` round-trip — first-time acquire AND stale-lease takeover. `getOrCreate` definitionally cannot mutate an existing row, so it would silently fail to take over an expired lease (returning `{ doc: staleRow, created: false }`), breaking the plugin's "crashed handler eventually unblocks" guarantee. Locked in by `tests/idempotency/get-or-create-evaluation.test.ts`. Both primitives remain valuable for hosts and future kits; arc internals just happen to do compound CAS that reaches beyond what these field-grade primitives target. ### Test delta - `tests/scope/require-accessors.test.ts` (20 tests) — throwing accessors. - `tests/core/tenant-field-inference.test.ts` (8 tests) — `tenantField: false` inference. - `tests/events/dual-publish-warn.test.ts` (9 tests) — dual-publish detector. - `tests/events/repository-outbox-claim-noop.test.ts` (5 tests) — locks in the no-`claim` outbox decision. - `tests/idempotency/get-or-create-evaluation.test.ts` (3 tests) — locks in the no-`getOrCreate` idempotency decision (proves a `getOrCreate`-based `tryLock` would silently fail stale-lease takeover). - 4751 arc tests pass across the full main suite after all changes. ## 2.11.4 ### Events - **Schema versioning enforced end-to-end.** Pre-fix, `defineEvent.create()` did NOT stamp `meta.schemaVersion`, and `registry.validate(name, payload)` always validated against the LATEST registered version — during rolling migrations a v1 producer's payload was silently matched against v2's `required` list. Now: `.create()` auto-stamps `schemaVersion: definition.version` (caller-supplied wins), `EventRegistry.validate(name, payload, version?)` accepts an explicit version (exact-match), and `eventPlugin.publish` + `wrapWithSchema` thread `event.meta.schemaVersion` so each surface validates against the schema the PRODUCER declared. 9-case lock in [`event-schema-versioning.test.ts`](tests/events/event-schema-versioning.test.ts). - **Redis Streams DLQ entries are now replayable** ([`transports/redis-stream.ts`](src/events/transports/redis-stream.ts)). `moveToDlq` writes a full `DeadLetteredEvent` envelope (event payload + error reason + accurate timestamps) instead of opaque `{ originalStream, originalId, group, failedAt }` references. `xrange` is OPTIONAL on `RedisStreamLike` for back-compat — when missing, the envelope is built from in-process failure context alone (no payload, but error + attempt accounting survive) with a one-shot operator warning. New in-process `failureContext` map carries the actual handler error forward across consumer-group failover. Verified against real Upstash in [`upstash-stream-dlq-replay.test.ts`](tests/e2e/upstash-stream-dlq-replay.test.ts). - **Bounded `RedisStreamTransport.close()`.** Pre-fix close could hang up to `blockTimeMs` (default 5s) waiting for the in-flight `XREADGROUP BLOCK` to drain. Added `closeTimeoutMs` (default 1s) + `externalLifecycle` opt-out. Under default lifecycle, timeout calls `redis.disconnect()` (ioredis force path) to break the BLOCK; under `externalLifecycle: true`, arc returns within budget and silently absorbs the lingering loop's eventual completion (no log spam, no unhandled rejection — contract is "bounded return, background drain", documented on the option). - **`subscribeWithSchema` / `wrapWithSchema`** (subscribe-side validation symmetry). Reads schema from `EventDefinitionOutput` and validates `event.payload` before invoking the handler. Single source of truth — declare once at `defineEvent`, enforced on BOTH publish and subscribe. Closes the gap where 19 be-prod handlers carried parallel Zod schemas just to narrow the payload type. - **`subscribeWithBoundary` / `wrapWithBoundary`** (error boundary for projections). Catches handler exceptions, logs structured `{ err, event, eventId, handler }`, swallows. For projection / cache-invalidation / fire-and-forget handlers where retry would just delay the next-event resync. Lighter than `withRetry`; composes with it. - **`PayloadOf`** type utility — `PayloadOf` extracts the payload type from a definition. Saves every host package from defining their own. - **`withRetry` is now generic.** Pre-fix, retry erased payload type to `EventHandler` and forced a cast at the `wrapWithSchema` boundary. Two-line type widen — body unchanged. Cast-free compose: `subscribeWithSchema(fastify, OrderPaid, withRetry(handler))` infers `EventHandler` end-to-end. - **Outbox `fail()` post-write ownership re-check** ([`repository-outbox-adapter.ts`](src/events/repository-outbox-adapter.ts)). Throws `OutboxOwnershipError` when `findOneAndUpdate` returns null + the lease moved between read and write — matches `acknowledge()`. - **WAL contract docs honest**. JSDoc on `EventPluginOptions.wal` now explicitly says "this is NOT at-least-once on its own; use `EventOutbox` for that" and links to the proper primitive. The `wal` slot stays as a low-level write-ahead hook for hosts integrating custom infrastructure. - **Real `RedisEventTransport` test coverage.** Replaced `redis-transport-mock.test.ts` (which tested its own mock's routing logic, not the transport) with [`redis-transport-real.test.ts`](tests/events/redis-transport-real.test.ts) — fake `RedisLike` driven against the actual `RedisEventTransport` class. Catches channel-prefixing, listener-attachment, dispatch, and close-lifecycle regressions. ### Core - **Action body fields no longer stripped under host `removeAdditional: 'all'`** ([`createActionRouter.ts`](src/core/createActionRouter.ts)). `buildActionBodySchema` now bakes the property union into every `oneOf` branch — AJV per-branch strip can't drop sibling-action fields. Strict-mode rejection still works under arc's own `removeAdditional: false`. 5-case lock in [`action-discriminator-strip.test.ts`](tests/core/action-discriminator-strip.test.ts). - **MCP fail-closed when no action gate resolves** ([`resourceToTools.ts`](src/integrations/mcp/resourceToTools.ts)). Throws at tool-generation time, mirroring the HTTP boot-time throw — closes the cross-surface auth gap when `resourceToTools()` runs without the resource's HTTP plugin lifecycle. - **`defineResource` warns when user controller is passed alongside auto-build-only options** ([`defineResource.ts`](src/core/defineResource.ts)). Two warns, two audiences: AUTHOR-declared options (`tenantField`, `schemaOptions`, `idField`, `defaultSort`, `cache`, `onFieldWriteDenied`) tell the user to forward through `super()`; PRESET-injected fields (`slugLookup` / soft-delete / parent) get a separate warn telling them to drop the preset OR extend `BaseController`/`BaseCrudController` — since they didn't declare those options to begin with. Same DX pattern as the existing `queryParser` warn. - **`preHandler` factory dispatch error** — single bare handler instead of array now throws a `TypeError` at registration with the canonical fix in the message ([`routerShared.ts`](src/core/routerShared.ts)). - **`@opentelemetry/api` added to `peerDependencies`** — was only in `peerDependenciesMeta`, so `npm update` could (and did) remove it. - **`.gitattributes` (`* text=auto eol=lf`)** prevents Windows CRLF drift. - **Docs**: README install path expanded to list the security peers `createApp()` enables by default; Prisma adapter clearly marked experimental. ## 2.11.2 - **`RouteSchemaOptions['query']` widened with `allowedPopulate` + `allowedLookups`**. Both fields are pre-2.11.2 runtime features in `QueryResolver` (`sanitizePopulate` / `sanitizeAdvancedPopulate` read `allowedPopulate`; `sanitizeLookups` reads `allowedLookups`). The TYPE was missing them, so hosts cast `query` to `Record` at every call site. After 2.11.2, both fields are first-class on `RouteSchemaOptions['query']`, layered on top of repo-core's inherited `SchemaBuilderOptions['query']['filterableFields']`. Pure additive type widening — runtime unchanged. Removes the cast at every host that wires populate/lookup whitelists. - **Arc-internal cast cleanup**. `QueryResolver.sanitizePopulate`, `sanitizePopulateOptions`, and `sanitizeLookups` previously cast their own `schemaOptions.query` via `as AnyRecord` to read fields they themselves declared as runtime contracts. With the type fix above, all three call sites read directly through the typed `query` accessor. Same self-consistency win as 2.11.1's `RouteSchemaOptions extends SchemaBuilderOptions`. - **Strict flag UX feedback** — kept as docs only (same call as 2.11.1). The two flags catch genuinely different failure modes (`strictResourceDir` for zero-discovery, `strictResources` for duplicate names); collapsing loses diagnostic clarity. Production preset already defaults both to `true`, so hosts don't need to set them manually. ## 2.11.1 - **`loadResources({ context })` + factory-export support**. Default exports may now be either a `ResourceLike` (unchanged) OR a factory function `(ctx) => ResourceLike | Promise`. Engine-bound resources use the factory form to receive a typed engine handle through auto-discovery — eliminates the parallel `createXResource(engine)` factory files + stringly-typed `exclude: [...]` bookkeeping that scaled linearly with engine count. Detection: `typeof default === 'function'` (class instances from `defineResource()` are objects). Async factories awaited; thrown / non-resource returns reported via the injected logger as a distinct "factory failure" diagnostic. - **`silent: boolean` option removed from `loadResources`; default routes through arcLog**. The flag overlapped confusingly with `logger` injection. Default behavior aligned with rest of arc: when `logger` is omitted, warnings flow through `arcLog('loadResources')` — same path as every other arc-internal warn, controlled by `ARC_SUPPRESS_WARNINGS=1` and routable via `configureArcLogger({ writer })`. Migration: - `silent: true` → set `ARC_SUPPRESS_WARNINGS=1` (global) **or** pass `logger: { warn: () => undefined }` (per-call no-op). - `silent: !isDev` → set `ARC_SUPPRESS_WARNINGS=1` in production env. - `logger: pinoAdapter` (already injected) → no change; injection still wins over the arcLog fallback. - `silent` omitted (relied on pre-2.11.1 silent default) → warnings will now show via arcLog. This is intentional — surfacing skip / factory-failure events catches misconfiguration earlier. - **`ActionDefinition.schema` and `EventDefinition.schema` widened from `Record` to `unknown`**. Aligns with the existing `RouteDefinition.schema.body` slot. Zod `ZodObject<...>` instances now assign without `as unknown as Record` casts at the host. Runtime feature-detects via `convertRouteSchema` / `toJsonSchema` — no behavior change. Removes ~8 host-side casts per JSON-Schema → Zod migration. - **Docs**: `production preset` already defaults `strictResourceDir` + `strictResources` to `true`; documented at [docs/production-ops/factory.mdx](docs/production-ops/factory.mdx) so hosts stop manually duplicating the defaults. ## 2.11 - Rebuilt `@classytic/arc/testing` around `createTestApp`, `TestAuthProvider`, `TestFixtures`, `expectArc()`, and a cleaner `HttpTestHarness`. - Added `createApp({ resources })` factory support for sync or async resource resolution after `bootstrap[]`, which removes lazy-bridge boilerplate for async-booted engines. - Unified action-router behavior with CRUD routing through shared `routerShared` prehandler ordering and permission handling. - Split the websocket integration into focused submodules while keeping the public entry stable. - Re-shipped Better Auth test helpers on top of the new testing primitives after downstream DX regressions were reported. - Tightened type ergonomics around `TDoc`, controller override result helpers, query-parser forwarding warnings, and adapter/schema compatibility. - Added follow-up docs for handler patterns, field rules, tenant-field pipeline debugging, and related extension points. ## 2.10 - `2.10` is the main repo-core cleanup line and the point where arc stopped re-exporting legacy repo-core types from the root package. - Fixed the outbox mongokit `fail()` path and later follow-up issues in audit/outbox pagination and repository-backed plugin adapters. - Hardened resource loading with zero-discovery warnings, `strictResourceDir`, `strictResources`, and support for `file://` / `import.meta.url` style resource directories. - Fixed custom-controller `queryParser` forwarding so resource-declared parsers actually reach user-supplied controllers. - Fixed documented case-insensitive `[contains]` and `[like]` behavior, plus cache-key collisions for `RegExp` and `Date` values. - Split the old `BaseController` god class into `BaseCrudController` plus mixins, and tightened root-barrel discipline and type-only exports. - Improved multi-tenant request threading, shorthand-action permission fallback, scope projection, field rule handling, and `systemManaged` protections. - Added `context` and first-class `scope` to config hook handlers. - Fixed plugin `onSend` header races and the idempotency lock leak on empty-body responses. - `2.10.6`, `2.10.7`, and `2.10.8` were intermediate unpublished or transitional snapshots; the important user-facing fixes are represented by the final `2.10.x` line above. ## 2.9.3 - Fixed `cachingPlugin` header-write races under `light-my-request` by moving header mutation earlier in the response lifecycle. - Fixed ETag generation for object payloads and empty 304 bodies. - Added audit retention support with purge scheduling and manual purge hooks. - Extracted shared repository-backed store helpers used by outbox and idempotency adapters. ## 2.9.1 - Removed MongoDB-specific wrapper stores in favor of passing `RepositoryLike` directly to audit, idempotency, and outbox features. - Cleaned up the routes API: `additionalRoutes` became `routes`, `wrapHandler: false` became `raw: true`, and related older route naming was retired. - Expanded repository contracts with `findOneAndUpdate`, duplicate-key detection hooks, and optional search/vector-oriented capabilities. - Improved duplicate-key detection in `errorHandlerPlugin` across MongoDB, Prisma, Postgres, MySQL/MariaDB, and SQLite. - Added `searchPreset` and broadened MCP emission for custom and preset routes. - Improved multipart/file upload flexibility, namespace-aware idempotency, webhook signature error reporting, and event/plugin module organization. ## 2.8.5 - Fixed Zod-to-Fastify schema conversion issues and aligned schema conversion defaults with Fastify's AJV target. - Added `filesUploadPreset` with a pluggable storage contract and storage contract test helpers. - Added required multipart field validation via `multipartBody({ requiredFields })`. ## 2.8.4 - Added the MCP to AI SDK bridge helpers. - Hardened `jobsPlugin` shutdown, repeatable job, payload-size, and Redis-client behavior. - Added Redis/upstash cache and idempotency adapters for both server and edge-style runtimes. - Improved Redis idempotency prefix scans with batched lookup behavior. ## 2.8.3 - Exported `Guard` and `GuardConfig` from `/utils` to fix TS4023 friction. ## 2.8.2 - Expanded repository contracts, including hard-delete forwarding and more optional backend capabilities. - Added declarative action schemas with AJV validation, OpenAPI generation, and MCP generation. - Extended the outbox with richer relay/failure behavior, retry helpers, and stronger delivery metadata. - Added `routeGuards` on resources and `defineGuard()` for typed request prehandler patterns. - Fixed several preset and route-merging issues. ## 2.8.0 - Replaced `additionalRoutes` with `routes`. - Added declarative `actions` on `defineResource()`. ## 2.7.x - `2.7.7` updated workflow typings and the MongoKit peer floor. - `2.7.5` included CI cleanup, streamline execution helpers, and MCP E2E improvements. - `2.7.3` added DX helpers, service scope support, and security fixes. - `2.7.2` improved webhook verification, lifecycle cleanup, and bounded concurrency behavior. - `2.7.1` fixed scope combinators and added `requireServiceScope` plus stronger MCP auth and org scoping. ## 2.6.x - `2.6.3` completed `idField` override support end to end. - `2.6.2` added an event WAL for durable at-least-once delivery. - `2.6.0` introduced the audit trail and idempotency plugins. ## 2.5.5 - Added `createApp({ resources })`, `loadResources()`, bracket-notation filters, and body sanitization. ## 2.4.x - `2.4.3` added the Better Auth adapter, org scoping, role hierarchy, and field-level permissions. - `2.4.1` was the initial public release.