@classytic/arc 2.11.1 → 2.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +143 -673
  3. package/bin/arc.js +2 -2
  4. package/dist/{BaseController-JNV08qOT.mjs → BaseController-swXruJ2_.mjs} +2 -2
  5. package/dist/{actionPermissions-C8YYU92K.mjs → actionPermissions-sUUKDhtP.mjs} +4 -2
  6. package/dist/adapters/index.d.mts +2 -2
  7. package/dist/audit/index.d.mts +1 -1
  8. package/dist/auth/index.d.mts +1 -1
  9. package/dist/auth/index.mjs +1 -1
  10. package/dist/cli/commands/docs.mjs +1 -1
  11. package/dist/cli/commands/generate.d.mts +0 -2
  12. package/dist/cli/commands/generate.mjs +15 -15
  13. package/dist/cli/commands/init.mjs +24 -22
  14. package/dist/context/index.mjs +1 -1
  15. package/dist/core/index.d.mts +2 -2
  16. package/dist/core/index.mjs +3 -3
  17. package/dist/{core-DXdSSFW-.mjs → core-DnUsRpuX.mjs} +20 -8
  18. package/dist/{createActionRouter-BwaSM0No.mjs → createActionRouter-u3ql2EDo.mjs} +73 -13
  19. package/dist/{createApp-P1d6rjPy.mjs → createApp-BFxtdKy6.mjs} +1 -1
  20. package/dist/docs/index.d.mts +1 -1
  21. package/dist/docs/index.mjs +1 -1
  22. package/dist/{eventPlugin--5HIkdPU.mjs → eventPlugin-KrFIQ097.mjs} +1 -1
  23. package/dist/events/index.d.mts +1 -1
  24. package/dist/events/index.mjs +11 -3
  25. package/dist/factory/index.d.mts +1 -1
  26. package/dist/factory/index.mjs +1 -1
  27. package/dist/hooks/index.d.mts +1 -1
  28. package/dist/idempotency/index.d.mts +1 -1
  29. package/dist/{index-C_bgx9o4.d.mts → index-6u4_Gg6G.d.mts} +34 -0
  30. package/dist/{index-CvM1e09j.d.mts → index-BbMrcvGp.d.mts} +1 -1
  31. package/dist/{index-pUczGjO0.d.mts → index-BdXnTPRj.d.mts} +1 -1
  32. package/dist/{index-smCAoA5W.d.mts → index-DdQ3O9Pg.d.mts} +1 -1
  33. package/dist/index.d.mts +4 -4
  34. package/dist/index.mjs +6 -6
  35. package/dist/integrations/index.d.mts +1 -1
  36. package/dist/integrations/mcp/index.d.mts +2 -2
  37. package/dist/integrations/mcp/index.mjs +1 -1
  38. package/dist/integrations/mcp/testing.d.mts +1 -1
  39. package/dist/integrations/mcp/testing.mjs +1 -1
  40. package/dist/middleware/index.d.mts +1 -1
  41. package/dist/{openapi-C0L9ar7m.mjs → openapi-BGUn7Ki1.mjs} +2 -2
  42. package/dist/org/index.d.mts +1 -1
  43. package/dist/permissions/index.mjs +1 -1
  44. package/dist/{permissions-B4vU9L0Q.mjs → permissions-gd_aUWrR.mjs} +42 -0
  45. package/dist/pipeline/index.d.mts +1 -1
  46. package/dist/plugins/index.d.mts +1 -1
  47. package/dist/plugins/index.mjs +1 -1
  48. package/dist/plugins/tracing-entry.mjs +1 -1
  49. package/dist/presets/filesUpload.d.mts +1 -1
  50. package/dist/presets/filesUpload.mjs +1 -1
  51. package/dist/presets/index.d.mts +1 -1
  52. package/dist/presets/index.mjs +1 -1
  53. package/dist/presets/multiTenant.d.mts +1 -1
  54. package/dist/presets/search.d.mts +1 -1
  55. package/dist/presets/search.mjs +1 -1
  56. package/dist/{presets-k604Lj99.mjs → presets-Z7P5w4gF.mjs} +1 -1
  57. package/dist/registry/index.d.mts +1 -1
  58. package/dist/{requestContext-CfRkaxwf.mjs → requestContext-C5XeK3VA.mjs} +15 -0
  59. package/dist/{resourceToTools--okX6QBr.mjs → resourceToTools-ByZpgjeH.mjs} +5 -4
  60. package/dist/{routerShared-DeESFp4a.mjs → routerShared-BqLRb5l7.mjs} +60 -3
  61. package/dist/testing/index.d.mts +2 -2
  62. package/dist/testing/index.mjs +1 -1
  63. package/dist/types/index.d.mts +1 -1
  64. package/dist/{types-Bh_gEJBi.d.mts → types-9beEMe25.d.mts} +1 -1
  65. package/dist/{types-BdA4uMBV.d.mts → types-BH7dEGvU.d.mts} +1 -1
  66. package/dist/utils/index.d.mts +1 -1
  67. package/dist/utils/index.mjs +1 -1
  68. package/dist/{utils-D3Yxnrwr.mjs → utils-CcYTj09l.mjs} +1 -1
  69. package/package.json +5 -1
  70. package/skills/arc/references/events.md +489 -489
package/README.md CHANGED
@@ -1,17 +1,38 @@
1
1
  # @classytic/arc
2
2
 
3
- Database-agnostic resource framework for Fastify. Define resources, get CRUD routes, permissions, presets, caching, events, OpenAPI, and MCP tools — without boilerplate.
3
+ Database-agnostic resource framework for Fastify. One `defineResource()` call REST + auth + permissions + events + caching + OpenAPI + MCP tools — without boilerplate.
4
4
 
5
- **v2.10** | Fastify 5+ | Node.js 22+ | ESM only
6
-
7
- ## Install
5
+ **v2.11** · Fastify 5+ · Node.js 22+ · ESM only
8
6
 
9
7
  ```bash
8
+ # Core
10
9
  npm install @classytic/arc fastify
11
- npm install @classytic/mongokit mongoose # MongoDB adapter
10
+
11
+ # Security defaults that createApp() enables out of the box
12
+ npm install @fastify/cors @fastify/helmet @fastify/rate-limit @fastify/under-pressure @fastify/sensible
13
+ # (each is opt-out via `cors: false` / `helmet: false` / etc.)
14
+
15
+ # Pick a storage adapter
16
+ npm install @classytic/mongokit mongoose # MongoDB (most common)
17
+ # OR @classytic/sqlitekit drizzle-orm better-sqlite3 (sqlite)
18
+ # OR bring your own: implement RepositoryLike from @classytic/repo-core
12
19
  ```
13
20
 
14
- ## Quick Start
21
+ ---
22
+
23
+ ## Why arc
24
+
25
+ | | |
26
+ |---|---|
27
+ | **One call, full REST** | `defineResource({ name, adapter, presets, permissions })` → `GET /`, `GET /:id`, `POST /`, `PATCH /:id`, `DELETE /:id` + custom routes + actions |
28
+ | **DB-agnostic** | Mongoose, Drizzle/sqlitekit, or any `RepositoryLike` impl — swap backends without rewriting routes. (Prisma adapter is experimental: implemented, no integration tests yet.) |
29
+ | **Multi-tenant by default** | Tenant-field auto-injected, scope-aware queries, per-org cache keys, elevation events. |
30
+ | **Tree-shakable subpaths** | `@classytic/arc/auth`, `/events`, `/cache`, `/mcp`, `/integrations/jobs` — pay only for what you import. |
31
+ | **MCP tools, free** | Resources auto-generate Model Context Protocol tools for AI agents. Same permissions, same field rules. |
32
+
33
+ ---
34
+
35
+ ## Quick start
15
36
 
16
37
  ```typescript
17
38
  import mongoose from 'mongoose';
@@ -22,7 +43,7 @@ await mongoose.connect(process.env.DB_URI);
22
43
  const app = await createApp({
23
44
  preset: 'production',
24
45
  resourcePrefix: '/api/v1',
25
- resources: await loadResources(import.meta.url), // auto-discovers *.resource.ts
46
+ resources: await loadResources(import.meta.url), // auto-discover *.resource.ts
26
47
  auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET } },
27
48
  cors: { origin: process.env.ALLOWED_ORIGINS?.split(',') },
28
49
  });
@@ -30,182 +51,89 @@ const app = await createApp({
30
51
  await app.listen({ port: 8040, host: '0.0.0.0' });
31
52
  ```
32
53
 
33
- Three ways to register resources:
54
+ Resources can be a static array, an async factory (engine-bound), or auto-discovered from disk:
34
55
 
35
56
  ```typescript
36
- // Auto-discover from directory (recommended)
37
- resources: await loadResources(import.meta.url), // dev/prod parity
57
+ // Auto-discover (recommended for >5 resources)
58
+ resources: await loadResources(import.meta.url),
38
59
 
39
- // Explicit array
60
+ // Explicit list
40
61
  resources: [productResource, orderResource],
41
62
 
42
- // Via plugins callback (full Fastify control)
43
- plugins: async (f) => { await f.register(productResource.toPlugin()); },
44
- ```
45
-
46
- `loadResources()` discovers `default` exports, `export const resource`, OR any named export with `toPlugin()` (e.g. `export const userResource`). Per-resource opt-out of `resourcePrefix` via `skipGlobalPrefix: true` for webhooks/admin routes.
47
-
48
- > **Import compatibility:** Works with relative imports and Node.js `#` subpath imports. Does **not** support tsconfig path aliases (`@/*`, `~/`) — use explicit `resources: [...]` instead.
49
-
50
- ## Boot Sequence
51
-
52
- ```typescript
53
- const app = await createApp({
54
- resourcePrefix: '/api/v1',
55
- plugins: async (f) => { await connectDB(); }, // 1. infra (DB, docs)
56
- bootstrap: [inventoryInit, accountingInit], // 2. domain init
57
- resources: await loadResources(import.meta.url), // 3. routes
58
- afterResources: async (f) => { subscribeEvents(f); }, // 4. post-wiring
59
- onReady: async (f) => { logger.info('ready'); },
60
- });
63
+ // Async factory runs after `bootstrap[]`, before route wiring
64
+ resources: async () => {
65
+ const [catalog, flow] = await Promise.all([ensureCatalogEngine(), ensureFlowEngine()]);
66
+ return loadResources(import.meta.url, { context: { catalog, flow } });
67
+ },
61
68
  ```
62
69
 
63
- ## Audit (per-resource opt-in)
64
-
65
- Clean DX without growing exclude lists:
66
-
67
- ```typescript
68
- import { Repository, methodRegistryPlugin, batchOperationsPlugin } from '@classytic/mongokit';
69
-
70
- // app.ts — pass any RepositoryLike (mongokit / prismakit / custom)
71
- await fastify.register(auditPlugin, {
72
- autoAudit: { perResource: true },
73
- // batchOperationsPlugin enables deleteMany, required for purgeOlderThan()
74
- repository: new Repository(AuditModel, [methodRegistryPlugin(), batchOperationsPlugin()]),
75
- // or omit `repository` for in-memory dev
76
- });
77
-
78
- // order.resource.ts — opt in
79
- defineResource({ name: 'order', audit: true });
80
-
81
- // payment.resource.ts — only audit deletes
82
- defineResource({ name: 'payment', audit: { operations: ['delete'] } });
70
+ `loadResources({ context })` (2.11.1+) threads engine handles into resources whose default export is `(ctx) => defineResource(...)`. No parallel factory files, no `exclude: [...]` bookkeeping.
83
71
 
84
- // Manual logging from MCP tools or custom routes
85
- app.post('/orders/:id/refund', async (req) => {
86
- await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
87
- });
88
- ```
72
+ ---
89
73
 
90
- ## defineResource
91
-
92
- Single API for a full REST resource with routes, permissions, and behaviors:
74
+ ## Define a resource
93
75
 
94
76
  ```typescript
95
- import { defineResource, createMongooseAdapter, allowPublic, roles } from '@classytic/arc';
77
+ import { defineResource, createMongooseAdapter } from '@classytic/arc';
78
+ import { allowPublic, requireRoles, requireAuth } from '@classytic/arc/permissions';
79
+ import { buildCrudSchemasFromModel } from '@classytic/mongokit';
80
+ import ProductModel from './product.model.js';
81
+ import productRepository from './product.repository.js';
96
82
 
97
- const productResource = defineResource({
83
+ export default defineResource({
98
84
  name: 'product',
99
- adapter: createMongooseAdapter({ model: ProductModel, repository: productRepo }),
100
- presets: ['softDelete', 'slugLookup', { name: 'multiTenant', tenantField: 'orgId' }],
85
+ adapter: createMongooseAdapter({
86
+ model: ProductModel,
87
+ repository: productRepository,
88
+ schemaGenerator: buildCrudSchemasFromModel, // auto-derives CRUD schemas
89
+ }),
90
+ presets: ['softDelete', 'slugLookup', { name: 'multiTenant', tenantField: 'organizationId' }],
101
91
  permissions: {
102
92
  list: allowPublic(),
103
93
  get: allowPublic(),
104
- create: roles('admin', 'editor'), // checks platform + org roles
105
- update: roles('admin', 'editor'),
106
- delete: roles('admin'),
94
+ create: requireRoles(['admin']),
95
+ update: requireRoles(['admin']),
96
+ delete: requireRoles(['admin']),
97
+ },
98
+ schemaOptions: {
99
+ fieldRules: {
100
+ name: { minLength: 2, maxLength: 200 },
101
+ sku: { pattern: '^[A-Z]{3}-\\d{3}$' },
102
+ status: { enum: ['draft', 'active', 'archived'] },
103
+ priceMode: { nullable: true }, // accept null for round-trips
104
+ organizationId: { systemManaged: true, preserveForElevated: true },
105
+ },
106
+ query: {
107
+ allowedPopulate: ['category', 'createdBy'], // populate whitelist
108
+ filterableFields: { status: { type: 'string' } },
109
+ },
107
110
  },
108
- cache: { staleTime: 30, gcTime: 300, tags: ['catalog'] }, // QueryCache (opt-in)
111
+ cache: { staleTime: 30, gcTime: 300, tags: ['catalog'] },
109
112
  routes: [
110
113
  { method: 'GET', path: '/featured', handler: 'getFeatured', permissions: allowPublic() },
111
114
  ],
115
+ actions: {
116
+ approve: { handler: approveOrder, permissions: requireRoles(['admin']) },
117
+ },
112
118
  });
113
-
114
- // Auto-generates: GET /, GET /:id, POST /, PATCH /:id, DELETE /:id
115
- // Plus preset routes: GET /deleted, POST /:id/restore, GET /slug/:slug
116
- ```
117
-
118
- **Custom primary key?** Use `idField` for resources keyed by UUIDs, slugs, or business identifiers:
119
-
120
- ```typescript
121
- defineResource({
122
- name: 'job',
123
- adapter: createMongooseAdapter(JobModel, jobRepository),
124
- idField: 'jobId', // routes + BaseController lookups + OpenAPI + MCP tools all use this
125
- });
126
- // GET /jobs/job-5219f346-a4d → 200 (no ObjectId pattern enforcement)
127
- ```
128
-
129
- ## Authentication
130
-
131
- Auth uses a discriminated union — pick a `type`:
132
-
133
- ```typescript
134
- // Arc JWT
135
- auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET, expiresIn: '15m' } }
136
-
137
- // Better Auth (recommended for SaaS with orgs)
138
- import { createBetterAuthAdapter } from '@classytic/arc/auth';
139
- auth: { type: 'betterAuth', betterAuth: createBetterAuthAdapter({ auth, orgContext: true }) }
140
-
141
- // Custom plugin
142
- auth: { type: 'custom', plugin: myAuthPlugin }
143
-
144
- // Custom function
145
- auth: { type: 'authenticator', authenticate: async (req, reply) => { ... } }
146
-
147
- // Disabled
148
- auth: false
149
- ```
150
-
151
- **Decorates:** `app.authenticate`, `app.optionalAuthenticate`, `app.authorize`
152
-
153
- ### Better Auth + Mongoose populate bridge
154
-
155
- When you back Better Auth with `@better-auth/mongo-adapter`, BA writes through the native `mongodb` driver and never registers anything with Mongoose. Any arc resource that does `Schema({ userId: { ref: 'user' } })` and calls `.populate('userId')` then throws `MissingSchemaError`.
156
-
157
- Optional helper at a dedicated subpath registers `strict: false` stub Mongoose models for BA's collections so populate works. Lives behind `@classytic/arc/auth/mongoose` so users on Prisma/Drizzle/Kysely never get Mongoose pulled into their bundle.
158
-
159
- ```typescript
160
- import mongoose from 'mongoose';
161
- import { registerBetterAuthMongooseModels } from '@classytic/arc/auth/mongoose';
162
-
163
- // Default is core only — every plugin set is opt-in.
164
- registerBetterAuthMongooseModels(mongoose, {
165
- plugins: ['organization', 'organization-teams'],
166
- // For separate @better-auth/* packages:
167
- extraCollections: ['passkey', 'ssoProvider'],
168
- });
169
-
170
- // Now arc resources can populate BA-owned references:
171
- const Post = mongoose.model('Post', new mongoose.Schema({
172
- title: String,
173
- authorId: { type: String, ref: 'user' },
174
- }));
175
- await Post.findOne().populate('authorId');
176
119
  ```
177
120
 
178
- Supports `usePlural` (matches `mongodbAdapter({ usePlural: true })`) and `modelOverrides` (for custom `user: { modelName: 'profile' }` configs). Idempotent and de-dupes overlapping plugin sets.
179
-
180
- ### Token Revocation
181
-
182
- Arc provides the `isRevoked` primitive — you implement the store (Redis, DB, Better Auth):
121
+ Auto-generates: `GET /products`, `GET /products/:id`, `POST /products`, `PATCH /products/:id`, `DELETE /products/:id` + softDelete adds `GET /products/deleted`, `POST /products/:id/restore` + slugLookup adds `GET /products/by-slug/:slug` + custom routes + `POST /products/:id/action`.
183
122
 
184
- ```typescript
185
- auth: {
186
- type: 'jwt',
187
- jwt: { secret: process.env.JWT_SECRET },
188
- isRevoked: async (decoded) => {
189
- // Redis set, DB lookup, or any async check
190
- return await redis.sismember('revoked-tokens', decoded.jti ?? decoded.id);
191
- },
192
- }
193
- ```
194
-
195
- Fail-closed: if the revocation check throws, the token is rejected.
123
+ ---
196
124
 
197
125
  ## Permissions
198
126
 
199
- Function-based, composable:
127
+ Function-based — RBAC, ABAC, ReBAC, or any combination.
200
128
 
201
129
  ```typescript
202
130
  import {
203
131
  allowPublic, requireAuth, requireRoles, requireOwnership,
204
132
  requireOrgMembership, requireOrgRole, requireServiceScope,
205
- requireScopeContext,
206
- allOf, anyOf, denyAll,
133
+ requireScopeContext, requireOrgInScope,
134
+ allOf, anyOf, when, denyAll,
207
135
  createDynamicPermissionMatrix,
208
- } from '@classytic/arc';
136
+ } from '@classytic/arc/permissions';
209
137
 
210
138
  permissions: {
211
139
  list: allowPublic(),
@@ -213,567 +141,109 @@ permissions: {
213
141
  create: requireRoles(['admin', 'editor']),
214
142
  update: anyOf(requireOwnership('userId'), requireRoles(['admin'])),
215
143
  delete: allOf(requireAuth(), requireRoles(['admin'])),
216
-
217
- // Mixed human + machine routes — accept org admins OR API keys
218
- bulkImport: anyOf(
219
- requireOrgRole('admin'), // human path
220
- requireServiceScope('jobs:bulk-write'), // machine path (OAuth-style)
221
- ),
222
-
223
- // Multi-level tenancy — branch/project/region scoped routes
224
- branchAdmin: allOf(requireOrgRole('admin'), requireScopeContext('branchId')),
225
- euOnly: requireScopeContext('region', 'eu'),
226
- projectEdit: requireScopeContext({ projectId: 'p-1', region: 'eu' }),
227
-
228
- // Parent-child org hierarchy (holding → subsidiary → branch, MSP, white-label)
229
- // Reads scope.ancestorOrgIds (loaded by your auth function from your own org table)
230
- childOrgAccess: requireOrgInScope((ctx) => ctx.request.params.orgId),
231
- }
232
- ```
233
-
234
- `requireRoles()` checks platform roles (`user.role`) AND org roles
235
- (`scope.orgRoles`) by default — same call works for arc JWT, Better Auth user
236
- roles, and Better Auth org plugin. `requireOrgMembership()` accepts `member`,
237
- `service` (API key), and `elevated` scopes; `multiTenantPreset` filters by
238
- org for all three. For machine identities, `requireServiceScope('jobs:write')`
239
- mirrors OAuth 2.0 scope strings. For app-defined dimensions beyond org/team
240
- (branch, project, region, workspace), `requireScopeContext('branchId')`
241
- reads from `scope.context` populated by your auth function. For parent-child
242
- org hierarchies (holding → subsidiary, MSP → tenants, white-label),
243
- `requireOrgInScope((ctx) => ctx.request.params.orgId)` accepts the current
244
- org or any ancestor in `scope.ancestorOrgIds`.
245
-
246
- **Multi-level tenant filtering** — the `multiTenantPreset` scales from
247
- single-org isolation to lockstep filtering across any number of dimensions:
248
-
249
- ```typescript
250
- import { multiTenantPreset } from '@classytic/arc/presets';
251
-
252
- // Single-field (default, backwards compatible)
253
- multiTenantPreset({ tenantField: 'organizationId' })
254
-
255
- // Multi-field — org + branch + project, all enforced in lockstep
256
- multiTenantPreset({
257
- tenantFields: [
258
- { field: 'organizationId', type: 'org' }, // → getOrgId(scope)
259
- { field: 'teamId', type: 'team' }, // → getTeamId(scope)
260
- { field: 'branchId', contextKey: 'branchId' }, // → scope.context.branchId
261
- { field: 'projectId', contextKey: 'projectId' },
262
- ],
263
- })
264
- ```
265
-
266
- Fail-closed semantics: if any required dimension is missing from the caller's
267
- scope, list/get/update/delete return 403 with the specific missing field name,
268
- and create is rejected. Elevated scopes apply whatever resolves and skip the
269
- rest (cross-context admin bypass). Your auth function populates
270
- `scope.context` from JWT claims, BA session fields, or request headers — arc
271
- takes no position on which dimension names you use.
272
-
273
- **Field-level permissions:**
274
-
275
- ```typescript
276
- import { fields } from '@classytic/arc';
277
-
278
- fields: {
279
- password: fields.hidden(),
280
- salary: fields.visibleTo(['admin', 'hr']),
281
- role: fields.writableBy(['admin']),
282
- email: fields.redactFor(['viewer'], '***'),
283
- }
284
- ```
285
-
286
- **Dynamic ACL (DB-managed):**
287
-
288
- ```typescript
289
- const acl = createDynamicPermissionMatrix({
290
- resolveRolePermissions: async (ctx) => aclService.getRoleMatrix(orgId),
291
- cacheStore: new RedisCacheStore({ client: redis, prefix: 'acl:' }),
292
- });
293
-
294
- permissions: {
295
- list: acl.canAction('product', 'read'),
296
- create: acl.canAction('product', 'create'),
297
- }
298
- ```
299
-
300
- ## Presets
301
-
302
- Composable resource behaviors:
303
-
304
- | Preset | Effect | Config |
305
- |--------|--------|--------|
306
- | `softDelete` | `GET /deleted`, `POST /:id/restore`, `deletedAt` field | `{ deletedField }` |
307
- | `slugLookup` | `GET /slug/:slug` | `{ slugField }` |
308
- | `tree` | `GET /tree`, `GET /:parent/children` | `{ parentField }` |
309
- | `ownedByUser` | Auto-checks `createdBy` on update/delete | `{ ownerField }` |
310
- | `multiTenant` | Auto-filters all queries by tenant | `{ tenantField }` |
311
- | `audited` | Sets `createdBy`/`updatedBy` from user | — |
312
-
313
- ```typescript
314
- presets: ['softDelete', { name: 'multiTenant', tenantField: 'organizationId' }]
315
- ```
316
-
317
- ## QueryCache
318
-
319
- TanStack Query-inspired server cache with stale-while-revalidate and auto-invalidation:
320
-
321
- ```typescript
322
- // Enable globally
323
- const app = await createApp({
324
- arcPlugins: { queryCache: true }, // Memory store by default
325
- });
326
-
327
- // Per-resource config
328
- defineResource({
329
- name: 'product',
330
- cache: {
331
- staleTime: 30, // seconds fresh
332
- gcTime: 300, // seconds stale data kept (SWR window)
333
- tags: ['catalog'],
334
- invalidateOn: { 'category.*': ['catalog'] }, // cross-resource
335
- },
336
- });
337
- ```
338
-
339
- **How it works:**
340
- - `GET` requests: cached with `x-cache: HIT | STALE | MISS` header
341
- - `POST/PATCH/DELETE`: auto-bumps resource version, invalidating all cached queries
342
- - Cross-resource: category mutation bumps `catalog` tag, invalidates product cache
343
- - Multi-tenant safe: cache keys scoped by userId + orgId
344
-
345
- **Runtime modes:**
346
-
347
- | Mode | Store | Config |
348
- |------|-------|--------|
349
- | `memory` (default) | `MemoryCacheStore` (50 MiB budget) | Zero config |
350
- | `distributed` | `RedisCacheStore` | `stores: { queryCache: new RedisCacheStore({ client: redis }) }` |
351
-
352
- ## BaseController
353
-
354
- Override only what you need:
355
-
356
- ```typescript
357
- import { BaseController } from '@classytic/arc';
358
- import type { IRequestContext, IControllerResponse } from '@classytic/arc';
359
-
360
- class ProductController extends BaseController<Product> {
361
- constructor() { super(productRepo); }
362
-
363
- async getFeatured(req: IRequestContext): Promise<IControllerResponse> {
364
- const products = await this.repository.getAll({ filters: { isFeatured: true } });
365
- return { success: true, data: products };
366
- }
367
- }
368
- ```
369
-
370
- ## Events
371
-
372
- Domain event pub/sub with pluggable transports. The factory auto-registers `eventPlugin` — no manual setup needed:
373
-
374
- ```typescript
375
- // createApp() registers eventPlugin automatically (default: MemoryEventTransport)
376
- // Transport is sourced from stores.events if provided
377
- const app = await createApp({
378
- stores: { events: new RedisEventTransport(redis) }, // optional, defaults to memory
379
- arcPlugins: {
380
- events: { // event plugin config (default: true)
381
- logEvents: true,
382
- retry: { maxRetries: 3, backoffMs: 1000 },
383
- },
384
- },
385
- });
386
-
387
- await app.events.publish('order.created', { orderId: '123' });
388
- await app.events.subscribe('order.*', async (event) => { ... });
389
- ```
390
-
391
- CRUD events (`product.created`, `product.updated`, `product.deleted`) emit automatically.
392
-
393
- ### Causation Chains & DLQ (v2.9)
394
-
395
- ```typescript
396
- import { createEvent, createChildEvent, type DeadLetteredEvent } from '@classytic/arc/events';
397
-
398
- const placed = createEvent('order.placed', { orderId: 'o1' }, {
399
- correlationId: req.id, userId: user.id,
400
- });
401
- await app.events.publish(placed.type, placed.payload, placed.meta);
402
-
403
- // Downstream handler emits a child — correlation inherited, causation linked:
404
- const reserved = createChildEvent(placed, 'inventory.reserved', { sku: 'a' });
405
- // reserved.meta.correlationId === placed.meta.correlationId (stays stable across chain)
406
- // reserved.meta.causationId === placed.meta.id (direct parent)
407
-
408
- // Transports with native DLQ (Kafka, SQS) implement optional deadLetter():
409
- class KafkaTransport implements EventTransport {
410
- async deadLetter(dlq: DeadLetteredEvent) { /* route to .DLQ topic */ }
411
144
  }
412
145
  ```
413
146
 
414
- `EventMeta` also accepts `schemaVersion` (evolve event payloads) and `partitionKey` (ordered delivery hint for Kafka/Kinesis).
415
-
416
- ### defineEvent — Typed Events with Schema Validation
417
-
418
- Declare events with schemas for runtime validation and introspection:
419
-
420
- ```typescript
421
- import { defineEvent, createEventRegistry } from '@classytic/arc/events';
422
-
423
- // Define typed events
424
- const OrderCreated = defineEvent({
425
- name: 'order.created',
426
- version: 1,
427
- description: 'Emitted when an order is placed',
428
- schema: {
429
- type: 'object',
430
- properties: {
431
- orderId: { type: 'string' },
432
- total: { type: 'number' },
433
- currency: { type: 'string' },
434
- },
435
- required: ['orderId', 'total'],
436
- },
437
- });
438
-
439
- // Type-safe event creation
440
- const event = OrderCreated.create({ orderId: 'o-1', total: 100 }, { userId: 'user-1' });
441
- await app.events.publish(event.type, event.payload, event.meta);
442
- ```
443
-
444
- **Event Registry** — catalog + auto-validation on publish:
445
-
446
- ```typescript
447
- const registry = createEventRegistry();
448
- registry.register(OrderCreated);
449
- registry.register(OrderShipped);
450
-
451
- // Wire into eventPlugin — validates payloads on publish
452
- const app = await createApp({
453
- arcPlugins: {
454
- events: { registry, validateMode: 'warn' },
455
- // 'warn' (default): log warning, still publish
456
- // 'reject': throw error, do NOT publish
457
- // 'off': registry is introspection-only
458
- },
459
- });
460
-
461
- // Introspect at runtime
462
- app.events.registry?.catalog();
463
- // → [{ name: 'order.created', version: 1, schema: {...} }, ...]
464
- ```
465
-
466
- Export the registry alongside resources for `arc describe` to auto-detect:
147
+ Custom checks return `{ granted, reason?, filters?, scope? }``filters` propagate into the repo query (row-level ABAC), `scope` stamps attributes downstream.
467
148
 
468
- ```typescript
469
- // src/events.ts
470
- export const eventRegistry = createEventRegistry();
471
- eventRegistry.register(OrderCreated);
472
- eventRegistry.register(OrderShipped);
473
- ```
149
+ ---
474
150
 
475
- ### Event Transports
151
+ ## Authentication
476
152
 
477
- | Transport | Import | Use Case |
478
- |-----------|--------|----------|
479
- | `MemoryEventTransport` | `@classytic/arc/events` | Development, testing, single-instance |
480
- | `RedisEventTransport` | `@classytic/arc/events/redis` | Multi-instance pub/sub (fan-out) |
481
- | `RedisStreamTransport` | `@classytic/arc/events/redis-stream` | Ordered events with consumer groups |
153
+ Discriminated union on `type`:
482
154
 
483
155
  ```typescript
484
- // Redis Pub/Sub
485
- import { RedisEventTransport } from '@classytic/arc/events/redis';
486
- const transport = new RedisEventTransport(redis, { channel: 'arc-events' });
156
+ // JWT (with optional revocation + custom token extractor)
157
+ auth: { type: 'jwt', jwt: { secret, expiresIn: '15m' } }
487
158
 
488
- // Redis Streams (ordered, durable)
489
- import { RedisStreamTransport } from '@classytic/arc/events/redis-stream';
490
- const transport = new RedisStreamTransport(redis, { stream: 'arc-events' });
491
- ```
492
-
493
- **Behavioral contract:**
494
- - **Memory**: Handlers execute sequentially (ordered, awaited)
495
- - **Redis Pub/Sub**: Handlers fire-and-forget (unordered, fan-out)
496
- - **Redis Streams**: Ordered delivery with consumer group acknowledgment
497
-
498
- ### Retry & Dead Letter Queue
159
+ // Better Auth (recommended for SaaS with orgs)
160
+ import { createBetterAuthAdapter } from '@classytic/arc/auth';
161
+ auth: { type: 'betterAuth', betterAuth: createBetterAuthAdapter({ auth: getAuth(), orgContext: true }) }
499
162
 
500
- ```typescript
501
- import { withRetry, createDeadLetterPublisher } from '@classytic/arc/events';
502
-
503
- // Per-handler retry with exponential backoff
504
- await app.events.subscribe('order.created', withRetry(
505
- async (event) => { await sendConfirmationEmail(event.payload); },
506
- {
507
- maxRetries: 3,
508
- backoffMs: 1000,
509
- onDead: createDeadLetterPublisher(app.events), // publishes to $deadLetter channel
510
- },
511
- ));
163
+ // Custom Fastify plugin
164
+ auth: { type: 'custom', plugin: myAuthPlugin }
512
165
 
513
- // Or configure auto-retry for ALL handlers via plugin
514
- const app = await createApp({
515
- arcPlugins: {
516
- events: {
517
- retry: { maxRetries: 3, backoffMs: 1000 },
518
- deadLetterQueue: { store: async (event, errors) => { /* custom DLQ */ } },
519
- },
520
- },
521
- });
166
+ // Disabled (e.g. internal services)
167
+ auth: false
522
168
  ```
523
169
 
524
- ## Factory createApp()
170
+ Better Auth + Mongoose `populate()`: import `registerBetterAuthMongooseModels` from `@classytic/arc/auth/mongoose` to register `strict: false` stub models for BA collections. Subpath gate keeps Mongoose out of Prisma/Drizzle bundles.
525
171
 
526
- ```typescript
527
- const app = await createApp({
528
- preset: 'production', // production | development | testing | edge
529
- runtime: 'memory', // memory (default) | distributed (requires Redis)
530
- auth: { type: 'jwt', jwt: { secret } },
531
- cors: { origin: ['https://myapp.com'] },
532
- helmet: true, // false to disable
533
- rateLimit: { max: 100 }, // false to disable
534
- arcPlugins: {
535
- events: true, // event plugin (default: true, false to disable)
536
- emitEvents: true, // CRUD event emission (default: true)
537
- queryCache: true, // server cache (default: false)
538
- sse: true, // server-sent events (default: false)
539
- caching: true, // ETag + Cache-Control (default: false)
540
- },
541
- stores: { // required when runtime: 'distributed'
542
- events: new RedisEventTransport({ client: redis }),
543
- cache: new RedisCacheStore({ client: redis }),
544
- queryCache: new RedisCacheStore({ client: redis, prefix: 'arc:qc:' }),
545
- },
546
- });
547
- ```
172
+ ---
548
173
 
549
- **Arc plugins defaults:**
174
+ ## Subpath imports
550
175
 
551
- | Plugin | Default | Status |
552
- |--------|---------|--------|
553
- | `events` | `true` | opt-out — registers `eventPlugin` (provides `fastify.events`) |
554
- | `emitEvents` | `true` | opt-out — CRUD operations emit domain events |
555
- | `requestId` | `true` | opt-out |
556
- | `health` | `true` | opt-out |
557
- | `gracefulShutdown` | `true` | opt-out |
558
- | `caching` | `false` | opt-in — ETag + Cache-Control headers |
559
- | `queryCache` | `false` | opt-in — TanStack Query-inspired server cache |
560
- | `sse` | `false` | opt-in — Server-Sent Events streaming |
176
+ Tree-shake by importing only the subpath you need:
561
177
 
562
- | Preset | Logging | Rate Limit | Security |
563
- |--------|---------|------------|----------|
564
- | production | info | 100/min | full |
565
- | development | debug | 1000/min | relaxed |
566
- | testing | silent | disabled | minimal |
567
- | edge | warn | disabled | none (API GW handles) |
178
+ | Subpath | Purpose |
179
+ |---|---|
180
+ | `@classytic/arc` | `defineResource`, `BaseController`, `createMongooseAdapter`, error classes |
181
+ | `@classytic/arc/factory` | `createApp`, `loadResources`, presets |
182
+ | `@classytic/arc/auth` | JWT + Better Auth adapters |
183
+ | `@classytic/arc/auth/mongoose` | Better Auth Mongoose stub models (opt-in) |
184
+ | `@classytic/arc/permissions` | All permission helpers |
185
+ | `@classytic/arc/scope` | `RequestScope` accessors (`isMember`, `isElevated`, `getOrgId`, …) |
186
+ | `@classytic/arc/cache` | `QueryCache`, transports, plugin |
187
+ | `@classytic/arc/events` | Event plugin, transports, outbox |
188
+ | `@classytic/arc/events/redis` · `/redis-stream` | Redis Pub/Sub + Streams transports (opt-in) |
189
+ | `@classytic/arc/plugins` | Health, request-id, versioning, tracing, response-cache |
190
+ | `@classytic/arc/integrations/jobs` | BullMQ job dispatcher |
191
+ | `@classytic/arc/integrations/websocket` | WebSocket integration |
192
+ | `@classytic/arc/mcp` | Model Context Protocol tools |
193
+ | `@classytic/arc/testing` | `createTestApp`, `expectArc`, `TestAuthProvider`, `createTestFixtures` |
194
+ | `@classytic/arc/types` | Type-only barrel (zero runtime cost) |
568
195
 
569
- ## Real-Time
196
+ ---
570
197
 
571
- SSE and WebSocket with fail-closed auth (throws at registration if auth missing):
198
+ ## Testing
572
199
 
573
200
  ```typescript
574
- // SSE via factory
575
- const app = await createApp({
576
- arcPlugins: { sse: { path: '/events', requireAuth: true, orgScoped: true } },
577
- });
578
-
579
- // WebSocket — separate plugin
580
- import { websocketPlugin } from '@classytic/arc/integrations/websocket';
581
- await app.register(websocketPlugin, {
582
- auth: true, // fail-closed: throws if authenticate not registered
583
- resources: ['product', 'order'],
584
- roomPolicy: (client, room) => ['product', 'order'].includes(room),
585
- reauthInterval: 300000, // re-validate token every 5 min (0 = disabled)
586
- maxMessageBytes: 16384, // 16KB message size cap
587
- maxSubscriptionsPerClient: 100, // prevent resource exhaustion
588
- });
589
-
590
- // EventGateway — unified SSE + WebSocket with shared config
591
- import { eventGatewayPlugin } from '@classytic/arc/integrations/event-gateway';
592
- await app.register(eventGatewayPlugin, {
593
- auth: true, orgScoped: true,
594
- roomPolicy: (client, room) => allowedRooms.includes(room),
595
- sse: { path: '/api/events', patterns: ['order.*'] },
596
- ws: { path: '/ws', resources: ['product', 'order'] },
597
- });
598
- ```
599
-
600
- ## Pipeline — Guards, Transforms, Interceptors
201
+ import { createTestApp, expectArc } from '@classytic/arc/testing';
202
+ import productResource from './product.resource.js';
601
203
 
602
- Functional composition for cross-cutting concerns:
603
-
604
- ```typescript
605
- import { pipe, guard, transform, intercept } from '@classytic/arc/pipeline';
606
-
607
- const isActive = guard('isActive', (ctx) => ctx.query?.filters?.isActive !== false);
608
- const slugify = transform('slugify', (ctx) => ({ ...ctx, body: { ...ctx.body, slug: toSlug(ctx.body.name) } }));
609
- const timing = intercept('timing', async (ctx, next) => {
610
- const start = Date.now();
611
- const result = await next();
612
- console.log(`${ctx.resource}.${ctx.operation}: ${Date.now() - start}ms`);
613
- return result;
204
+ const ctx = await createTestApp({
205
+ resources: [productResource],
206
+ authMode: 'jwt',
207
+ connectMongoose: true, // in-memory Mongo + Mongoose connect
614
208
  });
209
+ ctx.auth.register('admin', { user: { id: '1', roles: ['admin'] }, orgId: 'org-1' });
615
210
 
616
- defineResource({
617
- name: 'product',
618
- pipe: pipe(isActive, slugify, timing),
619
- // or per-operation: pipe: { create: pipe(slugify), list: pipe(timing) }
211
+ const res = await ctx.app.inject({
212
+ method: 'POST',
213
+ url: '/products',
214
+ headers: ctx.auth.as('admin').headers,
215
+ payload: { name: 'Widget' },
620
216
  });
621
- ```
217
+ expectArc(res).ok().hidesField('password');
622
218
 
623
- ## Utilities
624
-
625
- ```typescript
626
- // Circuit Breaker — fault tolerance for external service calls
627
- import { createCircuitBreaker } from '@classytic/arc/utils';
628
- const paymentBreaker = createCircuitBreaker(
629
- async (amount) => stripe.charges.create({ amount }),
630
- { name: 'stripe', failureThreshold: 5, resetTimeout: 30000, fallback: async () => cached },
631
- );
632
-
633
- // State Machine — workflow validation
634
- import { createStateMachine } from '@classytic/arc/utils';
635
- const orderState = createStateMachine('Order', {
636
- approve: ['pending', 'draft'],
637
- cancel: ['pending', 'approved'],
638
- fulfill: { from: ['approved'], to: 'fulfilled', guard: ({ data }) => data.paid },
639
- });
640
- orderState.assert('approve', currentStatus); // throws if invalid transition
219
+ await ctx.close();
641
220
  ```
642
221
 
643
- ## Integrations
222
+ Three entry points: `createTestApp` (custom scenarios), `createHttpTestHarness` (~16 auto-generated CRUD/permission/validation tests per resource), `runStorageContract` (adapter conformance).
644
223
 
645
- All separate subpath imports — only loaded when used:
646
-
647
- ```typescript
648
- // Job Queue (BullMQ)
649
- import { jobsPlugin, defineJob } from '@classytic/arc/integrations/jobs';
650
-
651
- // WebSocket (room-based, CRUD auto-broadcast)
652
- import { websocketPlugin } from '@classytic/arc/integrations/websocket';
653
-
654
- // EventGateway (unified SSE + WebSocket)
655
- import { eventGatewayPlugin } from '@classytic/arc/integrations/event-gateway';
656
-
657
- // Streamline Workflows
658
- import { streamlinePlugin } from '@classytic/arc/integrations/streamline';
659
-
660
- // Audit Trail
661
- import { auditPlugin } from '@classytic/arc/audit';
662
-
663
- // Idempotency (exactly-once mutations)
664
- import { idempotencyPlugin } from '@classytic/arc/idempotency';
665
-
666
- // OpenTelemetry Tracing
667
- import { tracingPlugin } from '@classytic/arc/plugins/tracing';
668
- ```
224
+ ---
669
225
 
670
226
  ## CLI
671
227
 
672
228
  ```bash
673
- npx @classytic/arc init my-api --mongokit --better-auth --ts # Scaffold project
674
- npx @classytic/arc generate resource product # Generate resource files
675
- npx @classytic/arc describe ./dist/index.js # Resource metadata (JSON)
676
- npx @classytic/arc docs ./openapi.json --entry ./dist/index.js # Export OpenAPI
677
- npx @classytic/arc introspect --entry ./dist/index.js # Show resources
678
- npx @classytic/arc doctor # Health check
679
- ```
680
-
681
- `arc describe` auto-detects exported `EventRegistry` and includes the event catalog in output:
682
-
683
- ```json
684
- {
685
- "$schema": "arc-describe/v1",
686
- "resources": [...],
687
- "eventCatalog": [
688
- { "name": "order.created", "version": 1, "hasSchema": true, "schemaFields": ["orderId", "total"], "requiredFields": ["orderId", "total"] }
689
- ],
690
- "stats": { "totalResources": 5, "totalRoutes": 28, "totalCatalogedEvents": 3 }
691
- }
692
- ```
693
-
694
- ## Bundle Size
695
-
696
- Arc is tree-shakable and split into 47 subpath exports. You pay only for what you import.
697
-
698
- | What you import | JS shipped to your bundle |
699
- |---|---|
700
- | `createApp` + `defineResource` + `BaseController` (minimal CRUD API) | **~130 KB** |
701
- | `+ @classytic/arc/events/redis` (distributed pub/sub) | +24 KB |
702
- | `+ @classytic/arc/integrations/jobs` (BullMQ) | +8 KB |
703
- | `+ @classytic/arc/mcp` (AI agent tools) | +24 KB |
704
-
705
- For reference — Fastify core alone is ~300 KB, NestJS core + reflect-metadata is 400+ KB. Arc's minimal footprint is smaller than either, with more features included. `dist/` on disk is 1.7 MB but most of it is `.d.mts` type declarations (free at runtime), the CLI (88 KB, only loaded when running `npx @classytic/arc init`), and the testing helpers (52 KB, never shipped to production).
706
-
707
- **Use subpath imports** — they're the whole reason arc stays lean:
708
-
709
- ```typescript
710
- // Good — each import resolves to exactly one subpath chunk
711
- import { createApp } from '@classytic/arc/factory';
712
- import { defineResource } from '@classytic/arc/core';
713
- import { jobsPlugin } from '@classytic/arc/integrations/jobs'; // only if you use queues
714
- import { mcpPlugin } from '@classytic/arc/mcp'; // only if you expose MCP
715
-
716
- // Bad — pulls the whole barrel; tree-shaking helps but subpath is better
717
- import { createApp, defineResource, jobsPlugin, mcpPlugin } from '@classytic/arc';
718
- ```
719
-
720
- Arc sets `"sideEffects": false` in [package.json](package.json), so modern bundlers (esbuild, Rollup, Webpack 5+, tsdown) correctly eliminate unused exports even from the barrel.
721
-
722
- ## Subpath Imports
723
-
724
- | Import | Purpose |
725
- |--------|---------|
726
- | `@classytic/arc` | Core: `defineResource`, `BaseController`, permissions, errors |
727
- | `@classytic/arc/factory` | `createApp()`, presets |
728
- | `@classytic/arc/cache` | `MemoryCacheStore`, `RedisCacheStore`, `QueryCache` |
729
- | `@classytic/arc/auth` | Auth plugin, Better Auth adapter, session manager |
730
- | `@classytic/arc/events` | Event plugin, transports, `defineEvent`, `createEventRegistry` |
731
- | `@classytic/arc/events/redis` | Redis Pub/Sub event transport |
732
- | `@classytic/arc/events/redis-stream` | Redis Streams event transport |
733
- | `@classytic/arc/plugins` | Health, graceful shutdown, request ID, SSE, caching |
734
- | `@classytic/arc/plugins/tracing` | OpenTelemetry |
735
- | `@classytic/arc/permissions` | All permission functions, role hierarchy |
736
- | `@classytic/arc/scope` | Request scope helpers (`isMember`, `isElevated`, `getOrgId`) |
737
- | `@classytic/arc/org` | Organization module |
738
- | `@classytic/arc/hooks` | Lifecycle hooks |
739
- | `@classytic/arc/presets` | Preset functions + interfaces |
740
- | `@classytic/arc/audit` | Audit trail |
741
- | `@classytic/arc/idempotency` | Idempotency |
742
- | `@classytic/arc/schemas` | TypeBox helpers |
743
- | `@classytic/arc/utils` | Errors, circuit breaker, state machine, query parser |
744
- | `@classytic/arc/testing` | Test utilities, mocks, in-memory DB |
745
- | `@classytic/arc/migrations` | Schema migrations |
746
- | `@classytic/arc/integrations/jobs` | BullMQ job queue |
747
- | `@classytic/arc/integrations/websocket` | WebSocket |
748
- | `@classytic/arc/integrations/event-gateway` | Unified SSE + WebSocket gateway |
749
- | `@classytic/arc/integrations/streamline` | Workflow orchestration |
750
- | `@classytic/arc/mcp` | MCP tools for AI agents |
751
- | `@classytic/arc/docs` | OpenAPI generation |
752
- | `@classytic/arc/cli` | CLI commands (programmatic) |
753
-
754
- ## Type imports
755
-
756
- Arc owns framework types (`IController`, `IRequestContext`, `ResourceConfig`, `RepositoryLike`, `PaginationResult`). The repository contract lives in `@classytic/repo-core` — import those types directly:
757
-
758
- ```typescript
759
- // Arc framework types
760
- import type { IRequestContext, RepositoryLike, PaginationResult } from '@classytic/arc';
761
-
762
- // Repository contract (repo-core is the single source of truth)
763
- import type { StandardRepo, WriteOptions, QueryOptions } from '@classytic/repo-core/repository';
764
- import type { OffsetPaginationResult } from '@classytic/repo-core/pagination';
229
+ arc init my-api --mongokit --better-auth --ts # scaffold a new project
230
+ arc generate resource product # generate a resource
231
+ arc generate resource product --mcp # + MCP tools file
232
+ arc docs ./openapi.json --entry ./dist/index.js # emit OpenAPI
233
+ arc introspect --entry ./dist/index.js # introspect resources
234
+ arc doctor # diagnose env
765
235
  ```
766
236
 
767
- > Arc 2.10 dropped the legacy `CrudRepository`, `PaginatedResult`, and pass-through `WriteOptions`/`QueryOptions` re-exports. See [CHANGELOG.md](CHANGELOG.md#210) for the migration table.
237
+ ---
768
238
 
769
- ## v2.10 Highlights
239
+ ## Documentation
770
240
 
771
- - **Clean-break on repo-core types** `CrudRepository` / `PaginatedResult` / pass-through repo options removed from arc's public surface. Import `StandardRepo`, `OffsetPaginationResult`, etc. directly from `@classytic/repo-core`. See [CHANGELOG.md](CHANGELOG.md) for the rewrite table.
772
- - **Outbox bugfix** — `repositoryAsOutboxStore.fail()` now passes `updatePipeline: true` to `findOneAndUpdate`, so retry / DLQ transitions work on mongokit ≥3.10.
773
- - **Plugin requirements documented** — audit / outbox / idempotency require mongokit's `methodRegistryPlugin` + `batchOperationsPlugin` for `deleteMany`; README snippets + production-ops docs now show the correct chain.
774
- - **Removed** `@classytic/arc/policies` (use `permissions/`), `@classytic/arc/rpc`, `@classytic/arc/dynamic` (use `factory/loadResources`).
241
+ - **Skill** for AI agents: `npx skills add classytic/arc` wires arc into Claude Code / agentic flows.
242
+ - **Concept reference**: [wiki/index.md](wiki/index.md) short, interlinked pages.
243
+ - **Guides**: [docs/](docs/) getting-started, framework-extension, production-ops, testing, ecosystem.
244
+ - **Release notes**: [changelog/v2.md](changelog/v2.md).
775
245
 
776
- See [CHANGELOG.md](CHANGELOG.md) for the full v2.9 / v2.8 / v2.7 history.
246
+ ---
777
247
 
778
248
  ## License
779
249