@classytic/arc 2.38.0 → 2.41.0

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 (196) hide show
  1. package/dist/{BaseController-CYaDvmOB.mjs → BaseController-DYJRZ0EJ.mjs} +114 -24
  2. package/dist/{EventTransport-Cx60_s-6.d.mts → EventTransport-CbUKDzZZ.d.mts} +33 -0
  3. package/dist/{EventTransport-Gq7JWbWw.mjs → EventTransport-n7qO5Cz3.mjs} +22 -2
  4. package/dist/{HookSystem-C0ikzqQf.mjs → HookSystem-CxvZBo77.mjs} +2 -2
  5. package/dist/{ResourceRegistry-DPcX--zk.mjs → ResourceRegistry-Cu7uTJp9.mjs} +2 -2
  6. package/dist/{actionPermissions-jlFZ1it4.mjs → actionPermissions-DPwPWPOV.mjs} +7 -6
  7. package/dist/audit/index.d.mts +2 -2
  8. package/dist/audit/index.mjs +10 -3
  9. package/dist/auth/audit.mjs +1 -1
  10. package/dist/auth/index.d.mts +2 -2
  11. package/dist/auth/index.mjs +12 -6
  12. package/dist/auth/redis-session.mjs +1 -1
  13. package/dist/{authorizationDecision-BP8GHtjc.mjs → authorizationDecision-2YY_Kaj1.mjs} +2 -2
  14. package/dist/{base-C-NL2fTi.d.mts → base-CxrV1LmG.d.mts} +10 -0
  15. package/dist/{buildHandler-DjXOqIvX.mjs → buildHandler-CUhjGHGZ.mjs} +4 -4
  16. package/dist/cache/index.d.mts +4 -2
  17. package/dist/cache/index.mjs +4 -4
  18. package/dist/{caching-a3ZEcJJs.mjs → caching-DsWDjIxx.mjs} +2 -2
  19. package/dist/cleanup/index.d.mts +2 -2
  20. package/dist/cleanup/index.mjs +21 -5
  21. package/dist/cli/commands/describe.d.mts +1 -1
  22. package/dist/cli/commands/describe.mjs +3 -3
  23. package/dist/cli/commands/docs.mjs +3 -3
  24. package/dist/cli/commands/doctor.mjs +1 -1
  25. package/dist/cli/commands/generate.mjs +2 -2
  26. package/dist/cli/commands/init.mjs +2 -2
  27. package/dist/cli/commands/introspect.mjs +2 -2
  28. package/dist/cli/index.mjs +3 -3
  29. package/dist/{constants-BONghE1Y.mjs → constants-BxLgNFF7.mjs} +1 -1
  30. package/dist/context/index.d.mts +34 -6
  31. package/dist/context/index.mjs +42 -24
  32. package/dist/core/index.d.mts +2 -2
  33. package/dist/core/index.mjs +7 -7
  34. package/dist/{core-8She7FgF.mjs → core-iNGqYerO.mjs} +3 -3
  35. package/dist/{createActionRouter-Bqi6Bhhx.mjs → createActionRouter-BBlbPs3k.mjs} +7 -7
  36. package/dist/{createAggregationRouter-BsYO7ktd.mjs → createAggregationRouter-Dv5SuK2x.mjs} +7 -7
  37. package/dist/createMcpServer-CbDWkZ1T.d.mts +48 -0
  38. package/dist/{defineAggregation-0KBvqEEC.mjs → defineAggregation-UyeurGkp.mjs} +1 -1
  39. package/dist/{defineResource-BMdyRelW.mjs → defineResource-DzGT-2LP.mjs} +20 -22
  40. package/dist/discovery/index.mjs +1 -1
  41. package/dist/docs/index.d.mts +1 -1
  42. package/dist/docs/index.mjs +3 -3
  43. package/dist/{elevation-C6woysGC.mjs → elevation-BF-vhGxY.mjs} +4 -4
  44. package/dist/encryption/index.mjs +3 -3
  45. package/dist/{errorHandler-DI5AN2b5.d.mts → errorHandler--4rSMMvq.d.mts} +7 -1
  46. package/dist/{errorHandler-BdmIWPba.mjs → errorHandler-CIMkCOWm.mjs} +3 -3
  47. package/dist/{errors-2ExmOgIJ.mjs → errors-C_YhELhe.mjs} +1 -1
  48. package/dist/{eventPlugin-DFvHa4Nf.d.mts → eventPlugin-B52abJ8W.d.mts} +1 -1
  49. package/dist/{eventPlugin-DJwpGMzx.mjs → eventPlugin-CmXaPWLv.mjs} +5 -5
  50. package/dist/events/index.d.mts +4 -4
  51. package/dist/events/index.mjs +5 -5
  52. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  53. package/dist/events/transports/redis-stream-entry.mjs +34 -24
  54. package/dist/events/transports/redis.d.mts +1 -1
  55. package/dist/events/transports/redis.mjs +11 -8
  56. package/dist/factory/index.d.mts +5 -5
  57. package/dist/factory/index.mjs +5 -5
  58. package/dist/{fastify-augmentation-jYA2oHyL.d.mts → fastify-augmentation-Cq2nHVaW.d.mts} +3 -3
  59. package/dist/{fields-Cha2QCKH.mjs → fields-BTyPLHK0.mjs} +1 -1
  60. package/dist/{generate-BUP4Oydg.mjs → generate-D_YmiCNf.mjs} +4 -3
  61. package/dist/{headers-DeXNifVj.mjs → headers-CNECDE6q.mjs} +2 -2
  62. package/dist/{health-Bh0N_FLk.mjs → health-CxQzbJmr.mjs} +10 -2
  63. package/dist/hooks/index.d.mts +1 -1
  64. package/dist/hooks/index.mjs +2 -2
  65. package/dist/idempotency/index.d.mts +1 -1
  66. package/dist/idempotency/index.mjs +4 -4
  67. package/dist/idempotency/redis.d.mts +1 -1
  68. package/dist/idempotency/redis.mjs +1 -1
  69. package/dist/{index-BtYoMip1.d.mts → index-BHdZD8zK.d.mts} +10 -2
  70. package/dist/{index-D2uIUSQk.d.mts → index-CFfLKMH4.d.mts} +2 -2
  71. package/dist/{index-eyg5WEcy.d.mts → index-CbJiYrfv.d.mts} +1 -1
  72. package/dist/{index-BRoDtjek.d.mts → index-D4u76eQW.d.mts} +3 -3
  73. package/dist/{index-B78WZom8.d.mts → index-DHbyPo5j.d.mts} +52 -12
  74. package/dist/index.d.mts +5 -5
  75. package/dist/index.mjs +11 -11
  76. package/dist/{init-Dg_Am2o_.mjs → init-Y2MlSN9n.mjs} +9 -9
  77. package/dist/integrations/event-gateway.d.mts +1 -1
  78. package/dist/integrations/event-gateway.mjs +2 -2
  79. package/dist/integrations/index.d.mts +2 -2
  80. package/dist/integrations/index.mjs +1 -1
  81. package/dist/integrations/jobs/index.d.mts +1 -1
  82. package/dist/integrations/jobs/index.mjs +2 -2
  83. package/dist/integrations/mcp/index.d.mts +4 -47
  84. package/dist/integrations/mcp/index.mjs +2 -2
  85. package/dist/integrations/mcp/testing.d.mts +22 -2
  86. package/dist/integrations/mcp/testing.mjs +55 -33
  87. package/dist/integrations/streamline.mjs +3 -3
  88. package/dist/integrations/webhooks.d.mts +2 -2
  89. package/dist/integrations/webhooks.mjs +3 -3
  90. package/dist/integrations/websocket-pushref-redis.d.mts +1 -1
  91. package/dist/integrations/websocket-pushref-redis.mjs +1 -1
  92. package/dist/integrations/websocket-redis.mjs +1 -1
  93. package/dist/integrations/websocket.mjs +24 -6
  94. package/dist/{interface-DYPVscCU.mjs → interface-BIpgzbZl.mjs} +1 -1
  95. package/dist/{jsonBody-DVui0uJl.mjs → jsonBody-Cq9KYl2Z.mjs} +2 -2
  96. package/dist/{keys-BSuxGS-B.mjs → keys-Cmv4cBRa.mjs} +1 -1
  97. package/dist/{loadResources-CNd1DWnK.mjs → loadResources-CblF7xt2.mjs} +2 -2
  98. package/dist/{loadResourcesFromEntry-DNj8cpiY.mjs → loadResourcesFromEntry-99fIMC3p.mjs} +2 -2
  99. package/dist/logger/index.mjs +1 -1
  100. package/dist/{mcpPlugin-KWsezMx7.mjs → mcpPlugin-xF3Gw-HW.mjs} +75 -31
  101. package/dist/{memory-BiPVaCPg.mjs → memory-CpDZ9NPw.mjs} +2 -2
  102. package/dist/{metrics-CsWaIVTa.mjs → metrics-BaKnq8ag.mjs} +2 -2
  103. package/dist/middleware/index.d.mts +1 -1
  104. package/dist/middleware/index.mjs +3 -3
  105. package/dist/migrations/index.mjs +2 -2
  106. package/dist/{module-BQXHxMEk.mjs → module-BkQEesNQ.mjs} +1 -1
  107. package/dist/{multipartBody-u6VrS_iH.mjs → multipartBody-C92aJ8am.mjs} +1 -1
  108. package/dist/{openapi-Dxc8RLy_.mjs → openapi-C3LFRzPq.mjs} +6 -6
  109. package/dist/outbox-admin/index.d.mts +2 -2
  110. package/dist/outbox-admin/index.mjs +4 -4
  111. package/dist/{outbox-module-CQXu_O6o.d.mts → outbox-module-BHdUnuuK.d.mts} +15 -2
  112. package/dist/{outbox-module-DyIqA5vo.mjs → outbox-module-dsCyLxXH.mjs} +43 -21
  113. package/dist/permissions/index.d.mts +2 -2
  114. package/dist/permissions/index.mjs +5 -5
  115. package/dist/{permissions-H7Zcb82Z.mjs → permissions-DiHXy8Ck.mjs} +12 -6
  116. package/dist/{pipe-SunhEaZ6.mjs → pipe-CBVmUJ-j.mjs} +2 -2
  117. package/dist/pipeline/index.d.mts +1 -1
  118. package/dist/pipeline/index.mjs +2 -2
  119. package/dist/plugins/index.d.mts +7 -37
  120. package/dist/plugins/index.mjs +34 -22
  121. package/dist/plugins/response-cache.mjs +9 -2
  122. package/dist/plugins/tracing-entry.mjs +2 -2
  123. package/dist/{pluralize-Da7IT_qJ.mjs → pluralize-2_U6QZQg.mjs} +1 -1
  124. package/dist/{preloadResources-DlhH81py.mjs → preloadResources-Bbp4QJ8V.mjs} +82 -26
  125. package/dist/{preloadResources-BbBkNkqK.d.mts → preloadResources-CjS2OfpU.d.mts} +14 -9
  126. package/dist/presets/filesUpload.d.mts +2 -2
  127. package/dist/presets/filesUpload.mjs +5 -5
  128. package/dist/presets/index.d.mts +2 -2
  129. package/dist/presets/index.mjs +2 -2
  130. package/dist/presets/multiTenant.d.mts +38 -1
  131. package/dist/presets/multiTenant.mjs +24 -6
  132. package/dist/presets/search.d.mts +2 -2
  133. package/dist/presets/search.mjs +2 -2
  134. package/dist/{presets-CNyfaxGL.mjs → presets-BdlugYTc.mjs} +4 -4
  135. package/dist/{projection-DSfEoooA.mjs → projection-Uvgp-EEj.mjs} +2 -2
  136. package/dist/{queryCachePlugin-oTsmVyEQ.mjs → queryCachePlugin-B5F09bYr.mjs} +11 -5
  137. package/dist/rateLimitKey-CK-EU2L8.mjs +41 -0
  138. package/dist/{realtime-CPmjjgqd.mjs → realtime-FnnD0n3Z.mjs} +9 -9
  139. package/dist/{redis-CduhKsJi.d.mts → redis-Bzi1y3F9.d.mts} +10 -2
  140. package/dist/{redis-stream-eh0eOtjh.d.mts → redis-stream-BPKscsTB.d.mts} +12 -7
  141. package/dist/{registerRoute-XvcI_3sh.mjs → registerRoute-DRsSHPSM.mjs} +6 -6
  142. package/dist/registry/index.d.mts +2 -2
  143. package/dist/registry/index.mjs +3 -3
  144. package/dist/{registry-UFEJQN8R.mjs → registry-D3y8VEJ1.mjs} +2 -2
  145. package/dist/{renewingLease-DuRGVOMe.mjs → renewingLease-B3VYh5aU.mjs} +1 -1
  146. package/dist/{replyHelpers-BHebarf0.mjs → replyHelpers-DPHsdw-5.mjs} +2 -2
  147. package/dist/{requestContext-DpmntRZy.mjs → requestContext-cSVKnYPk.mjs} +1 -1
  148. package/dist/{rolldown-runtime-Add2L50I.mjs → rolldown-runtime-DjDuyglE.mjs} +1 -1
  149. package/dist/{runtimeCapabilities-D793Pxci.mjs → runtimeCapabilities-DSA_dH1_.mjs} +31 -5
  150. package/dist/{schedules-DthM8Qag.mjs → schedules-BktJb3Y3.mjs} +8 -4
  151. package/dist/{schemaConverter-Db-MMsH6.mjs → schemaConverter-BLCSmNbr.mjs} +1 -1
  152. package/dist/{schemaIR-CIXShch7.mjs → schemaIR-K16Bb5BX.mjs} +2 -2
  153. package/dist/schemas/index.mjs +1 -1
  154. package/dist/scim/index.mjs +2 -2
  155. package/dist/scope/index.mjs +5 -5
  156. package/dist/{scope-D03sk17n.mjs → scope-jSxuBTFM.mjs} +4 -4
  157. package/dist/{simpleEqualityMatcher-B7qVjQBv.mjs → simpleEqualityMatcher-BiOnyflP.mjs} +1 -1
  158. package/dist/{sse-D4MYqkic.mjs → sse-CH7Slxc1.mjs} +5 -5
  159. package/dist/{sseStream-BuMNPR6r.mjs → sseStream-CN-Lh1Tu.mjs} +2 -2
  160. package/dist/{store-helpers-CIF4RG8s.mjs → store-helpers-9DX9-1j_.mjs} +1 -1
  161. package/dist/{streaming-DkpW1HwJ.mjs → streaming-dvppnxsr.mjs} +1 -1
  162. package/dist/{subscribe-helpers-CBZOi6pk.mjs → subscribe-helpers-AVYPgqTl.mjs} +2 -2
  163. package/dist/sync/changelog.mjs +1 -1
  164. package/dist/testing/cleanupStoreContract.mjs +1 -1
  165. package/dist/testing/index.d.mts +5 -5
  166. package/dist/testing/index.mjs +10 -10
  167. package/dist/testing/outboxStoreContract.mjs +14 -3
  168. package/dist/testing/storageContract.mjs +1 -1
  169. package/dist/{transactionContext-DMmvcfdo.mjs → transactionContext-DCWMI6hV.mjs} +1 -1
  170. package/dist/{typeGuards-DHTnEs5X.mjs → typeGuards-D7tHFdH6.mjs} +1 -1
  171. package/dist/types/index.d.mts +2 -2
  172. package/dist/types/index.mjs +1 -1
  173. package/dist/types/storage.mjs +1 -1
  174. package/dist/{types-BqLyyH0o.mjs → types-CDy3-zw1.mjs} +4 -4
  175. package/dist/{types-LQFwC37d.d.mts → types-CWQn9-IC.d.mts} +46 -1
  176. package/dist/{types-BRU-Kd3t.d.mts → types-DfVDvkdP.d.mts} +20 -2
  177. package/dist/usage/index.d.mts +58 -2
  178. package/dist/usage/index.mjs +0 -0
  179. package/dist/{userHelpers-CXySQFRg.mjs → userHelpers-DDMOZ_Pm.mjs} +1 -1
  180. package/dist/utils/index.d.mts +110 -8
  181. package/dist/utils/index.mjs +12 -12
  182. package/dist/{utils-BbGG8XNT.mjs → utils-B-zj_b__.mjs} +61 -12
  183. package/dist/{validate-CidSeUrx.mjs → validate-R0G_JrnK.mjs} +2 -2
  184. package/dist/{versioning-B843jOYX.d.mts → versioning-D6JIK8Tk.d.mts} +58 -2
  185. package/dist/{versioning-C2r0eOZ4.mjs → versioning-DuVWcu-F.mjs} +3 -3
  186. package/dist/workScope-BhsWde4k.mjs +41 -0
  187. package/package.json +574 -565
  188. package/skills/arc/SKILL.md +8 -4
  189. package/skills/arc/references/mcp.md +20 -0
  190. package/skills/arc/references/production.md +94 -9
  191. package/skills/arc/references/testing.md +0 -12
  192. package/skills/arc-code-review/references/anti-patterns.md +1 -1
  193. package/skills/arc-code-review/references/migration-recipes.md +0 -60
  194. package/skills/arc-code-review/references/mongokit-migration.md +0 -27
  195. package/skills/arc-module-publishing/references/package-manifest.md +3 -1
  196. package/dist/rateLimitKey-CewdR-m6.mjs +0 -15
@@ -725,7 +725,7 @@ await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
725
725
 
726
726
  ## Usage counters + plan limits (2.22)
727
727
 
728
- `@classytic/arc/usage` — `usagePlugin` decorates `fastify.usage` with per-actor, per-UTC-month counters (`record`/`summary`/`period`/`actorOf`; actor chain org → user → client → ip). Auto-tracks `api.requests` (default on) + `api.egress.bytes` (opt-in); recording is fail-safe (a throwing `UsageStore` never fails a request; `MemoryUsageStore` built in, Redis/kit backends are ~5 lines). Pair with `createApp({ rateLimit: { plan: { resolve, limits: { free: { max: 60 }, enterprise: false }, default: 'free' } } })` for per-plan ceilings — `false` = unlimited, unknown/throwing resolvers fall back to `default` then global `max`, and buckets follow the tenant key chain by default. Caveat: the limiter runs before route auth, so `resolve` sees the raw request.
728
+ `@classytic/arc/usage` — `usagePlugin` decorates `fastify.usage` with per-actor, per-UTC-month counters (`record`/`summary`/`period`/`actorOf`; actor chain org → user → client → ip). Auto-tracks `api.requests` (default on) + `api.egress.bytes` (opt-in); recording is fail-safe (a throwing `UsageStore` never fails a request). Stores: `MemoryUsageStore` (default, single-process) and `RedisUsageStore` (2.41 — `HINCRBY`, the right shape for a hot per-request counter) both from `@classytic/arc/usage`; or a kit's (`@classytic/mongokit/usage`) when counters belong beside domain data. `RedisUsageStore` keeps counters forever by default — `retentionSeconds` is opt-in, because expiring billing history on an unchosen schedule is silent (`summary()` returns `{}` for an expired bucket and a never-written one alike). Pair with `createApp({ rateLimit: { plan: { resolve, limits: { free: { max: 60 }, enterprise: false }, default: 'free' } } })` for per-plan ceilings — `false` = unlimited, unknown/throwing resolvers fall back to `default` then global `max`, and buckets follow the tenant key chain by default. Caveat: the limiter runs before route auth, so `resolve` sees the raw request.
729
729
 
730
730
  ## Runtime capability registry (2.33)
731
731
 
@@ -737,12 +737,16 @@ import { declareRuntimeCapability } from '@classytic/arc/utils';
737
737
  declareRuntimeCapability(fastify, {
738
738
  subsystem: 'billing.sequence-cache',
739
739
  durability: 'memory', // 'memory' | 'shared'
740
- accepted: true, // per-process BY DESIGN (logged, never fatal)
741
- detail: 'short-TTL cache; correctness from TTL, not shared state',
740
+ detail: 'invoice numbering cached per process',
741
+ // accepted: true, // ONLY when per-process is intended by design
742
+ // // (logged, never fatal). Must be stated by the
743
+ // // declarant — the audit never infers it.
742
744
  });
743
745
  ```
744
746
 
745
- `createApp`'s constructor-time guard only sees what it is passed; state a host wires inside `plugins()` was invisible to it and lived on a checklist. This makes the checklist enforcement. Arc's webhooks plugin declares its default in-memory store.
747
+ `createApp`'s constructor-time guard only sees what it is passed; state a host wires inside `plugins()` was invisible to it and lived on a checklist. This makes the checklist enforcement. Declare from anywhere a registration reaches — `plugins()`, a module phase, or your own encapsulated `fastify.register()`; all land in the one audit (2.41).
748
+
749
+ **Since 2.41 every memory-backed default arc ships declares itself**: `auth.sessions`, `usage.store`, `audit.store`, `cache.query`, `permissions.dynamic-cache` (pass `fastify:` to the matrix — it has no handle of its own), `websocket.adapter`, `websocket.pushref-store`, `webhooks.store`. `http.response-cache` declares `accepted` (per-replica by design). So a distributed app that booted before 2.41 can now fail on a default it was silently running replica-local — the error names each one, and `references/production.md` has the store-by-store fix table.
746
750
 
747
751
  ## Adapters
748
752
 
@@ -133,6 +133,26 @@ await app.register(mcpPlugin, { resources: catalogResources, prefix: '/mcp/catal
133
133
  await app.register(mcpPlugin, { resources: orderResources, prefix: '/mcp/orders' });
134
134
  ```
135
135
 
136
+ ## DNS-rebinding protection (`Host` / `Origin`)
137
+
138
+ An MCP endpoint on loopback is reachable from any page the developer visits: if
139
+ an attacker-controlled DNS name resolves to `127.0.0.1`, the browser treats the
140
+ reply as same-origin and can read every tool's output. Validating `Host` breaks
141
+ the chain.
142
+
143
+ `dnsRebindingProtection` **defaults on when `auth: false`** (the local shape the
144
+ attack targets) and **off when auth is configured** (a real deployment's `Host`
145
+ is its own domain — defaulting on would 403 it; credentials are the gate there).
146
+
147
+ ```typescript
148
+ mcpPlugin({ resources, auth, dnsRebindingProtection: {} }); // localhost + auth
149
+ mcpPlugin({ resources, dnsRebindingProtection: { allowedHosts: ['mcp.example.com'] } });
150
+ mcpPlugin({ resources, auth: false, dnsRebindingProtection: false }); // deliberately public
151
+ ```
152
+
153
+ An absent `Origin` passes — non-browser MCP clients send none. A present but
154
+ unlisted one is refused. `${prefix}/health` is exempt so probes keep working.
155
+
136
156
  ## Auth — Three Modes
137
157
 
138
158
  Arc doesn't enforce an auth strategy. You choose what fits.
@@ -46,23 +46,50 @@ await fastify.register(requestIdPlugin, {
46
46
 
47
47
  ## Graceful Shutdown Plugin
48
48
 
49
+ Registered by default (`arcPlugins.gracefulShutdown`). Pass an options object to configure it inline from `createApp`.
50
+
49
51
  ```typescript
50
52
  import { gracefulShutdownPlugin } from '@classytic/arc/plugins';
51
53
 
52
54
  await fastify.register(gracefulShutdownPlugin, {
53
- timeout: 30000,
55
+ timeout: 30000, // hard cap; drainDelayMs counts against it
56
+ drainDelayMs: 10000, // lame-duck window — see below
54
57
  signals: ['SIGTERM', 'SIGINT'],
55
58
  logEvents: true,
56
59
  onShutdown: async () => {
57
60
  await mongoose.disconnect();
58
61
  await redis.quit();
59
62
  },
63
+ onForceExit: () => {}, // tests: stop a timeout from killing the runner
60
64
  });
61
65
 
62
- // Sequence: receive signal → stop accepting connections → wait for in-flight
63
- // → run onShutdown → close Fastify → exit process
66
+ // Sequence: signal → shutdownState.draining = true (/ready starts 503ing)
67
+ // → wait drainDelayMs (still serving) → fastify.close() (stops accepting,
68
+ // drains in-flight) → onShutdown() → exit naturally
69
+ ```
70
+
71
+ **`fastify.close()` runs BEFORE `onShutdown`** — so `onShutdown` is where you release what arc doesn't own (DB connections, your own Redis client), and it runs after in-flight requests are done. Don't close anything a request might still need.
72
+
73
+ ### `drainDelayMs` — the rolling-deploy 502
74
+
75
+ Default `0`, which is the pre-2.41 behaviour: close the moment SIGTERM arrives. That is the classic rolling-deploy 502 — a load balancer keeps routing to an instance until its readiness probe has failed **for several intervals**, and by then the instance already stopped accepting.
76
+
77
+ `drainDelayMs` is the window between "start failing readiness" and "stop accepting":
78
+
79
+ - `/health/ready` → `503 { status: 'draining' }` immediately on signal
80
+ - `/health/live` → stays `200` (a draining process is alive — that's the point; a 503 here gets the pod **killed**, not drained)
81
+ - everything already routed here is still served for the whole window
82
+
83
+ Set it **above** your LB's deregistration window — typically 5000–15000ms. On Kubernetes, also keep `terminationGracePeriodSeconds` > `drainDelayMs + timeout`, or the kubelet SIGKILLs you mid-drain.
84
+
85
+ ```typescript
86
+ createApp({
87
+ arcPlugins: { gracefulShutdown: { drainDelayMs: 10_000, timeout: 30_000 } },
88
+ });
64
89
  ```
65
90
 
91
+ **Do not also write `process.on('SIGTERM', () => app.close())`.** The plugin already owns those signals; a second handler closes the server immediately and skips the drain entirely — you get the 502s back while the config says you fixed them.
92
+
66
93
  ## Audit Plugin
67
94
 
68
95
  Change tracking. Pass a `RepositoryLike` for persistence, or omit for in-memory dev:
@@ -464,21 +491,79 @@ const app = await createApp({
464
491
  auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET } },
465
492
  cors: { origin: process.env.ALLOWED_ORIGINS?.split(',') ?? [], credentials: true },
466
493
  rateLimit: { max: 100, timeWindow: '1 minute' },
467
- arcPlugins: { queryCache: true },
494
+ arcPlugins: {
495
+ queryCache: true,
496
+ // Behind a load balancer: fail readiness first, keep serving while it
497
+ // deregisters, then close. No manual process.on('SIGTERM') — the plugin
498
+ // owns the signals, and a second handler skips the drain.
499
+ gracefulShutdown: { drainDelayMs: 10_000 },
500
+ },
468
501
  });
469
-
470
- process.on('SIGTERM', () => app.close());
471
- process.on('SIGINT', () => app.close());
472
502
  ```
473
503
 
474
504
  ## Distributed Runtime
475
505
 
476
- `runtime: 'distributed'` only validates stores you actually enable:
506
+ `runtime: 'distributed'` is the "more than one replica" switch. It refuses to boot when something in the app holds state that only exists in **this** process. Two checks, one policy:
507
+
508
+ **1. Constructor-time guard** — what `createApp` can see in its own options, checked before any boot cost:
477
509
 
478
510
  - `stores.events` — always required
479
511
  - `stores.cache` — only when `arcPlugins.caching` enabled
480
512
  - `stores.queryCache` — only when `arcPlugins.queryCache` enabled
481
- - `stores.idempotency` — never validated (per-resource opt-in)
513
+ - `stores.idempotency` — warn only (per-resource opt-in)
514
+ - `rateLimit` — needs a shared store: `{ redis: client }` or `{ store: CustomStore }`. Both forms count; `rateLimit: false` opts out.
515
+ - `arcPlugins.schedules` — needs `lock` (a `LockAdapter`, for a fleet where any replica may arm schedules) **or** `singleReplica: true` (exactly one replica arms them — the `role: 'scheduler'` deployment pinned at 1). Without one, boot fails: otherwise every replica fires every tick, and duplicate side effects are two invoices, not a stale cache. `singleReplica` is a claim about your deployment — scale that deployment past one replica and it becomes false.
516
+
517
+ **2. Capability audit** (end of boot) — everything wired inside `plugins()`, `bootstrap[]`, or a module phase, which the guard above cannot inspect. Subsystems declare via `declareRuntimeCapability`; an undeclared-shared memory default **fails the boot in one error naming every violator**, so you fix them in one pass rather than one restart each.
518
+
519
+ What declares itself, and what fixes it:
520
+
521
+ | Subsystem | Default | Shared option |
522
+ |---|---|---|
523
+ | `auth.sessions` | `MemorySessionStore` | `RedisSessionStore` (`@classytic/arc/auth/redis`) |
524
+ | `usage.store` | `MemoryUsageStore` | `RedisUsageStore` (`@classytic/arc/usage`) or `@classytic/mongokit/usage` |
525
+ | `audit.store` | `MemoryAuditStore` | `repository: yourRepo` (any kit) |
526
+ | `cache.query` | `MemoryCacheStore` | `RedisCacheStore` (`@classytic/arc/cache`) |
527
+ | `permissions.dynamic-cache` | internal memory | `cacheStore:` — **and pass `fastify:`**, the matrix has no handle of its own so it is invisible to the audit without it |
528
+ | `websocket.adapter` | `LocalWebSocketAdapter` | Redis adapter (`@classytic/arc/integrations/websocket-redis`) |
529
+ | `websocket.pushref-store` | memory | Redis pushRef store |
530
+ | `webhooks.store` | memory | shared store, or durable mode |
531
+ | `http.response-cache` | memory | **nothing — declared `accepted`.** Per-replica by design; correctness comes from short TTLs. Logged, never a failure. |
532
+
533
+ ### Circuit breakers count per replica unless you say otherwise
534
+
535
+ `CircuitBreaker` state is in-process. With 50 pods and `failureThreshold: 5`, a dead downstream absorbs **250 real requests** before anything opens — each pod rediscovers the same outage independently. Share the failure window:
536
+
537
+ ```typescript
538
+ import { CircuitBreaker, RedisCircuitBreakerState } from '@classytic/arc/utils';
539
+
540
+ const shared = new RedisCircuitBreakerState({ redis }); // fixed window, default 60s
541
+
542
+ const payments = new CircuitBreaker(charge, {
543
+ name: 'stripe.charges', // REQUIRED with sharedState — the key every replica counts under
544
+ failureThreshold: 5,
545
+ sharedState: shared,
546
+ });
547
+ ```
548
+
549
+ First replica trips at the threshold; every other trips on its **next** failure. It shares the failure *signal*, not the state machine — each replica still owns its own OPEN/HALF_OPEN probing, which avoids needing consensus over who probes. Nothing touches the network on the success path, the failure path never *waits* on the store, and a store that throws degrades to local counting rather than disabling the breaker.
550
+
551
+ Hosts declare their own too — a replica-local cache you built in `plugins()` belongs in the audit:
552
+
553
+ ```typescript
554
+ import { declareRuntimeCapability } from '@classytic/arc/utils';
555
+
556
+ declareRuntimeCapability(fastify, {
557
+ subsystem: 'billing.sequence-cache',
558
+ durability: 'memory', // 'memory' | 'shared'
559
+ detail: 'invoice numbering cached per process',
560
+ // accepted: true, // only when per-process is INTENDED
561
+ });
562
+ ```
563
+
564
+ Declare from anywhere a registration reaches — `plugins()`, a module phase, or inside your own encapsulated `fastify.register()`; all land in the one audit. `accepted: true` is for state that is per-process **by design**; it must be stated by the declarant, never inferred.
565
+
566
+ **Migration note:** a distributed app that booted fine before 2.41 can now fail on a default it was silently running replica-local (usage counters and audit trails were the common ones). The boot error names each. That is the bug surfacing, not a new one.
482
567
 
483
568
  ## Under-Pressure
484
569
 
@@ -198,15 +198,3 @@ runStorageContract(async () => {
198
198
  3. **Use `ctx.fixtures.clear()` in `afterEach`** — destroyers handle dependency order.
199
199
  4. **Test permission denials explicitly** — `expectArc(res).forbidden()` beats status-code assertions.
200
200
  5. **Reach for `createHttpTestHarness` first** — 16 tests for one function call.
201
-
202
- ## Migration from pre-2.11 testing APIs
203
-
204
- | Pre-2.11 | 2.11 |
205
- |---|---|
206
- | `TestHarness` / `createTestHarness` | `createHttpTestHarness(resource, ctxFn)` |
207
- | `TestAppResult` | `TestAppContext` |
208
- | `testApp.mongoUri` | `ctx.dbUri` |
209
- | `createJwtAuthProvider` / `createBetterAuthProvider` (as `HttpTestHarness` imports) | `ctx.auth` from `createTestApp` (or direct factory import) |
210
- | `withTestDb()` | `createTestApp({ db: 'in-memory' })` + `ctx.dbUri` |
211
- | `TestDatabase` / `TestSeeder` / `TestTransaction` | `createTestFixtures` + kit-level cleanup |
212
- | `setupBetterAuthOrg` | `setupBetterAuthTestApp` + `helpers.signUpWithOrg` |
@@ -831,7 +831,7 @@ output_mode: content
831
831
 
832
832
  Also: adapter contract types imported from arc — `import type { DataAdapter, AdapterRepositoryInput, AdapterFactory, OpenApiSchemas, SchemaMetadata, FieldMetadata, RelationMetadata, AdapterValidationResult, AdapterSchemaContext } from '@classytic/arc'` (any subpath). And: `mergeFieldRuleConstraints` from `@classytic/arc/adapters`. Any `from '@classytic/arc/adapters'` import — the entire subpath was removed in arc 2.12.
833
833
 
834
- **Why critical:** This only worked in arc ≤ 2.x. Arc 2.12 moved every kit-specific adapter (Mongoose, Drizzle, Prisma) into its kit and the cross-framework adapter contract into `@classytic/repo-core/adapter`. The `@classytic/arc/adapters` subpath was removed entirely. Importing these names from arc fails to resolve on 3.x — the build breaks at install time. The new shape is **strict**: kit-specific things MUST come from the kit; the contract MUST come from repo-core.
834
+ **Why critical:** `@classytic/arc/adapters` does not exist. Kit-specific adapters (Mongoose, Drizzle, Prisma) live in their kit; the cross-framework contract lives in `@classytic/repo-core/adapter`. Importing these names from arc fails to resolve — the build breaks at install time. The rule is **strict**: kit-specific things come from the kit, the contract comes from repo-core.
835
835
 
836
836
  **Fix:**
837
837
  ```typescript
@@ -4,66 +4,6 @@ Concrete diffs for the most common transformations. Apply in the order listed in
4
4
 
5
5
  ---
6
6
 
7
- ## §0. arc 2.x → 3.0 — every kit-specific adapter moves to its kit
8
-
9
- **Why:** Through arc 2.12, kit-specific adapter factories (`createMongooseAdapter`, `createDrizzleAdapter`, `createPrismaAdapter`) shipped from `@classytic/arc` and arc had a peer dep on `@classytic/mongokit`. This coupled arc's release cadence to every kit's, dragged mongoose into every arc consumer's resolution graph (even Drizzle/Prisma users), and double-published the adapter contract types.
10
-
11
- In arc 2.12:
12
- - Adapter contract → `@classytic/repo-core/adapter` (new subpath in repo-core 0.4.0).
13
- - Mongoose adapter → `@classytic/mongokit/adapter` (3.13.0).
14
- - Drizzle adapter → `@classytic/sqlitekit/adapter` (0.3.0).
15
- - Prisma adapter → `@classytic/prismakit/adapter` (0.1.0 — new kit).
16
- - `mergeFieldRuleConstraints` + `applyNullable` → `@classytic/repo-core/schema`.
17
- - `@classytic/mongokit`, `@prisma/client`, `mongoose` all dropped from arc's `peerDependencies`. Arc 2.12 has zero kit- or driver-bound peers.
18
- - The `@classytic/arc/adapters` subpath was removed entirely. The `src/adapters/` directory inside arc is gone.
19
- - `RepositoryLike` is still re-exported from `@classytic/arc` for convenience.
20
- - Custom kits implementing `DataAdapter<TDoc>` plug in identically — same contract, no special-casing.
21
-
22
- ### Coordinated versions
23
-
24
- These are the floors the adapter SPLIT requires — fixed history, safe to rely on:
25
-
26
- | Package | Min (adapter split) |
27
- |---|---|
28
- | `@classytic/arc` | 2.12.0 |
29
- | `@classytic/repo-core` | 0.4.0 |
30
- | `@classytic/mongokit` | 3.13.0 |
31
- | `@classytic/sqlitekit` | 0.3.0 |
32
- | `@classytic/prismakit` | 0.1.0 (not published to npm — build from source) |
33
-
34
- A second "current best-practice floor" column used to sit here and is gone: it named
35
- arc 2.21.0 while arc shipped 2.37, and sqlitekit 0.7.0 against a published 0.9.0. A
36
- version restated in a skill is wrong within a release and teaches nothing (CLAUDE.md,
37
- "no rotting metrics") — worse, it reads as researched. **For current floors read
38
- `peerDependencies` in arc's `package.json` and each kit's own README**; for what a
39
- given arc version added, [CHANGELOG.md](../../../CHANGELOG.md) is the index.
40
-
41
- ### Import migration table
42
-
43
- | Old (arc 2.11.x) | New (arc 2.12+) |
44
- |---|---|
45
- | `import { createMongooseAdapter } from '@classytic/arc'` | `import { createMongooseAdapter } from '@classytic/mongokit/adapter'` |
46
- | `import { createMongooseAdapter } from '@classytic/arc/adapters'` | `import { createMongooseAdapter } from '@classytic/mongokit/adapter'` |
47
- | `import { createDrizzleAdapter } from '@classytic/arc/adapters'` | `import { createDrizzleAdapter } from '@classytic/sqlitekit/adapter'` |
48
- | `import { createPrismaAdapter } from '@classytic/arc/adapters'` | `import { createPrismaAdapter } from '@classytic/prismakit/adapter'` |
49
- | `import type { DataAdapter, RepositoryLike, AdapterRepositoryInput } from '@classytic/arc'` | `import type { DataAdapter, RepositoryLike, AdapterRepositoryInput } from '@classytic/repo-core/adapter'` |
50
- | `import type { ... } from '@classytic/arc/adapters'` (any contract type) | `import type { ... } from '@classytic/repo-core/adapter'` |
51
- | `import type { InferMongooseDoc } from '@classytic/arc/adapters'` | `import type { InferMongooseDoc } from '@classytic/mongokit/adapter'` |
52
- | `import { mergeFieldRuleConstraints } from '@classytic/arc/adapters'` | `import { mergeFieldRuleConstraints } from '@classytic/repo-core/schema'` |
53
- | `MongooseAdapter`, `DrizzleAdapter`, `PrismaAdapter` (classes) | Same path moves: `@classytic/mongokit/adapter` / `@classytic/sqlitekit/adapter` / `@classytic/prismakit/adapter` |
54
- | Any `from '@classytic/arc/adapters'` import | The subpath was removed in arc 2.12 — re-route to the kit or repo-core per the rows above. |
55
-
56
- ### Mechanical steps
57
-
58
- 1. Bump versions in `package.json` to the matrix above. Drop `mongoose` from explicit deps if you only used it for the adapter — mongokit will pull it as its own peer.
59
- 2. Run a project-wide find/replace using the table above. Detection regex: see anti-patterns.md §32g.
60
- 3. `npx tsc --noEmit` — zero errors.
61
- 4. Smoke test the resource OpenAPI endpoint and one MCP tool — confirm the schema is non-empty (still requires `schemaGenerator`).
62
-
63
- No runtime behavior change — the symbols are identical, only their package paths moved.
64
-
65
- ---
66
-
67
7
  ## §1. Manual CRUD module → `defineResource()`
68
8
 
69
9
  **Scope:** typically 5 routes (`GET`, `GET /:id`, `POST`, `PATCH /:id`, `DELETE /:id`) + ~150 LOC of pagination/validation/permission glue per resource.
@@ -347,33 +347,6 @@ If consumers see `as unknown as RepositoryLike<T>` casts when wiring a repo into
347
347
 
348
348
  In the client project, prefer `createMongooseAdapter({ model, repository })` from `@classytic/mongokit/adapter` over manual `RepositoryLike` shaping — it accepts mongokit-native repos with no casts, and since mongokit 3.21 `schemaGenerator` defaults to `buildCrudSchemasFromModel` (`schemaGenerator: false` opts out — see anti-patterns §32f before recommending that).
349
349
 
350
- ## arc 2.12 / mongokit 3.13.0 — adapter split
351
-
352
- Through mongokit 3.12 / arc 2.11, `createMongooseAdapter` shipped from `@classytic/arc`. In mongokit 3.13.0 + arc 2.12, the adapter lives in mongokit at `@classytic/mongokit/adapter`. Coordinated minimums:
353
-
354
- | Package | Min |
355
- |---|---|
356
- | `@classytic/arc` | 2.12.0 |
357
- | `@classytic/mongokit` | 3.13.0 |
358
- | `@classytic/repo-core` | 0.4.0 |
359
-
360
- Migration shape:
361
-
362
- ```typescript
363
- // arc 2.x
364
- import { createMongooseAdapter } from '@classytic/arc';
365
- import type { DataAdapter, RepositoryLike, AdapterRepositoryInput } from '@classytic/arc';
366
- import type { InferMongooseDoc, MongooseAdapterOptions } from '@classytic/arc/adapters';
367
-
368
- // arc 2.12+
369
- import { createMongooseAdapter } from '@classytic/mongokit/adapter';
370
- import type { DataAdapter, RepositoryLike, AdapterRepositoryInput } from '@classytic/repo-core/adapter';
371
- import type { InferMongooseDoc, MongooseAdapterOptions } from '@classytic/mongokit/adapter';
372
- ```
373
-
374
- The kit owns mongoose as its peer dep; arc dropped both `@classytic/mongokit` and `mongoose` from its `peerDependencies`. Hosts depend on `@classytic/mongokit` directly, which transitively pulls mongoose. Detection regex for stragglers: see `references/anti-patterns.md` §32g.
375
-
376
- ---
377
350
 
378
351
  ## What mongokit does NOT do
379
352
 
@@ -46,7 +46,9 @@ optional-peer typing pattern. Values below are the real shapes shipped by
46
46
  },
47
47
 
48
48
  // Optional peers: declared here AND marked optional below. Host installs only if used.
49
- // "peerDependencies": { ..., "zod": ">=3.23.0", "ioredis": ">=5.0.0" },
49
+ // The floor is the major YOUR suite runs — never one below it. A floor you never test
50
+ // is a compatibility claim, not a fact (arc shipped seven of those until 2.40).
51
+ // "peerDependencies": { ..., "zod": ">=4.4.0", "ioredis": ">=6.0.0" },
50
52
  // "peerDependenciesMeta": {
51
53
  // "zod": { "optional": true },
52
54
  // "ioredis": { "optional": true }
@@ -1,15 +0,0 @@
1
- /*! @classytic/arc v2.38.0 | MIT | https://classytic.com */
2
- //#region src/scope/rateLimitKey.ts
3
- function createTenantKeyGenerator(opts) {
4
- if (opts?.strategy) return opts.strategy;
5
- return (ctx) => {
6
- const scope = ctx.scope;
7
- if (!scope || scope.kind === "public") return ctx.ip;
8
- if (scope.kind === "member") return scope.organizationId;
9
- if (scope.kind === "service") return scope.organizationId;
10
- if (scope.kind === "elevated") return scope.organizationId ?? scope.userId ?? ctx.ip;
11
- return scope.userId ?? ctx.ip;
12
- };
13
- }
14
- //#endregion
15
- export { createTenantKeyGenerator as t };