@classytic/arc 2.19.0 → 2.21.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 (139) hide show
  1. package/README.md +1 -1
  2. package/bin/arc.js +37 -0
  3. package/dist/{BaseController-Dy6EQHjM.mjs → BaseController-BnLYgE9A.mjs} +4 -4
  4. package/dist/{EventTransport-IEn7Tn-Q.d.mts → EventTransport-QmY7A258.d.mts} +0 -1
  5. package/dist/{QueryCache-TRfXau3W.d.mts → QueryCache-Z1B38koh.d.mts} +0 -1
  6. package/dist/{ResourceRegistry-5fKZwhK_.mjs → ResourceRegistry-7RnrEWNP.mjs} +1 -1
  7. package/dist/{actionPermissions-D7NGwcAR.mjs → actionPermissions-ze70xXJi.mjs} +1 -1
  8. package/dist/audit/index.d.mts +9 -5
  9. package/dist/audit/index.mjs +1 -1
  10. package/dist/auth/audit.d.mts +7 -4
  11. package/dist/auth/audit.mjs +5 -0
  12. package/dist/auth/index.d.mts +7 -14
  13. package/dist/auth/index.mjs +7 -5
  14. package/dist/auth/redis-session.d.mts +1 -2
  15. package/dist/{buildHandler-B0qvAguf.mjs → buildHandler-D_2ULWxZ.mjs} +3 -3
  16. package/dist/cache/index.d.mts +2 -3
  17. package/dist/cache/index.mjs +2 -2
  18. package/dist/cli/commands/describe.d.mts +1 -2
  19. package/dist/cli/commands/describe.mjs +2 -2
  20. package/dist/cli/commands/docs.mjs +2 -2
  21. package/dist/cli/commands/generate.mjs +1 -1
  22. package/dist/cli/commands/init.mjs +1 -1
  23. package/dist/cli/commands/introspect.mjs +4 -13
  24. package/dist/cli/index.mjs +2 -2
  25. package/dist/{constants-gaEeMlz-.mjs → constants-DNre0wRg.mjs} +1 -1
  26. package/dist/context/index.d.mts +2 -2
  27. package/dist/core/index.d.mts +3 -3
  28. package/dist/core/index.mjs +5 -5
  29. package/dist/{core-Ba-6Sue6.mjs → core-DZ6Xe1jR.mjs} +182 -55
  30. package/dist/{createActionRouter-B_WzBFyf.mjs → createActionRouter-BWMDW-dL.mjs} +4 -4
  31. package/dist/{createAggregationRouter-B_G_nIjm.mjs → createAggregationRouter-l5mNdYrZ.mjs} +5 -4
  32. package/dist/{createApp--703hQE1.mjs → createApp-Bi61XxqU.mjs} +229 -24
  33. package/dist/discovery/index.d.mts +0 -1
  34. package/dist/docs/index.d.mts +1 -2
  35. package/dist/docs/index.mjs +1 -1
  36. package/dist/{elevation-xGUBq6p_.d.mts → elevation-C9s5hFP_.d.mts} +0 -1
  37. package/dist/encryption/index.d.mts +2 -144
  38. package/dist/{errorHandler-BtUUDPEa.d.mts → errorHandler-B0e-bq-O.d.mts} +0 -1
  39. package/dist/{errors-DOHkjQlD.d.mts → errors-DHcHVAgk.d.mts} +0 -1
  40. package/dist/{eventPlugin-DbBFd3x_.d.mts → eventPlugin-A6EbthXR.d.mts} +10 -6
  41. package/dist/events/index.d.mts +9 -6
  42. package/dist/events/index.mjs +9 -6
  43. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  44. package/dist/events/transports/redis.d.mts +1 -2
  45. package/dist/factory/index.d.mts +5 -4
  46. package/dist/factory/index.mjs +3 -3
  47. package/dist/{fastify-augmentation-D-5_yQJi.d.mts → fastify-augmentation-DafigGQp.d.mts} +12 -2
  48. package/dist/{fields-C7ahdGYf.d.mts → fields-BGqrAa_u.d.mts} +1 -2
  49. package/dist/{generate-Brans3fW.mjs → generate-usRSdQmP.mjs} +4 -3
  50. package/dist/hooks/index.d.mts +1 -1
  51. package/dist/idempotency/index.d.mts +4 -3
  52. package/dist/idempotency/index.mjs +1 -1
  53. package/dist/idempotency/redis.d.mts +1 -1
  54. package/dist/{index-CP6A1Ffj.d.mts → index-B5N-NaHs.d.mts} +88 -7
  55. package/dist/{index-CRKiiP5o.d.mts → index-B5PoNbfu.d.mts} +41 -7
  56. package/dist/{index-CUTG2Cuo.d.mts → index-Dtrj6XZk.d.mts} +90 -29
  57. package/dist/index.d.mts +7 -7
  58. package/dist/index.mjs +7 -7
  59. package/dist/{init-Bqpt1Hxf.mjs → init-0YhHkXGk.mjs} +239 -140
  60. package/dist/integrations/event-gateway.d.mts +2 -3
  61. package/dist/integrations/index.d.mts +2 -2
  62. package/dist/integrations/jobs.d.mts +0 -1
  63. package/dist/integrations/mcp/index.d.mts +6 -7
  64. package/dist/integrations/mcp/index.mjs +1 -1
  65. package/dist/integrations/mcp/testing.d.mts +1 -2
  66. package/dist/integrations/mcp/testing.mjs +1 -1
  67. package/dist/integrations/streamline.d.mts +19 -7
  68. package/dist/integrations/streamline.mjs +9 -5
  69. package/dist/integrations/webhooks.d.mts +0 -1
  70. package/dist/integrations/websocket-pushref-redis.d.mts +1 -2
  71. package/dist/integrations/websocket-redis.d.mts +1 -2
  72. package/dist/integrations/websocket.d.mts +2 -2
  73. package/dist/{loadResources-B8UmpITt.mjs → loadResources-DpU8pfjV.mjs} +4 -3
  74. package/dist/{loadResourcesFromEntry-BTlFEx05.mjs → loadResourcesFromEntry-OMCvIEE7.mjs} +1 -1
  75. package/dist/{mcpPlugin-C088Nd-G.mjs → mcpPlugin-B9XjjNVG.mjs} +148 -49
  76. package/dist/middleware/index.d.mts +1 -2
  77. package/dist/middleware/index.mjs +1 -1
  78. package/dist/module-WeuU5jfd.d.mts +429 -0
  79. package/dist/{openapi-DZ-5a5Op.mjs → openapi-Kyod_di6.mjs} +3 -3
  80. package/dist/permissions/index.d.mts +3 -3
  81. package/dist/permissions/index.mjs +2 -2
  82. package/dist/{permissions-BPB1xqGf.mjs → permissions-BJMuKMl7.mjs} +43 -3
  83. package/dist/pipeline/index.d.mts +1 -2
  84. package/dist/plugins/index.d.mts +87 -10
  85. package/dist/plugins/index.mjs +91 -4
  86. package/dist/plugins/response-cache.d.mts +5 -3
  87. package/dist/plugins/tracing-entry.d.mts +1 -1
  88. package/dist/plugins/tracing-entry.mjs +1 -1
  89. package/dist/{pluralize-DccFV9R7.mjs → pluralize-BhrgwZb9.mjs} +3 -3
  90. package/dist/presets/filesUpload.d.mts +4 -5
  91. package/dist/presets/filesUpload.mjs +1 -1
  92. package/dist/presets/index.d.mts +16 -3
  93. package/dist/presets/index.mjs +1 -1
  94. package/dist/presets/multiTenant.d.mts +1 -2
  95. package/dist/presets/multiTenant.mjs +1 -1
  96. package/dist/presets/search.d.mts +2 -3
  97. package/dist/presets/search.mjs +3 -2
  98. package/dist/{presets-BtyFwTO4.mjs → presets-gw0ciIUD.mjs} +1 -1
  99. package/dist/{pushref-registry-DnCqL3aa.d.mts → pushref-registry-BbcnpNTm.d.mts} +4 -2
  100. package/dist/{queryCachePlugin-xzFyKAjT.d.mts → queryCachePlugin-CfYWq-Ak.d.mts} +1 -2
  101. package/dist/{queryCachePlugin-emHb0CuX.mjs → queryCachePlugin-DzqLN904.mjs} +2 -2
  102. package/dist/{redis-C3W0qbz8.d.mts → redis-UDtLaNj6.d.mts} +0 -1
  103. package/dist/{redis-stream-DsP1vqZB.d.mts → redis-stream-iVR2l7aS.d.mts} +1 -2
  104. package/dist/{registerRoute-BdV8DDwU.mjs → registerRoute-oGAl0oA8.mjs} +1 -1
  105. package/dist/registry/index.d.mts +2 -2
  106. package/dist/registry/index.mjs +2 -2
  107. package/dist/{registry-Bm9LjWKs.mjs → registry-vcOCtPij.mjs} +1 -1
  108. package/dist/{schemaIR-D1MJwqmp.mjs → schemaIR-ajetw3Fv.mjs} +1 -42
  109. package/dist/schemas/index.d.mts +0 -1
  110. package/dist/scim/index.d.mts +0 -1
  111. package/dist/scim/index.mjs +2 -2
  112. package/dist/scope/index.d.mts +2 -3
  113. package/dist/{sessionManager-BXG-HGwG.d.mts → sessionManager-BHOM3N_0.d.mts} +10 -6
  114. package/dist/{storage-Co_PQpeQ.d.mts → storage-DLUZ04kK.d.mts} +8 -4
  115. package/dist/sync/changelog.d.mts +2 -0
  116. package/dist/sync/changelog.mjs +2 -0
  117. package/dist/testing/index.d.mts +2 -3
  118. package/dist/testing/index.mjs +6 -3
  119. package/dist/testing/storageContract.d.mts +1 -2
  120. package/dist/{tracing-D8jlGXP7.d.mts → tracing-CsGKQJGg.d.mts} +0 -1
  121. package/dist/types/index.d.mts +4 -4
  122. package/dist/types/storage.d.mts +1 -1
  123. package/dist/types-Cc91nXuA.d.mts +132 -0
  124. package/dist/{types-LNebCoJx.d.mts → types-DH0GupI7.d.mts} +8 -4
  125. package/dist/{types-DpvSF5Hh.d.mts → types-DRj-Ut4n.d.mts} +1 -2
  126. package/dist/{types-9_wtNPsI.d.mts → types-DpxoNX5v.d.mts} +112 -214
  127. package/dist/utils/index.d.mts +182 -128
  128. package/dist/utils/index.mjs +2 -2
  129. package/dist/{utils-C6QQ9-hd.mjs → utils-CO8a3B_1.mjs} +1435 -1391
  130. package/dist/{validate-CRcaWzp2.mjs → validate-C2mGgSXg.mjs} +3 -3
  131. package/dist/{versioning-D_eBCfNr.d.mts → versioning-DQ4mCvg0.d.mts} +1 -2
  132. package/dist/{websocket-MlYlrYq0.d.mts → websocket-uuWqM6UY.d.mts} +5 -4
  133. package/package.json +18 -13
  134. package/skills/arc/SKILL.md +10 -0
  135. package/skills/arc/references/mcp.md +2 -0
  136. package/skills/arc-module-publishing/SKILL.md +242 -0
  137. package/skills/arc-module-publishing/references/authoring-modules.md +272 -0
  138. package/skills/arc-module-publishing/references/package-manifest.md +155 -0
  139. package/skills/arc-module-publishing/references/publishing-workflow.md +182 -0
@@ -316,14 +316,14 @@ function validateTopNConfig(resource, aggregation, topN, groupBy, measures, buck
316
316
  if (!topN.sortBy || Object.keys(topN.sortBy).length === 0) throw new ArcAggregationConfigError(`Resource "${resource}" aggregation "${aggregation}" topN.sortBy must declare at least one ranking field.`);
317
317
  const partitionList = Array.isArray(topN.partitionBy) ? topN.partitionBy : [topN.partitionBy];
318
318
  if (partitionList.length === 0) throw new ArcAggregationConfigError(`Resource "${resource}" aggregation "${aggregation}" topN.partitionBy must declare at least one partition column.`);
319
- const validKeys = new Set([
319
+ const validKeys = /* @__PURE__ */ new Set([
320
320
  ...groupBy,
321
321
  ...bucketAliases,
322
322
  ...Object.keys(measures)
323
323
  ]);
324
324
  for (const key of partitionList) if (!validKeys.has(key)) throw new ArcAggregationConfigError(`Resource "${resource}" aggregation "${aggregation}" topN.partitionBy "${key}" is not a groupBy field, dateBucket alias, or measure alias. Available: ${[...validKeys].join(", ") || "(none — declare groupBy, dateBuckets, or measures)"}.`);
325
325
  }
326
- const VALID_BUCKET_UNITS = new Set([
326
+ const VALID_BUCKET_UNITS = /* @__PURE__ */ new Set([
327
327
  "minute",
328
328
  "hour",
329
329
  "day",
@@ -332,7 +332,7 @@ const VALID_BUCKET_UNITS = new Set([
332
332
  "quarter",
333
333
  "year"
334
334
  ]);
335
- const CUSTOM_BIN_UNIT_BLOCKLIST = new Set(["quarter", "year"]);
335
+ const CUSTOM_BIN_UNIT_BLOCKLIST = /* @__PURE__ */ new Set(["quarter", "year"]);
336
336
  /**
337
337
  * Validate `dateBuckets`. Catches the two classes of misconfig kits
338
338
  * already throw on at runtime — alias collisions and field-rule
@@ -1,6 +1,5 @@
1
- import { n as DomainEvent } from "./EventTransport-IEn7Tn-Q.mjs";
1
+ import { n as DomainEvent } from "./EventTransport-QmY7A258.mjs";
2
2
  import { FastifyPluginAsync, FastifyRequest } from "fastify";
3
-
4
3
  //#region src/plugins/health.d.ts
5
4
  declare module "fastify" {
6
5
  interface FastifyRequest {
@@ -1,6 +1,5 @@
1
- import { a as PushRefRegistry, l as EnvelopeWriter, s as PushRefStore } from "./pushref-registry-DnCqL3aa.mjs";
1
+ import { a as PushRefRegistry, l as EnvelopeWriter, s as PushRefStore } from "./pushref-registry-BbcnpNTm.mjs";
2
2
  import { FastifyBaseLogger, FastifyPluginAsync } from "fastify";
3
-
4
3
  //#region src/integrations/websocket/adapter.d.ts
5
4
  /**
6
5
  * WebSocket cross-instance adapter contract.
@@ -135,8 +134,10 @@ interface WebSocketClient {
135
134
  send(data: string): void;
136
135
  close(code?: number, reason?: string): void;
137
136
  readyState: number;
138
- bufferedAmount?: number; /** RFC 6455 §5.5.2 ping control frame. Browsers auto-respond at the protocol layer. */
139
- ping?(data?: unknown): void; /** Immediate socket destroy — no Close frame, no flush. Use for zombies. */
137
+ bufferedAmount?: number;
138
+ /** RFC 6455 §5.5.2 ping control frame. Browsers auto-respond at the protocol layer. */
139
+ ping?(data?: unknown): void;
140
+ /** Immediate socket destroy — no Close frame, no flush. Use for zombies. */
140
141
  terminate?(): void;
141
142
  };
142
143
  subscriptions: Set<string>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/arc",
3
- "version": "2.19.0",
3
+ "version": "2.21.0",
4
4
  "description": "Resource-oriented backend framework for Fastify - clean, minimal, powerful, tree-shakable",
5
5
  "type": "module",
6
6
  "exports": {
@@ -203,6 +203,10 @@
203
203
  "./auth/audit": {
204
204
  "types": "./dist/auth/audit.d.mts",
205
205
  "default": "./dist/auth/audit.mjs"
206
+ },
207
+ "./sync": {
208
+ "types": "./dist/sync/changelog.d.mts",
209
+ "default": "./dist/sync/changelog.mjs"
206
210
  }
207
211
  },
208
212
  "main": "./dist/index.mjs",
@@ -227,8 +231,8 @@
227
231
  "lint:fix": "biome check --fix src/",
228
232
  "lint:all": "biome check src/ tests/",
229
233
  "test": "vitest run",
230
- "test:main": "vitest run --no-file-parallelism",
231
- "test:fast": "vitest run --exclude \"**/tests/integrations/websocket/**\" && vitest run tests/integrations/websocket/ --no-file-parallelism",
234
+ "test:main": "vitest run",
235
+ "test:main:serial": "vitest run --no-file-parallelism",
232
236
  "test:perf": "node --expose-gc ./node_modules/vitest/vitest.mjs run --config vitest.perf.config.ts --no-file-parallelism",
233
237
  "test:ci": "npm run test:main && npm run test:perf",
234
238
  "test:watch": "vitest",
@@ -242,15 +246,16 @@
242
246
  "push": "classytic-push",
243
247
  "release:tag": "node -e \"require('child_process').execSync('npm run push -- v'+require('./package.json').version,{stdio:'inherit'})\"",
244
248
  "release": "npm run push -- main && npm run release:tag && npm publish",
245
- "prepublishOnly": "npm run typecheck && npm run lint && npm run build && npm run test:ci && npm run smoke"
249
+ "check:peer-skew": "node scripts/check-peer-skew.mjs",
250
+ "prepublishOnly": "npm run check:peer-skew && npm run typecheck && npm run lint && npm run build && npm run test:ci && npm run smoke"
246
251
  },
247
252
  "engines": {
248
253
  "node": ">=22"
249
254
  },
250
255
  "peerDependencies": {
251
- "@classytic/primitives": ">=0.6.0",
252
- "@classytic/repo-core": ">=0.5.0",
253
- "@classytic/streamline": ">=2.3.3",
256
+ "@classytic/primitives": ">=0.9.0",
257
+ "@classytic/repo-core": ">=0.7.0",
258
+ "@classytic/streamline": ">=2.7.0",
254
259
  "@fastify/cors": ">=11.0.0",
255
260
  "@fastify/helmet": ">=13.0.0",
256
261
  "@fastify/jwt": ">=10.0.0",
@@ -368,11 +373,11 @@
368
373
  "@better-auth/mongo-adapter": "^1.6.19",
369
374
  "@biomejs/biome": "^2.5.0",
370
375
  "@classytic/dev-tools": "^0.2.0",
371
- "@classytic/mongokit": "^3.16.1",
372
- "@classytic/primitives": "^0.7.2",
373
- "@classytic/repo-core": "^0.6.0",
376
+ "@classytic/mongokit": "^3.21.0",
377
+ "@classytic/primitives": "^0.9.1",
378
+ "@classytic/repo-core": "^0.9.0",
374
379
  "@classytic/sqlitekit": "^0.6.0",
375
- "@classytic/streamline": "^2.6.0",
380
+ "@classytic/streamline": "^2.7.0",
376
381
  "@fastify/cors": "^11.2.0",
377
382
  "@fastify/helmet": "^13.0.2",
378
383
  "@fastify/jwt": "^10.1.0",
@@ -405,8 +410,8 @@
405
410
  "mongodb": "^7.3.0",
406
411
  "mongodb-memory-server": "^11.2.0",
407
412
  "mongoose": "^9.7.1",
408
- "tsdown": "^0.22.3",
409
- "typescript": "^6.0.3",
413
+ "tsdown": "^0.22.5",
414
+ "typescript": "^7.0.2",
410
415
  "vitest": "^4.1.9",
411
416
  "ws": "^8.21.0",
412
417
  "zod": "^4.4.3"
@@ -706,6 +706,16 @@ routes: [
706
706
  { method: 'GET', path: '/stream', raw: true, handler: async (req, reply) => reply.send(stream) },
707
707
  ]
708
708
 
709
+ // Per-route rate limit (2.20) — overrides the resource/app default for ONE endpoint.
710
+ // `false` = never throttle, object = tighten, omit = inherit. Custom routes only;
711
+ // actions share one POST /:id/action mount, so throttle an action by making it a route.
712
+ routes: [
713
+ { method: 'POST', path: '/export', handler: 'export', permissions: requireAuth(),
714
+ rateLimit: { max: 5, timeWindow: '1 minute' } },
715
+ { method: 'GET', path: '/health', handler: 'health', permissions: allowPublic(),
716
+ rateLimit: false },
717
+ ]
718
+
709
719
  // Per-resource opt-out of resourcePrefix (webhooks / admin routes)
710
720
  defineResource({ name: 'webhook', prefix: '/hooks', skipGlobalPrefix: true });
711
721
 
@@ -50,6 +50,8 @@ Resource `actions` (v2.8) also auto-generate MCP tools — each action becomes a
50
50
 
51
51
  Tool handlers call `BaseController` — same pipeline as REST (auth, org-scoping, hooks, field permissions, cache).
52
52
 
53
+ **Query params on generated tools (2.20):** `list_*` accepts `page`/`limit` (offset) **and** `cursor` (keyset, stable under concurrent inserts — when set, `page` is ignored), plus `sort`/`search`/`select`/`populate`. `get_*` accepts `select` for field projection alongside `id`. Both mirror REST exactly (same `QueryResolver`).
54
+
53
55
  ### McpPluginOptions
54
56
 
55
57
  | Option | Type | Default | Description |
@@ -0,0 +1,242 @@
1
+ ---
2
+ name: arc-module-publishing
3
+ description: |
4
+ Author and publish an ecosystem package for @classytic/arc the way arc's own packages
5
+ are built — modules, action presets, and plugins that hosts install and compose.
6
+ Codifies the non-negotiable conventions: no hardcoded dependencies (host duty), peers
7
+ as `>=` ranges (stable API contract), bundle-nothing tsdown, public-subpath imports only,
8
+ one-way dependency (arc never depends on you), and the classytic release gate.
9
+ Use when creating a new @classytic/arc-<domain> package, extracting a domain out of an
10
+ app into a reusable module, deciding a package's package.json / tsdown / peer setup,
11
+ writing a defineModule() or an action preset, or preparing an arc module for npm publish.
12
+ Triggers: publish arc module, arc package, arc ecosystem, defineModule, ArcModule, arc
13
+ plugin package, arc action preset, extract domain to package, arc-approval, arc-involvement,
14
+ peer dependencies arc, tsdown arc package, arc module conventions, classytic publish.
15
+ license: MIT
16
+ metadata:
17
+ author: Classytic
18
+ tags:
19
+ - arc
20
+ - packaging
21
+ - publishing
22
+ - modules
23
+ - ecosystem
24
+ - tsdown
25
+ - peer-dependencies
26
+ - monorepo
27
+ ---
28
+
29
+ # Publishing @classytic/arc ecosystem packages
30
+
31
+ How to author and ship a reusable package **on top of** arc — a domain module, an action
32
+ preset, or a plugin — so it composes cleanly into any host and follows the same discipline
33
+ as arc's own first-party packages (`@classytic/arc-approval`, `@classytic/arc-involvement`,
34
+ `@classytic/arc-notifications`).
35
+
36
+ **Node ≥22 · ESM only · TypeScript strict · tsdown build · Biome lint.**
37
+
38
+ ---
39
+
40
+ ## The one rule everything else follows
41
+
42
+ > **The package declares a contract; the host owns the installation.**
43
+
44
+ A published arc package is a *contribution* to someone else's app, not an app. That single
45
+ idea produces every convention below: you never bundle what the host provides, you never
46
+ pin what the host must choose, you never reach past arc's public surface, and you never
47
+ make arc depend on you.
48
+
49
+ ---
50
+
51
+ ## Decision: which shape are you shipping?
52
+
53
+ Three shapes, smallest surface first. Pick the smallest that does the job.
54
+
55
+ | Shape | You export | Host uses it via | Example |
56
+ |---|---|---|---|
57
+ | **Action preset** | a function returning an `actions:` slice | spread into `defineResource({ actions })` | `withApprovalChain()` (arc-approval) |
58
+ | **Plugin** | an `fp()`-wrapped Fastify plugin (+ named export) | `app.register()` / `plugins:` slot | any cross-cutting decorator/hook |
59
+ | **Module** | `createXModule(deps) → ArcModule` | `createApp({ modules: [...] })` | a whole domain (engine + resources + wiring) |
60
+
61
+ - A preset adds **verbs to one resource**. No lifecycle, no engine — just a function that
62
+ returns `Record<string, ActionDefinition>`. Storage-agnostic via a **structural repository
63
+ port** (an interface with the two or three methods you actually call), never a kit import.
64
+ - A module owns a **whole domain**: it initializes an engine in `bootstrap`, registers its
65
+ resources, wires `afterResources`, and tears down in `onClose`. Use `defineModule` and let
66
+ the host list it in `modules:`. See [references/authoring-modules.md](references/authoring-modules.md).
67
+
68
+ When unsure, ship a **preset or plugin**. A module is the right call only when there is
69
+ engine state to boot and multiple resources to register as a unit.
70
+
71
+ ---
72
+
73
+ ## Dependency discipline (the part reviewers keep catching)
74
+
75
+ This is where packages go wrong. Get the `package.json` dependency fields exactly right:
76
+
77
+ - **`dependencies`: usually empty.** Everything arc-shaped is a *peer*. Only add a real
78
+ `dependency` for a genuinely private, bundled-in helper the host should never see or
79
+ control — which is rare. If you're tempted to add `@classytic/*` or `fastify` here, stop:
80
+ that's a peer.
81
+ - **`peerDependencies`: `>=` ranges, never `^`.** A peer is a *contract*: "I work against
82
+ arc 2.20 and up." `^2.20.0` would wrongly forbid a host on arc 3.x that still satisfies the
83
+ API you use. Use `">=2.20.0"`. This keeps the API contract stable and lets hosts upgrade arc
84
+ without your package blocking them.
85
+ - **`devDependencies`: pin with `^`.** These are the versions your *test suite* runs against —
86
+ a concrete, current version (`"@classytic/arc": "^2.20.0"`). The floor of every devDep must
87
+ be `>=` the peer floor, or your tests never exercise the version you claim to support.
88
+ - **Optional peers** (zod, ioredis, a kit) go in `peerDependencies` **and**
89
+ `peerDependenciesMeta: { "<name>": { optional: true } }`. Never let their types leak into
90
+ your public `.d.mts` — use structural typing so a host that doesn't install them still
91
+ compiles. See [references/package-manifest.md](references/package-manifest.md).
92
+
93
+ **Why `>=` and not `^` for peers:** the host, not you, resolves the actual installed version.
94
+ Your job is to state the *floor* of the API you depend on and stay out of the host's upgrade
95
+ path. A caret peer turns every host major-bump into a forced wait on your release.
96
+
97
+ ---
98
+
99
+ ## Import discipline: public surface only
100
+
101
+ Import arc through its **published subpaths**, exactly as a third-party host would. Never
102
+ reach into `@classytic/arc/src/...` or a deep `dist/` path.
103
+
104
+ ```ts
105
+ import type { PermissionCheck } from "@classytic/arc/permissions";
106
+ import { getOrgId, getUserId, requireOrgId } from "@classytic/arc/scope";
107
+ import type { ActionDefinition, RequestWithExtras } from "@classytic/arc/types";
108
+ import { createDomainError, NotFoundError, ValidationError } from "@classytic/arc/utils";
109
+ ```
110
+
111
+ The canonical entry points: `@classytic/arc` (core), `/permissions`, `/scope`, `/types`,
112
+ `/utils`, `/factory` (for `defineModule`, `getModuleExports`), `/testing`. If something you
113
+ need isn't on a public subpath, that's a signal — file it against arc rather than deep-import
114
+ around it. Deep imports break the moment arc reorganizes internals, which it's free to do
115
+ because they were never public.
116
+
117
+ **One-way dependency:** your package depends on arc; **arc never depends on your package.**
118
+ If arc would need something from you, the abstraction belongs *in arc* (a port, a hook, a
119
+ preset seam), not as a back-edge. This keeps the dependency graph a DAG and arc publishable
120
+ on its own.
121
+
122
+ ---
123
+
124
+ ## Build: bundle nothing
125
+
126
+ tsdown, ESM only, and **bundle nothing that's a peer**. Every `@classytic/*` and `fastify`
127
+ stays external so the host's single installed copy is the one that runs (two copies of arc =
128
+ two plugin registries = subtle breakage).
129
+
130
+ ```ts
131
+ // tsdown.config.ts
132
+ import { defineConfig } from "tsdown";
133
+
134
+ export default defineConfig({
135
+ entry: ["src/index.ts"],
136
+ format: ["esm"],
137
+ dts: true,
138
+ sourcemap: false,
139
+ clean: true,
140
+ treeshake: true,
141
+ target: "node22",
142
+ outDir: "dist",
143
+ deps: {
144
+ skipNodeModulesBundle: true, // don't inline node_modules
145
+ neverBundle: [/^@classytic\//], // arc + primitives + kits stay external, always
146
+ },
147
+ });
148
+ ```
149
+
150
+ `package.json` build wiring: `sideEffects: false`, `files: ["dist"]`, an `exports` map with
151
+ `types` + `default`, `type: "module"`, `engines.node: ">=22"`, `publishConfig.access:
152
+ "public"`. Full template in [references/package-manifest.md](references/package-manifest.md).
153
+
154
+ ---
155
+
156
+ ## Bridge onto arc's primitives — don't reinvent them
157
+
158
+ A hard-won lesson: when a host already gets a capability from arc, **bridge onto it** instead
159
+ of shipping a second path.
160
+
161
+ - Need background work? Use `fastify.jobs` (arc's BullMQ bridge) — don't add your own BullMQ
162
+ dependency and stand up a parallel queue. Two job systems in one app is a footgun, not a
163
+ feature.
164
+ - Need guaranteed event delivery? Use arc's **outbox** and the `@classytic/primitives` event
165
+ contract (`createEvent`, `DomainEvent`, `EventTransport`) — don't invent an event shape.
166
+ - Need permissions/scope? Use `@classytic/arc/permissions` + `@classytic/arc/scope`
167
+ accessors (`getOrgId`, `requireUserId`) — don't re-derive tenant/user from `request.user`.
168
+
169
+ The test: *"Does arc already bless a primitive for this?"* If yes, your package composes onto
170
+ it. Shipping a competing path breaks the host's single-source-of-truth and doubles their ops
171
+ surface. Reinvent only when arc genuinely has no primitive — and then consider whether it
172
+ should live in arc.
173
+
174
+ ---
175
+
176
+ ## Naming & versioning
177
+
178
+ - **Package name:** `@classytic/arc-<domain>` for first-party; third parties use their own
179
+ scope (`@acme/arc-billing`). The `arc-` prefix signals "arc ecosystem package."
180
+ - **Module `name`** (the `defineModule({ name })` identifier): namespace it so it can't
181
+ collide in a host that composes many modules — `"accounting"`, `"order"`, or a
182
+ vendor-qualified `"acme.billing"`. This is the key hosts use in `dependsOn` and
183
+ `getModuleExports`, and arc rejects duplicate module names at boot.
184
+ - **SemVer the public export**, not the internals. Your exported function signatures, the
185
+ `ArcModule` shape you return, and the action/schema surface are the contract. Bumping a peer
186
+ floor (e.g. requiring a newer arc) is at least a **minor**; changing an exported signature is
187
+ a **major**.
188
+
189
+ ---
190
+
191
+ ## Release gate
192
+
193
+ Every publish runs a gate — no `--no-verify`, ever. In a workspace, a shared script; in a
194
+ solo package, `prepublishOnly`:
195
+
196
+ ```jsonc
197
+ "scripts": {
198
+ "build": "tsdown",
199
+ "typecheck": "tsc --noEmit",
200
+ "lint": "biome check src/",
201
+ "prepublishOnly": "npm run typecheck && npm run lint && npm run test && npm run build"
202
+ }
203
+ ```
204
+
205
+ For a **monorepo of packages**, add a **peer-skew gate** that fails if any package's devDep
206
+ floor is below its declared peer floor — it's the mechanical guard that stops your suite from
207
+ silently testing against a version older than you promise to support. Full script and workspace
208
+ layout in [references/publishing-workflow.md](references/publishing-workflow.md).
209
+
210
+ **classytic packages** additionally: commit as `classytic-bot[bot]` (per-invocation git env
211
+ vars, never `git config --global`, no `Co-Authored-By`), push via `classytic-push`, publish to
212
+ public npm. See the workspace `CLAUDE.md` and [references/publishing-workflow.md](references/publishing-workflow.md).
213
+
214
+ ---
215
+
216
+ ## Pre-publish checklist
217
+
218
+ - [ ] `dependencies` is empty (or only truly-private bundled helpers)
219
+ - [ ] every arc/fastify/kit dep is a `peerDependency` with a `>=` range
220
+ - [ ] every peer floor is covered by a `^`-pinned devDep at or above it (peer-skew clean)
221
+ - [ ] optional peers are marked `optional: true` and their types don't leak into `dist/*.d.mts`
222
+ - [ ] tsdown `neverBundle: [/^@classytic\//]` + `skipNodeModulesBundle: true`
223
+ - [ ] imports use public subpaths only — no `@classytic/arc/src` or deep `dist` paths
224
+ - [ ] arc has **no** dependency on this package (grep arc for the package name → nothing)
225
+ - [ ] `sideEffects: false`, `files: ["dist"]`, `exports` map with `types`+`default`, `type: "module"`
226
+ - [ ] action schemas shipped as **JSON Schema** (zod stays an optional peer, never required)
227
+ - [ ] `prepublishOnly` gate green (typecheck + lint + test + build), no `--no-verify`
228
+ - [ ] module `name` is namespaced and can't collide across a multi-module host
229
+ - [ ] boots green in a **kit-backed testkit** (real in-memory store, prod-fidelity — not a fake repo)
230
+ - [ ] `.npmrc`: `auto-install-peers=false` + an install-script allowlist for any dep with a `postinstall`
231
+
232
+ ---
233
+
234
+ ## References
235
+
236
+ - [references/package-manifest.md](references/package-manifest.md) — copy-paste `package.json`
237
+ + `tsdown.config.ts`, optional-peer structural-typing pattern, the dependency-field cheat sheet.
238
+ - [references/authoring-modules.md](references/authoring-modules.md) — `defineModule` lifecycle,
239
+ `dependsOn` composition order, `getModuleExports` cross-module wiring, thunk lazy/region packs,
240
+ action-preset and structural-port patterns.
241
+ - [references/publishing-workflow.md](references/publishing-workflow.md) — monorepo layout, the
242
+ peer-skew + prepublish gates, classytic bot-identity release steps, versioning a public export.