@smthrs/plugin 0.0.0-stage → 1.0.0-rc.4

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 (118) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +86 -2
  4. package/dist/cjs/Config.d.ts +129 -0
  5. package/dist/cjs/Config.d.ts.map +1 -0
  6. package/dist/cjs/Config.js +115 -0
  7. package/dist/cjs/Config.js.map +7 -0
  8. package/dist/cjs/Hooks.d.ts +176 -0
  9. package/dist/cjs/Hooks.d.ts.map +1 -0
  10. package/dist/cjs/Hooks.js +34 -0
  11. package/dist/cjs/Hooks.js.map +7 -0
  12. package/dist/cjs/Kernel.d.ts +81 -0
  13. package/dist/cjs/Kernel.d.ts.map +1 -0
  14. package/dist/cjs/Kernel.js +57 -0
  15. package/dist/cjs/Kernel.js.map +7 -0
  16. package/dist/cjs/Plugin.d.ts +71 -0
  17. package/dist/cjs/Plugin.d.ts.map +1 -0
  18. package/dist/cjs/Plugin.js +25 -0
  19. package/dist/cjs/Plugin.js.map +7 -0
  20. package/dist/cjs/PluginError.d.ts +50 -0
  21. package/dist/cjs/PluginError.d.ts.map +1 -0
  22. package/dist/cjs/PluginError.js +58 -0
  23. package/dist/cjs/PluginError.js.map +7 -0
  24. package/dist/cjs/Plugins.d.ts +97 -0
  25. package/dist/cjs/Plugins.d.ts.map +1 -0
  26. package/dist/cjs/Plugins.js +160 -0
  27. package/dist/cjs/Plugins.js.map +7 -0
  28. package/dist/cjs/Resolve.d.ts +128 -0
  29. package/dist/cjs/Resolve.d.ts.map +1 -0
  30. package/dist/cjs/Resolve.js +343 -0
  31. package/dist/cjs/Resolve.js.map +7 -0
  32. package/dist/cjs/index.d.ts +83 -0
  33. package/dist/cjs/index.d.ts.map +1 -0
  34. package/dist/cjs/index.js +45 -0
  35. package/dist/cjs/index.js.map +7 -0
  36. package/dist/cjs/internal/Boundary.d.ts +96 -0
  37. package/dist/cjs/internal/Boundary.d.ts.map +1 -0
  38. package/dist/cjs/internal/Boundary.js +129 -0
  39. package/dist/cjs/internal/Boundary.js.map +7 -0
  40. package/dist/cjs/internal/mergePluginLayers.d.ts +21 -0
  41. package/dist/cjs/internal/mergePluginLayers.d.ts.map +1 -0
  42. package/dist/cjs/internal/mergePluginLayers.js +57 -0
  43. package/dist/cjs/internal/mergePluginLayers.js.map +7 -0
  44. package/dist/cjs/internal/snapshotCacheEnvironment.d.ts +19 -0
  45. package/dist/cjs/internal/snapshotCacheEnvironment.d.ts.map +1 -0
  46. package/dist/cjs/internal/snapshotCacheEnvironment.js +92 -0
  47. package/dist/cjs/internal/snapshotCacheEnvironment.js.map +7 -0
  48. package/dist/cjs/package.json +1 -0
  49. package/dist/esm/Config.d.ts +129 -0
  50. package/dist/esm/Config.d.ts.map +1 -0
  51. package/dist/esm/Config.js +160 -0
  52. package/dist/esm/Config.js.map +1 -0
  53. package/dist/esm/Hooks.d.ts +176 -0
  54. package/dist/esm/Hooks.d.ts.map +1 -0
  55. package/dist/esm/Hooks.js +56 -0
  56. package/dist/esm/Hooks.js.map +1 -0
  57. package/dist/esm/Kernel.d.ts +81 -0
  58. package/dist/esm/Kernel.d.ts.map +1 -0
  59. package/dist/esm/Kernel.js +58 -0
  60. package/dist/esm/Kernel.js.map +1 -0
  61. package/dist/esm/Plugin.d.ts +71 -0
  62. package/dist/esm/Plugin.d.ts.map +1 -0
  63. package/dist/esm/Plugin.js +20 -0
  64. package/dist/esm/Plugin.js.map +1 -0
  65. package/dist/esm/PluginError.d.ts +50 -0
  66. package/dist/esm/PluginError.d.ts.map +1 -0
  67. package/dist/esm/PluginError.js +53 -0
  68. package/dist/esm/PluginError.js.map +1 -0
  69. package/dist/esm/Plugins.d.ts +97 -0
  70. package/dist/esm/Plugins.d.ts.map +1 -0
  71. package/dist/esm/Plugins.js +178 -0
  72. package/dist/esm/Plugins.js.map +1 -0
  73. package/dist/esm/Resolve.d.ts +128 -0
  74. package/dist/esm/Resolve.d.ts.map +1 -0
  75. package/dist/esm/Resolve.js +367 -0
  76. package/dist/esm/Resolve.js.map +1 -0
  77. package/dist/esm/index.d.ts +83 -0
  78. package/dist/esm/index.d.ts.map +1 -0
  79. package/dist/esm/index.js +48 -0
  80. package/dist/esm/index.js.map +1 -0
  81. package/dist/esm/internal/Boundary.d.ts +96 -0
  82. package/dist/esm/internal/Boundary.d.ts.map +1 -0
  83. package/dist/esm/internal/Boundary.js +162 -0
  84. package/dist/esm/internal/Boundary.js.map +1 -0
  85. package/dist/esm/internal/mergePluginLayers.d.ts +21 -0
  86. package/dist/esm/internal/mergePluginLayers.d.ts.map +1 -0
  87. package/dist/esm/internal/mergePluginLayers.js +41 -0
  88. package/dist/esm/internal/mergePluginLayers.js.map +1 -0
  89. package/dist/esm/internal/snapshotCacheEnvironment.d.ts +19 -0
  90. package/dist/esm/internal/snapshotCacheEnvironment.d.ts.map +1 -0
  91. package/dist/esm/internal/snapshotCacheEnvironment.js +63 -0
  92. package/dist/esm/internal/snapshotCacheEnvironment.js.map +1 -0
  93. package/docs/README.md +131 -0
  94. package/docs/api.md +522 -0
  95. package/docs/concepts/configuration.md +111 -0
  96. package/docs/concepts/hook-kinds.md +107 -0
  97. package/docs/concepts/resolution.md +132 -0
  98. package/docs/guides/cache-identity.md +106 -0
  99. package/docs/guides/contribute-services.md +84 -0
  100. package/docs/guides/host-your-own-hooks.md +129 -0
  101. package/docs/guides/order-handlers.md +76 -0
  102. package/docs/guides/select-plugins.md +72 -0
  103. package/docs/guides/testing.md +149 -0
  104. package/docs/installation.md +71 -0
  105. package/docs/quickstart.md +151 -0
  106. package/docs/troubleshooting.md +179 -0
  107. package/package.json +179 -3
  108. package/src/Config.ts +207 -0
  109. package/src/Hooks.ts +212 -0
  110. package/src/Kernel.ts +119 -0
  111. package/src/Plugin.ts +80 -0
  112. package/src/PluginError.ts +62 -0
  113. package/src/Plugins.ts +254 -0
  114. package/src/Resolve.ts +535 -0
  115. package/src/index.ts +91 -0
  116. package/src/internal/Boundary.ts +252 -0
  117. package/src/internal/mergePluginLayers.ts +57 -0
  118. package/src/internal/snapshotCacheEnvironment.ts +77 -0
package/docs/api.md ADDED
@@ -0,0 +1,522 @@
1
+ ---
2
+ title: "API reference"
3
+ description: "Every export of @smthrs/plugin: the plugin and hook declarations, the Kernel, Plugins, Resolve, and Config modules, the PluginError codes, and the published limits."
4
+ ---
5
+
6
+ Import the declarations from the root and the four stateful modules either as
7
+ namespaces from the root or from their own subpaths:
8
+
9
+ ```ts
10
+ import { engineHooks, type FlowsPlugin, make, PluginError } from "@smthrs/plugin"
11
+ import * as Config from "@smthrs/plugin/Config"
12
+ import * as Kernel from "@smthrs/plugin/Kernel"
13
+ import * as Plugins from "@smthrs/plugin/Plugins"
14
+ import * as Resolve from "@smthrs/plugin/Resolve"
15
+ ```
16
+
17
+ Every function that can refuse returns `Effect<A, PluginError>`, except
18
+ `Config.merge` and `Config.deepFreeze`, which are synchronous and throw a
19
+ `PluginError`.
20
+
21
+ ## Example
22
+
23
+ ```ts
24
+ import { Kernel, make } from "@smthrs/plugin"
25
+
26
+ const editor = make({
27
+ name: "flows-plugin-editor",
28
+ hooks: { config: () => Effect.succeed({ editor: { readOnly: false } }) }
29
+ })
30
+ const kernel = await Effect.runPromise(Kernel.make([editor], {}, { target: "harness" }))
31
+ console.log(kernel.config) // { editor: { readOnly: false } }
32
+ ```
33
+
34
+ ## Root exports
35
+
36
+ | Export | Kind | Summary |
37
+ | ------------------------------------------------------------------------ | ------------------ | --------------------------------------------------------------------- |
38
+ | `FlowsHooks` | interface | The augmentable hook catalog. Declares `config` and `configResolved`. |
39
+ | `Apply`, `FlowsPlugin`, `PluginInput`, `make` | from `Plugin` | The plugin record and its constructor. |
40
+ | Hook kinds and type-level helpers, `engineHooks`, `handlerOf`, `orderOf` | from `Hooks` | The hook surface. |
41
+ | `PluginErrorCode`, `PluginError` | from `PluginError` | The single typed failure. |
42
+ | `Config`, `Kernel`, `Plugins`, `Resolve` | namespaces | The four modules. |
43
+
44
+ ### FlowsHooks
45
+
46
+ ```ts
47
+ interface FlowsHooks {
48
+ readonly config: WaterfallHook<(config: FlowsConfig) => Effect.Effect<Partial<FlowsConfig> | void, any, never>>
49
+ readonly configResolved: ParallelHook<(config: ResolvedConfig) => Effect.Effect<void, any, never>>
50
+ }
51
+ ```
52
+
53
+ Declared in the entry point so that `declare module "@smthrs/plugin"` can
54
+ augment it. Open for augmentation, closed for dispatch: the kernel dispatches
55
+ only the config lifecycle, and a host supplies and dispatches its own catalog
56
+ over the same augmented interface.
57
+
58
+ Both startup hooks are context-free. `Kernel.make` runs them before any plugin
59
+ layer is built and supplies no services of its own, so no externally supplied
60
+ service is available during startup. A handler that needs one provides it
61
+ inside the hook with `Effect.provide`. A plugin literal whose `config` hook
62
+ requires a service fails to compile against `FlowsHooks`; a host whose startup
63
+ hooks do require services declares a separate hook interface, and
64
+ `Kernel.make<H>` then carries the requirement as `Kernel.StartupContext<H>`.
65
+
66
+ ## Plugin
67
+
68
+ ```ts
69
+ type Apply = "engine" | "harness" | ((config: FlowsConfig) => boolean)
70
+
71
+ interface FlowsPlugin<H = FlowsHooks> {
72
+ readonly name: string
73
+ readonly version?: string | undefined
74
+ readonly enforce?: "pre" | "post" | undefined
75
+ readonly apply?: Apply | undefined
76
+ readonly layer?: Layer.Layer<never, any, any> | undefined
77
+ readonly hooks?: Partial<H> | undefined
78
+ }
79
+
80
+ type PluginInput<H = FlowsHooks> =
81
+ | FlowsPlugin<H>
82
+ | false
83
+ | null
84
+ | undefined
85
+ | ReadonlyArray<PluginInput<H>>
86
+
87
+ const make: <H = FlowsHooks>(plugin: FlowsPlugin<H>) => FlowsPlugin<H>
88
+ ```
89
+
90
+ | Field | Meaning |
91
+ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
92
+ | `name` | Required and unique among selected plugins. Compared as an exact Unicode string, at most 256 UTF-16 code units, never empty or whitespace only. Convention: `flows-plugin-<thing>`. |
93
+ | `version` | Semantic identity folded into sealed cache keys. Required when the host declares a `cacheEnvironment`; refused as empty or malformed text. |
94
+ | `enforce` | Ordering group. Omitted means normal. |
95
+ | `apply` | Conditional inclusion. A literal names the host target; a predicate receives the pre-resolution configuration and returns a boolean. |
96
+ | `layer` | Services this plugin contributes to the composition. Must be an Effect `Layer`. |
97
+ | `hooks` | Typed hook entries. Only keys declared in `H` compile, and only names in the host's runtime catalog resolve. |
98
+
99
+ `make` is an identity function that pins a plugin literal to `FlowsPlugin<H>`,
100
+ so an excess or misspelled hook key fails at the definition site.
101
+ `PluginInput` accepts falsy entries and arrays nested up to
102
+ `maximumPluginDepth`, which is what makes a preset an ordinary function that
103
+ returns plugins.
104
+
105
+ ## Hooks
106
+
107
+ ```ts
108
+ type HookKind = "sequential" | "parallel" | "first" | "waterfall"
109
+
110
+ interface HookObject<F> {
111
+ readonly order?: "pre" | "post" | undefined
112
+ readonly handler: F
113
+ }
114
+
115
+ type HookEntry<K extends HookKind, F> = (F | HookObject<F>) & HookMeta<K, F>
116
+
117
+ type SequentialHook<F> = HookEntry<"sequential", F>
118
+ type ParallelHook<F> = HookEntry<"parallel", F>
119
+ type FirstHook<F> = HookEntry<"first", F>
120
+ type WaterfallHook<F> = HookEntry<"waterfall", F>
121
+ ```
122
+
123
+ `HookMeta<K, F>` is a phantom carrier: it records the kind and the handler type
124
+ in the type system and adds nothing at runtime. A hook entry is either the bare
125
+ handler or the `{ order, handler }` object; `undefined` and `null` are refused.
126
+
127
+ Type-level helpers, used by the dispatcher's signatures and available to hosts:
128
+
129
+ | Helper | Extracts |
130
+ | ------------------ | ---------------------------------------------------------------------------------------------------------------------- |
131
+ | `KindOf<T>` | The declared kind of a hook entry type. |
132
+ | `HandlerOf<T>` | The handler function type. |
133
+ | `KeysOfKind<H, K>` | The hook names of kind `K` in interface `H`. |
134
+ | `ArgsOf<T>` | The positional argument tuple the handler accepts. |
135
+ | `ReturnOf<T>` | The Effect the handler returns. |
136
+ | `SuccessOf<T>` | That Effect's success value. |
137
+ | `ContextOf<T>` | That Effect's required context. |
138
+ | `HookCatalog<H>` | The runtime catalog for `H`: a name declared in `H` must carry the kind its type declares; other names admit any kind. |
139
+
140
+ Values:
141
+
142
+ ```ts
143
+ const engineHooks: Readonly<{ readonly config: "waterfall"; readonly configResolved: "parallel" }>
144
+ const handlerOf: (entry: unknown) => (...args: Array<any>) => unknown
145
+ const orderOf: (entry: unknown) => "pre" | "post" | undefined
146
+ ```
147
+
148
+ `engineHooks` is the frozen runtime catalog for the shared configuration hooks,
149
+ and the catalog `Resolve.resolve` checks against when a host supplies none.
150
+ Spread it into your own catalog to keep the config lifecycle available.
151
+ `handlerOf` and `orderOf` normalize either hook entry form.
152
+
153
+ ## Kernel
154
+
155
+ ```ts
156
+ interface Kernel<H = FlowsHooks> {
157
+ readonly plugins: Plugins.Service<H>
158
+ readonly config: ResolvedConfig
159
+ readonly layer: Layer.Layer<any, PluginError, any>
160
+ readonly observerErrors: ReadonlyArray<PluginError>
161
+ }
162
+
163
+ type ConfigContext<H> = ContextOf<H["config"]>
164
+ type StartupContext<H> = ConfigContext<H> | ContextOf<H["configResolved"]>
165
+
166
+ const make: <H = FlowsHooks>(
167
+ input: PluginInput<NoInfer<H>>,
168
+ config?: FlowsConfig,
169
+ options?: Omit<Resolve.Options<NoInfer<H>>, "config">
170
+ ) => Effect.Effect<Kernel<H>, PluginError, StartupContext<H>>
171
+
172
+ const runConfig: <H = FlowsHooks>(
173
+ plugins: Plugins.Service<H>,
174
+ config: FlowsConfig
175
+ ) => Effect.Effect<ResolvedConfig, PluginError, ConfigContext<H>>
176
+ ```
177
+
178
+ `StartupContext<H>` is `never` for `FlowsHooks`, whose startup hooks are
179
+ context-free, so `Kernel.make([...])` runs with `Effect.runPromise` as is. For a
180
+ separate hook interface whose `config` or `configResolved` handlers require
181
+ services, the requirement is the startup Effect's context and the caller
182
+ provides it before running; the kernel never supplies startup services itself.
183
+
184
+ `make` performs startup in this order:
185
+
186
+ 1. Snapshot the positional `config`.
187
+ 2. Resolve the plugin list against `options`.
188
+ 3. Run the `config` waterfall, deep-merging each returned patch.
189
+ 4. Decode and freeze the result into a `ResolvedConfig`.
190
+ 5. Run `configResolved` observers in parallel, collecting their failures into
191
+ `observerErrors` rather than failing.
192
+ 6. Merge the selected plugins' layers, left to right in resolved order.
193
+
194
+ The positional `config` is the kernel's only pre-resolution configuration
195
+ source. `Options.config` is excluded from the options type so a caller cannot
196
+ declare a second one, and a `config` key smuggled past the type fails at
197
+ runtime with `invalid_plugin` at `$options.config`, the same refusal an
198
+ unknown option key gets. `runConfig` exposes step 3 and step 4 on their own, for a
199
+ host that resolved its plugin list separately. Before dispatching the first
200
+ hook, it validates and copies the supplied config into a detached, recursively
201
+ frozen snapshot. Invalid or accessor-bearing input fails with `config_invalid`
202
+ before any hook runs, without executing accessors. The final waterfall result
203
+ is admitted again through `Config.resolve`.
204
+
205
+ `observerErrors` is a return value, not a log: a `configResolved` failure never
206
+ fails startup, so a host that ignores the array cannot tell a working observer
207
+ from a broken one.
208
+
209
+ ## Plugins
210
+
211
+ ```ts
212
+ interface Service<H = FlowsHooks> {
213
+ readonly resolved: Resolved<H>
214
+ readonly handlers: (hook: string) => ReadonlyArray<HandlerRecord>
215
+
216
+ readonly sequential: <K extends KeysOfKind<H, "sequential">>(
217
+ hook: K,
218
+ ...args: ArgsOf<H[K]>
219
+ ) => Effect.Effect<ReadonlyArray<SuccessOf<H[K]>>, PluginError, ContextOf<H[K]>>
220
+
221
+ readonly parallel: <K extends KeysOfKind<H, "parallel">>(
222
+ hook: K,
223
+ ...args: ArgsOf<H[K]>
224
+ ) => Effect.Effect<ReadonlyArray<PluginError>, never, ContextOf<H[K]>>
225
+
226
+ readonly first: <K extends KeysOfKind<H, "first">>(
227
+ hook: K,
228
+ ...args: ArgsOf<H[K]>
229
+ ) => Effect.Effect<SuccessOf<H[K]>, PluginError, ContextOf<H[K]>>
230
+
231
+ readonly waterfall: <K extends KeysOfKind<H, "waterfall">>(
232
+ hook: K,
233
+ initial: ArgsOf<H[K]>[0],
234
+ merge: (previous: ArgsOf<H[K]>[0], patch: Exclude<SuccessOf<H[K]>, void>) => ArgsOf<H[K]>[0]
235
+ ) => Effect.Effect<ArgsOf<H[K]>[0], PluginError, ContextOf<H[K]>>
236
+ }
237
+
238
+ const make: <H = FlowsHooks>(resolved: Resolved<H>) => Service<H>
239
+ const makeNoop: <H = FlowsHooks>() => Service<H>
240
+
241
+ class Plugins extends Context.Service<Plugins, Service>()("flows/plugin/Plugins") {}
242
+ const layer: (resolved: Resolved) => Layer.Layer<Plugins>
243
+ const layerNoop: Layer.Layer<Plugins>
244
+ ```
245
+
246
+ Dispatch semantics, one row per kind:
247
+
248
+ | Method | Runs | Returns | On handler failure |
249
+ | ------------ | --------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
250
+ | `sequential` | Every handler, in resolved order, one at a time. | Each handler's success value, in resolved order. | Fails the caller with `hook_failed` and stops. |
251
+ | `parallel` | Every handler, bounded by `Resolved.parallelConcurrency`. | The failures, in resolved handler order. Success values are discarded. | Never fails the caller. |
252
+ | `first` | Handlers in order until one returns `Option.some`. | That `Option`, or `Option.none()`. | Fails with `hook_failed`; a non-`Option` value fails with `invalid_hook_result`. |
253
+ | `waterfall` | Every handler, threading the merged value. | The final value. | Fails with `hook_failed` and stops. |
254
+
255
+ Every method first compares its kind with the runtime catalog the resolution
256
+ admitted (`Resolved.kinds`). A hook the catalog declares with a different kind
257
+ is refused before any handler runs: `sequential`, `first`, and `waterfall` fail
258
+ with `hook_kind_mismatch`, and `parallel` returns that one error. A hook name
259
+ absent from the catalog dispatches nothing. The typed signatures make the
260
+ mismatch unreachable without a cast; the runtime check covers the cast.
261
+
262
+ Handlers must return an Effect. Synchronous throws, Effect failures and defects,
263
+ and non-Effect results (including `undefined`, scalars, and Promises) become
264
+ `hook_failed` with the plugin and hook names. Non-Effect error messages include
265
+ the result's JavaScript `typeof`. Parallel dispatch collects these errors and
266
+ continues running sibling observers, including with `parallelConcurrency: 1`.
267
+ `Kernel.make` succeeds and reports them in `observerErrors`. Sequential dispatch
268
+ fails with the attributed error and skips later handlers.
269
+
270
+ A waterfall handler whose Effect succeeds with `undefined` leaves the value
271
+ unchanged: the merge function is not called. A merge that throws a `PluginError` keeps that
272
+ error's code and path and gains the handler's attribution; a merge that throws
273
+ anything else becomes `config_invalid` attributed to the handler.
274
+
275
+ Cancellation is fiber interruption through scope closure. Nothing threads an
276
+ `AbortSignal`. Interrupting a dispatch interrupts the handler in flight, runs
277
+ its finalizers, and skips the handlers that had not started.
278
+
279
+ The `Plugins` service tag, `layer`, and `layerNoop` hold one dispatcher over the
280
+ process-wide augmented `FlowsHooks`. A host typed against a separate hook
281
+ interface holds its `Service<H>` directly and uses none of the three.
282
+
283
+ ## Resolve
284
+
285
+ ```ts
286
+ interface HandlerRecord {
287
+ readonly plugin: string
288
+ readonly hook: string
289
+ readonly handler: (...args: Array<any>) => unknown
290
+ }
291
+
292
+ interface Resolved<H = FlowsHooks> {
293
+ readonly plugins: ReadonlyArray<FlowsPlugin<H>>
294
+ readonly handlers: ReadonlyMap<string, ReadonlyArray<HandlerRecord>>
295
+ readonly kinds: Readonly<Record<string, HookKind>>
296
+ readonly parallelConcurrency: number
297
+ readonly cacheEnvironment?: Action.CacheEnvironment | undefined
298
+ }
299
+
300
+ interface Options<H = FlowsHooks> {
301
+ readonly config?: FlowsConfig | undefined
302
+ readonly target?: "engine" | "harness" | undefined
303
+ readonly hooks?: HookCatalog<H> | undefined
304
+ readonly cacheEnvironment?: Action.CacheEnvironment | undefined
305
+ readonly parallelConcurrency?: number | undefined
306
+ }
307
+
308
+ const resolve: <H = FlowsHooks>(
309
+ input: PluginInput<NoInfer<H>>,
310
+ options?: Options<NoInfer<H>>,
311
+ configOverride?: FlowsConfig
312
+ ) => Effect.Effect<Resolved<H>, PluginError>
313
+
314
+ const layer: <H>(resolved: Resolved<H>) => Layer.Layer<any, PluginError, any>
315
+ ```
316
+
317
+ | Option | Default | Meaning |
318
+ | --------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
319
+ | `config` | `{}` | Pre-resolution configuration tested by `apply` predicates. `Kernel.make` supplies it positionally and omits this field. Supplying both `config` and `configOverride` fails with `invalid_plugin` at `$options.config`. |
320
+ | `target` | `"engine"` | The host whose literal `apply` selectors are active. |
321
+ | `hooks` | `engineHooks` | The hook names and kinds this host recognizes, typed as `HookCatalog<H>`. The `unknown_hook` guard checks against it, the frozen copy is `Resolved.kinds`, and `config` or `configResolved` labelled with any kind but `waterfall` or `parallel` fails with `hook_kind_mismatch` at `$options.hooks.<name>`. |
322
+ | `cacheEnvironment` | absent | Complete composition identity for sealed activity keys. Requires a `version` on every selected plugin. |
323
+ | `parallelConcurrency` | `16` | Maximum observers run at once. A positive safe integer through 256. |
324
+
325
+ `resolve` is once-only and everything it returns is a copy the kernel owns.
326
+ Plugin records and hook objects are snapshotted, handler records and ordered
327
+ arrays are frozen, and `handlers` is a read-only map facade with no `set`,
328
+ `delete`, or `clear`. Reflection is descriptor-only: accessors never execute,
329
+ exotic prototypes are refused, and a hostile proxy observes a bounded number of
330
+ traps whose results are copied rather than retained.
331
+
332
+ Order of the checks: options, flatten, validate every record, filter by `apply`,
333
+ check hook names against the catalog for selected plugins, reject duplicate
334
+ names, order, then decode the cache environment. Structure is validated before
335
+ filtering, so exclusion cannot hide a malformed plugin; catalog names are
336
+ checked after, so a shared preset carrying harness-only hooks still resolves
337
+ under an engine kernel.
338
+
339
+ Ordering follows Vite exactly. `enforce` sorts the plugin list once into pre,
340
+ normal, and post, stably within each group. The per-hook `order` then
341
+ re-partitions that list for one hook, so `{ order: "pre", handler }` runs ahead
342
+ of every normal-order handler even when its plugin is `enforce: "post"`. Ties
343
+ keep resolved plugin order.
344
+
345
+ `layer` merges every selected plugin's layer left to right, so an earlier
346
+ plugin's services are visible to a later plugin's layer, and the later plugin
347
+ wins when two provide the same tag. A layer that fails during acquisition
348
+ becomes `layer_failed` with the plugin's name. `layer` also seals
349
+ `Action.CurrentCacheEnvironment`: plugin layers read the declared environment,
350
+ or `undefined` when none was declared, and a plugin layer that provides its own
351
+ environment is overridden.
352
+
353
+ ### Limits
354
+
355
+ | Constant | Value | Refusal |
356
+ | ---------------------------- | ----- | ----------------------------- |
357
+ | `maximumPlugins` | 256 | `resource_limit` |
358
+ | `maximumHandlers` | 1,024 | `resource_limit` |
359
+ | `maximumPluginInputNodes` | 4,096 | `resource_limit` |
360
+ | `maximumPluginDepth` | 64 | `resource_limit` |
361
+ | `maximumParallelConcurrency` | 256 | `resource_limit` |
362
+ | `maximumPluginNameLength` | 256 | `invalid_plugin` |
363
+ | `defaultParallelConcurrency` | 16 | none, it is the default value |
364
+
365
+ A `resource_limit` carries the path of the offending entry or preset array, and
366
+ the handler bound counts the handlers the kernel dispatches. Plugin, version,
367
+ and hook names are a separate check, not a budget: they are control-free,
368
+ well-formed strings within `maximumPluginNameLength` that are neither empty nor
369
+ entirely whitespace, and a name that is not fails with `invalid_plugin` at its
370
+ own path even when every budget is free.
371
+
372
+ ## Config
373
+
374
+ ```ts
375
+ const ConfigValue: Schema.Json
376
+ type ConfigValue = typeof ConfigValue.Type
377
+
378
+ const FlowsConfig: Schema.Codec<Readonly<Record<string, ConfigValue>>, unknown>
379
+ type FlowsConfig = typeof FlowsConfig.Type
380
+
381
+ const ResolvedConfig: typeof FlowsConfig
382
+ type ResolvedConfig = typeof ResolvedConfig.Type
383
+
384
+ const defaults: ResolvedConfig
385
+
386
+ const merge: (base: FlowsConfig, patch: unknown) => FlowsConfig
387
+ const deepFreeze: <A extends ConfigValue>(value: A) => A
388
+ const snapshot: (config: unknown) => Effect.Effect<FlowsConfig, PluginError>
389
+ const resolve: (config: unknown) => Effect.Effect<ResolvedConfig, PluginError>
390
+ ```
391
+
392
+ | Export | Behavior |
393
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
394
+ | `defaults` | The frozen empty configuration. Engine policy is deliberately not defaulted here. |
395
+ | `merge` | Copies and deep-merges a patch over a base. Records merge key by key; every other JSON value replaces wholesale. Raw operands are admitted; unchanged snapshot subtrees are reused. Cached totals enforce result bounds. Throws `PluginError` with code `config_invalid`. |
396
+ | `deepFreeze` | Copies and recursively freezes one JSON value without retaining caller-owned objects. Throws `PluginError`. |
397
+ | `snapshot` | Admits a raw pre-resolution configuration as an immutable snapshot. |
398
+ | `resolve` | Decodes the post-waterfall configuration into its final immutable form. |
399
+
400
+ `FlowsConfig` and `ResolvedConfig` decode through the same admission contract as
401
+ `snapshot` and `resolve`. Decoding raw input detaches and recursively freezes it;
402
+ invalid input produces a schema error. `Schema.is` recognizes admitted snapshots,
403
+ not mutable JSON shapes. Encoding accepts admitted snapshots and returns their
404
+ JSON value. The TypeScript types remain read-only namespace maps.
405
+
406
+ Known snapshots can be reused by `snapshot`, `resolve`, and `merge`. Freezing an
407
+ object yourself does not establish admission. A merge copies each changed record
408
+ and retains unchanged frozen subtrees. It fully admits and detaches patch data,
409
+ including references to previous snapshots, so patches cannot introduce shared
410
+ references. Cached byte, member, node, and depth totals enforce result bounds.
411
+ Admission work scales with the initial tree plus patch data; copying changed
412
+ records also scales with their immediate key counts.
413
+
414
+ Configuration is a plugin-owned JSON namespace map. The root keys `engine`,
415
+ `retry`, `store`, and `plugins` are refused, because the kernel does not apply
416
+ those policies.
417
+
418
+ Refused with `config_invalid`, at the offending path, without executing user
419
+ accessors: cycles, repeated object references, sparse arrays, accessors, symbol
420
+ keys, exotic prototypes, non-finite numbers, unpaired surrogates, `undefined`
421
+ members, and the prototype-control keys `__proto__`, `constructor`, and
422
+ `prototype`. `undefined` is refused rather than dropped, so
423
+ `{ endpoint: undefined }` fails at `$.endpoint` instead of resolving to `{}`.
424
+ Omit the key, or write `null`.
425
+
426
+ ### Limits
427
+
428
+ | Constant | Value |
429
+ | ---------------------- | ----------------------- |
430
+ | `maximumConfigBytes` | 1 MiB of encoded JSON |
431
+ | `maximumConfigDepth` | 64 container edges |
432
+ | `maximumConfigMembers` | 4,096 aggregate members |
433
+ | `maximumConfigNodes` | 8,192 aggregate values |
434
+
435
+ Strings are limited to 64 KiB of encoded JSON and keys to 1 KiB.
436
+
437
+ ## Cache identity
438
+
439
+ `Options.cacheEnvironment` is optional. Omitting it keeps sealed activity keys
440
+ local to the run and leaves plugin `version` optional. Supplying it requires the
441
+ complete `Action.CacheEnvironment` schema and a bounded, non-empty `version` on
442
+ every selected plugin.
443
+
444
+ Resolution copies and freezes every layer entry, capability record, and
445
+ capability array, then prepends each selected `name@version` in resolved order.
446
+ Both halves percent-escape `%` and then `@`, so the identity is injective:
447
+ `a@b` at version `c` and `a` at version `b@c` produce different layer entries
448
+ instead of one shared sealed identity. A scoped name reads as
449
+ `%40scope/name@1.0.0`. A versionless composition fails with
450
+ `cache_environment_invalid` instead of declaring an ambiguous cross-run
451
+ identity. Invalid or mutable caller data never reaches
452
+ `Action.CurrentCacheEnvironment`, and no plugin layer can declare or replace
453
+ it.
454
+
455
+ ## PluginError
456
+
457
+ ```ts
458
+ const PluginErrorCode: Schema.Literals<[
459
+ "duplicate_name",
460
+ "unknown_hook",
461
+ "hook_kind_mismatch",
462
+ "invalid_plugin",
463
+ "apply_failed",
464
+ "config_invalid",
465
+ "cache_environment_invalid",
466
+ "invalid_hook_result",
467
+ "resource_limit",
468
+ "hook_failed",
469
+ "layer_failed"
470
+ ]>
471
+ type PluginErrorCode = typeof PluginErrorCode.Type
472
+
473
+ class PluginError extends Schema.TaggedError<PluginError>()("flows/plugin/PluginError", {
474
+ code: PluginErrorCode,
475
+ message: Schema.String,
476
+ plugin: Schema.optional(Schema.String),
477
+ hook: Schema.optional(Schema.String),
478
+ path: Schema.optional(Schema.String),
479
+ cause: Schema.optional(Schema.Unknown)
480
+ }) {}
481
+ ```
482
+
483
+ Every startup and dispatch refusal uses this one error. `plugin` and `hook` are
484
+ attribution, `path` is a diagnostic dollar-path into the refused value, and
485
+ `cause` carries a failing handler's or layer's original failure. Match on
486
+ `code`; the messages are prose.
487
+
488
+ `cause` is not redacted. It holds whatever the plugin failed or threw with,
489
+ which can include credentials or config values. Keep it in process for
490
+ diagnostics, and redact it before a host logs, persists, or transmits a
491
+ `PluginError`.
492
+
493
+ A dollar-path is not an RFC 6901 JSON Pointer: `$` is the input, `.config` an
494
+ identifier property, `[2]` an array index, and `["not an identifier"]` any other
495
+ key, JSON-quoted. So `$[2].hooks.config` is the third plugin's `config` entry
496
+ and `$options.parallelConcurrency` is an option.
497
+
498
+ | Code | Meaning |
499
+ | --------------------------- | ------------------------------------------------------------------------------ |
500
+ | `duplicate_name` | Two selected plugins have the same exact name. |
501
+ | `unknown_hook` | A plugin declares a hook absent from the host catalog. |
502
+ | `hook_kind_mismatch` | A catalog kind disagrees with the kind the kernel or a dispatch method runs. |
503
+ | `invalid_plugin` | A plugin, preset, option, or hook entry has an invalid runtime shape. |
504
+ | `apply_failed` | An `apply` predicate threw. Its raw failure is not retained. |
505
+ | `config_invalid` | Config is not bounded strict JSON or uses a reserved policy key. |
506
+ | `cache_environment_invalid` | Cache identity is malformed or incomplete. |
507
+ | `invalid_hook_result` | A first hook returned a value other than `Option`. |
508
+ | `resource_limit` | Preset, plugin, handler, or concurrency work exceeds a published bound. |
509
+ | `hook_failed` | A hook Effect failed, defected, threw synchronously, or returned a non-Effect. |
510
+ | `layer_failed` | A validated plugin layer failed during acquisition. |
511
+
512
+ `configResolved` is a lossy observer boundary. Its failures do not fail kernel
513
+ startup; `Kernel.observerErrors` returns them for redacted logging by the host.
514
+ What to change for each code is in [Troubleshooting](./troubleshooting.md).
515
+
516
+ ## Related packages
517
+
518
+ The shipped hook catalog is the Smithers agent loop in
519
+ [`@smthrs/agent`](/api/agent), which augments `FlowsHooks` with three
520
+ waterfalls. `Action.CacheEnvironment` comes from
521
+ [`@smthrs/flow`](/api/flow). The other runtime dependency,
522
+ [`@smthrs/canonical`](/api/canonical), supplies immutable catalog maps.
@@ -0,0 +1,111 @@
1
+ ---
2
+ title: "Configuration"
3
+ description: "The config waterfall and the JSON boundary it enforces: plugin-owned namespaces, reserved root keys, deep merge semantics, admission rules, and the published size limits."
4
+ sidebar:
5
+ order: 3
6
+ ---
7
+
8
+ Configuration is the one lifecycle the kernel owns. It is two hooks: `config`,
9
+ a waterfall in which each plugin may return a patch, and `configResolved`, a
10
+ parallel observer that sees the frozen result. `Kernel.make` runs both.
11
+
12
+ ## Configuration is a namespace map, not a policy surface
13
+
14
+ A configuration is a flat record whose root keys name plugin-owned namespaces.
15
+ Each namespace holds JSON, and the kernel never interprets it: the plugin that
16
+ owns a namespace is the only code that knows what its contents mean.
17
+
18
+ Four root keys are refused with `config_invalid`: `engine`, `retry`, `store`,
19
+ and `plugins`. The kernel does not apply retry, storage, concurrency, cache,
20
+ wait, journal, or ownership policy, and accepting those names would imply that
21
+ setting them changes something. Those policies stay on the Effect service or
22
+ constructor option that applies them.
23
+
24
+ ## The waterfall merges patches
25
+
26
+ Each `config` handler receives the accumulated configuration and returns a
27
+ partial to merge, or nothing:
28
+
29
+ ```ts
30
+ import { type FlowsPlugin, make } from "@smthrs/plugin"
31
+ import * as Effect from "effect/Effect"
32
+
33
+ const plugin: FlowsPlugin = make({
34
+ name: "flows-plugin-endpoint",
35
+ hooks: {
36
+ config: (config) =>
37
+ config["endpoint"] === undefined
38
+ ? Effect.succeed({ endpoint: { url: "https://example.test" } })
39
+ : Effect.void
40
+ }
41
+ })
42
+ ```
43
+
44
+ `Config.merge` is the merge function `Kernel.make` uses. Records merge key by
45
+ key, recursively. Every other JSON value, including an array, replaces the
46
+ previous value wholesale:
47
+
48
+ ```ts
49
+ Config.merge({ a: { b: 1, c: 2 } }, { a: { c: 3, d: 4 } }) // { a: { b: 1, c: 3, d: 4 } }
50
+ Config.merge({ a: [1] }, { a: [2] }) // { a: [2] }
51
+ Config.merge({ a: { b: 1 } }, { a: 5 }) // { a: 5 }
52
+ ```
53
+
54
+ Both operands and the merged result are admitted, so no handler in the chain
55
+ ever observes a value that breaks the rules below, even when each individual
56
+ patch was within them.
57
+
58
+ ## What the boundary admits
59
+
60
+ Configuration is strict JSON, copied before any plugin observes it and frozen
61
+ recursively afterward. The following are refused with `config_invalid` and the
62
+ path of the offending value:
63
+
64
+ - Cycles and repeated object references.
65
+ - Sparse arrays, arrays with extra properties, and array subclasses.
66
+ - Accessors, symbol keys, non-enumerable properties, and exotic prototypes.
67
+ - Non-finite numbers, unpaired surrogates, and non-JSON values such as `Date`
68
+ and `Map`.
69
+ - The prototype-control keys `__proto__`, `constructor`, and `prototype`, at
70
+ every depth.
71
+ - `undefined` members.
72
+
73
+ The last one is a decision worth naming. `{ endpoint: undefined }` fails at
74
+ `$.endpoint` instead of resolving to `{}`. A silent drop hid a typo behind a
75
+ value the next handler never saw. To leave something unset, omit the key or
76
+ write `null`.
77
+
78
+ Admission reads a value through `Reflect.ownKeys`, `Object.getPrototypeOf`, and
79
+ one property descriptor per key. An accessor is never called, so a proxy sees a
80
+ bounded number of traps and only the data those traps return is copied. A trap
81
+ that throws refuses the whole tree.
82
+
83
+ ## Size limits
84
+
85
+ | Limit | Value | Export |
86
+ | ------------------------------- | ----- | ----------------------------- |
87
+ | Encoded bytes per configuration | 1 MiB | `Config.maximumConfigBytes` |
88
+ | Container nesting depth | 64 | `Config.maximumConfigDepth` |
89
+ | Aggregate members | 4,096 | `Config.maximumConfigMembers` |
90
+ | Aggregate values | 8,192 | `Config.maximumConfigNodes` |
91
+
92
+ Strings are limited to 64 KiB of encoded JSON and keys to 1 KiB. A tree over any
93
+ bound fails with `config_invalid`, and because the merged result is admitted
94
+ too, a chain of small patches cannot add up past them.
95
+
96
+ ## configResolved is a lossy observer boundary
97
+
98
+ After the waterfall settles, `Kernel.make` decodes and freezes the result, then
99
+ runs every `configResolved` handler in parallel. Those handlers are observers:
100
+ their success values are discarded, and their failures do not fail startup.
101
+ Read them from `kernel.observerErrors` and log them at your own boundary:
102
+
103
+ ```ts
104
+ const kernel = await Effect.runPromise(Kernel.make([plugin]))
105
+ for (const error of kernel.observerErrors) {
106
+ console.warn(`plugin ${error.plugin} failed to observe the config: ${error.code}`)
107
+ }
108
+ ```
109
+
110
+ Dropping them on the floor is the one thing to avoid. A plugin whose observer
111
+ fails silently looks configured and is not.