@orkestrel/scaffold 0.0.67 → 0.0.69

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 (74) hide show
  1. package/dist/bin/main.js +67 -44
  2. package/dist/bin/main.js.map +1 -1
  3. package/dist/host/agents/templates/brief.md +9 -0
  4. package/dist/host/claude/agents/orkestrel.md +4 -4
  5. package/dist/host/claude/rules/names.md +15 -0
  6. package/dist/host/claude/rules/tests.md +33 -4
  7. package/dist/host/claude/rules/workspace.md +14 -2
  8. package/dist/host/dotfiles/prettierignore +3 -0
  9. package/dist/host/guides/README.md +65 -0
  10. package/dist/host/guides/abort.md +169 -0
  11. package/dist/host/guides/agent.md +1567 -0
  12. package/dist/host/guides/brief.md +1266 -0
  13. package/dist/host/guides/browser.md +2200 -0
  14. package/dist/host/guides/budget.md +196 -0
  15. package/dist/host/guides/codec.md +519 -0
  16. package/dist/host/guides/console.md +785 -0
  17. package/dist/host/guides/contract.md +1193 -0
  18. package/dist/host/guides/csv.md +541 -0
  19. package/dist/host/guides/database.md +2518 -0
  20. package/dist/host/guides/emitter.md +233 -0
  21. package/dist/host/guides/form.md +1791 -0
  22. package/dist/host/guides/html.md +717 -0
  23. package/dist/host/guides/indexeddb.md +505 -0
  24. package/dist/host/guides/interpret.md +1029 -0
  25. package/dist/host/guides/lsp.md +515 -0
  26. package/dist/host/guides/markdown.md +964 -0
  27. package/dist/host/guides/mcp.md +5554 -0
  28. package/dist/host/guides/middleware.md +927 -0
  29. package/dist/host/guides/msg.md +440 -0
  30. package/dist/host/guides/ndjson.md +120 -0
  31. package/dist/host/guides/ollama.md +380 -0
  32. package/dist/host/guides/pool.md +280 -0
  33. package/dist/host/guides/probe.md +1210 -0
  34. package/dist/host/guides/process.md +1620 -0
  35. package/dist/host/guides/program.md +1110 -0
  36. package/dist/host/guides/qualifier.md +854 -0
  37. package/dist/host/guides/queue.md +370 -0
  38. package/dist/host/guides/rater.md +330 -0
  39. package/dist/host/guides/reason.md +1122 -0
  40. package/dist/host/guides/relation.md +373 -0
  41. package/dist/host/guides/router.md +753 -0
  42. package/dist/host/guides/scaffold.md +192 -31
  43. package/dist/host/guides/sea.md +383 -0
  44. package/dist/host/guides/server.md +752 -0
  45. package/dist/host/guides/sqlite.md +330 -0
  46. package/dist/host/guides/sse.md +187 -0
  47. package/dist/host/guides/supervisor.md +4890 -0
  48. package/dist/host/guides/table.md +1556 -0
  49. package/dist/host/guides/template.md +280 -0
  50. package/dist/host/guides/terminal.md +1145 -0
  51. package/dist/host/guides/test.md +2969 -0
  52. package/dist/host/guides/timeout.md +252 -0
  53. package/dist/host/guides/tool.md +507 -0
  54. package/dist/host/guides/toolbox.md +1038 -0
  55. package/dist/host/guides/websocket.md +282 -0
  56. package/dist/host/guides/worker.md +615 -0
  57. package/dist/host/guides/workflow.md +1507 -0
  58. package/dist/host/guides/workspace.md +595 -0
  59. package/dist/host/manifest.json +1218 -10
  60. package/dist/host/tests/policy.test.ts +279 -2
  61. package/dist/host/tests/setupPolicy.ts +445 -6
  62. package/dist/src/core/index.cjs +38 -16
  63. package/dist/src/core/index.cjs.map +1 -1
  64. package/dist/src/core/index.d.cts +33 -9
  65. package/dist/src/core/index.d.ts +33 -9
  66. package/dist/src/core/index.js +37 -17
  67. package/dist/src/core/index.js.map +1 -1
  68. package/dist/src/server/index.cjs +1750 -1567
  69. package/dist/src/server/index.cjs.map +1 -1
  70. package/dist/src/server/index.d.cts +106 -24
  71. package/dist/src/server/index.d.ts +106 -24
  72. package/dist/src/server/index.js +1751 -1570
  73. package/dist/src/server/index.js.map +1 -1
  74. package/package.json +3 -3
@@ -0,0 +1,854 @@
1
+ # Qualifier
2
+
3
+ > A synchronous, deterministic eligibility engine that runs a pure,
4
+ > JSON-serializable `QualificationDefinition`'s ordered `passes` against one
5
+ > subject through one `@orkestrel/reason` engine and returns a fresh
6
+ > `QualificationResult` carrying global and scoped eligibility, evidence-rich
7
+ > `findings`, and quantitative `derivations`.
8
+
9
+ `Qualifier` stops at eligibility: it reports whether and where a subject may proceed,
10
+ and never calculates line amounts, builds worksheets, totals rates, emits notices,
11
+ decides authority, or aggregates a batch. Qualification never mutates its inputs, so
12
+ every result is a fresh object, and the internal working projection under
13
+ `QUALIFICATION_KEY` is discarded after each call and must never be forwarded to a
14
+ downstream consumer. A failed qualification, a global `ineligible`, and a global
15
+ `referral` are terminal — a caller that runs qualification ahead of a downstream step
16
+ stops there. A scoped restriction removes only that named scope from what the caller
17
+ selects next, so an excluded scope is never evaluated merely to discard its outcome.
18
+ `Qualifier` either receives an injected `ReasonInterface`, which it never destroys, or
19
+ builds and owns its own engine (`bail: false`), destroyed in `destroy()`. An injected
20
+ engine must be able to dispatch both quantitative and logical definitions — one it
21
+ cannot dispatch surfaces `QualifierError('ENGINE')` wrapping the engine's throw. Every
22
+ `qualify` call fires through `Qualifier`'s typed `emitter`. Source:
23
+ [`src/core`](../src/core). Surfaced through the `@src/core` barrel.
24
+
25
+ ## Surface
26
+
27
+ Create a qualifier, author a definition, and qualify a subject:
28
+
29
+ ```ts
30
+ import { createQualificationDefinition, createQualifier, createRuling } from '@orkestrel/qualifier'
31
+ import { createAtom, createLogicalDefinition, createRule } from '@orkestrel/reason'
32
+
33
+ const gates = createLogicalDefinition('gates', 'Eligibility gates', [
34
+ createRule(
35
+ 'licensed',
36
+ [createAtom('licensed', 'equals', false)],
37
+ createAtom('blocked', 'equals', true),
38
+ ),
39
+ ])
40
+
41
+ const definition = createQualificationDefinition('standard', 'Standard eligibility', [gates], {
42
+ rulings: [
43
+ createRuling('license', 'gates', 'licensed', 'restriction', {
44
+ message: 'A license is required',
45
+ }),
46
+ ],
47
+ })
48
+
49
+ const qualifier = createQualifier()
50
+ const result = qualifier.qualify({ id: 'risk-1', licensed: false }, definition)
51
+
52
+ result.eligibility // 'ineligible'
53
+ result.findings[0]?.message // 'A license is required'
54
+ result.derivations // [] — no quantitative pass ran
55
+
56
+ qualifier.destroy()
57
+ ```
58
+
59
+ `qualify` accepts exactly one subject per call — there is no batch-of-subjects
60
+ overload. A caller that must qualify many subjects loops and calls `qualify` once
61
+ per subject.
62
+
63
+ ### Types
64
+
65
+ A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`.
66
+
67
+ | Type | Kind | Shape | Summary |
68
+ | ------------------------- | --------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
69
+ | `Eligibility` | type | `'eligible' \| 'ineligible' \| 'referral'` | Represents the eligibility outcome axis. |
70
+ | `QualificationEffect` | type | `'restriction' \| 'referral' \| 'condition'` | Represents an authored ruling's eligibility impact. |
71
+ | `QualificationPass` | type | `QuantitativeDefinition \| LogicalDefinition` | Represents one ordered derivation or rule pass. |
72
+ | `QualificationProjection` | type | `number \| boolean \| Readonly<Record<string, unknown>>` | Represents one pass's internal working projection. |
73
+ | `QualificationContext` | type | `Readonly<Record<string, QualificationProjection>>` | Represents the internal projection record stored under `QUALIFICATION_KEY`. |
74
+ | `RulingInput` | interface | `{ scope?, message? }` | Carries the optional fields `createRuling` accepts. |
75
+ | `QualificationInput` | interface | `{ description?, rulings?, metadata? }` | Carries the optional fields `createQualificationDefinition` accepts. |
76
+ | `Ruling` | interface | `{ id, pass, rule, effect, scope?, message? }` | Represents an authored consequence for one rule in one logical pass. |
77
+ | `Premise` | interface | `{ field?, label?, description?, comparison?, expected?, actual?, met? }` | Represents display-neutral evidence for one condition, authored as a checked or a described premise. |
78
+ | `Finding` | interface | `{ id, pass, rule, effect, scope?, applied, message?, premises }` | Represents one resolved ruling. |
79
+ | `Derivation` | interface | `{ id, value, success, trace, errors }` | Represents one quantitative pass's audit result. |
80
+ | `QualificationDefinition` | interface | `{ id, name, description?, passes, rulings?, metadata? }` | Represents a pure authored qualification definition. |
81
+ | `QualificationResult` | interface | `{ id, name, eligibility, scopes, findings, derivations, success, trace, errors }` | Represents one subject's complete qualification outcome. |
82
+ | `QualifierErrorCode` | type | `'DEFINITION' \| 'MISMATCH' \| 'DESTROYED' \| 'ENGINE'` | Represents a coded `QualifierError` programmer-error code. |
83
+ | `QualifierErrorContext` | interface | `{ pass?, definition?, cause? }` | Represents the structured payload a `QualifierError` carries. |
84
+ | `QualifierEventMap` | type | `{ derive, finding, qualify, destroy }` | Represents the push observation surface of a `QualifierInterface`. |
85
+ | `QualifierOptions` | interface | `{ engine?, validate?, labels?, on?, error? }` | Carries the options for `createQualifier` and the `Qualifier` constructor. |
86
+ | `QualifierInterface` | interface | `{ emitter } plus qualify, validate, destroy` | Owns or borrows one reason engine and returns eligibility. |
87
+
88
+ Every public data member is `readonly`, every optional key is omitted rather than
89
+ `undefined`, and each name is single-word within its entity. Reason
90
+ supplies the pass primitives (`QuantitativeDefinition`, `LogicalDefinition`,
91
+ `Subject`, `Comparison`) and the `ReasonValidationResult` type `validate` returns;
92
+ contract supplies `FieldPath` and `JSONValue`; the emitter supplies the observation
93
+ types.
94
+
95
+ ### Constants
96
+
97
+ A `Shape` cell holds the constant's declared type.
98
+
99
+ | API | Kind | Shape | Summary |
100
+ | ---------------------------- | ----- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
101
+ | `DEFAULT_QUALIFIER_VALIDATE` | const | `boolean` | Holds the default definition validation policy for `createQualifier` and `Qualifier.qualify`, `true`. |
102
+ | `QUALIFICATION_KEY` | const | `string` | Names `'qualification'`, the reserved internal projection namespace a pass's working projection is written under. |
103
+ | `ELIGIBILITY_PRECEDENCE` | const | `readonly Eligibility[]` | Lists the eligibility severities most to least severe: `ineligible`, `referral`, `eligible`. |
104
+ | `EFFECT_ELIGIBILITIES` | const | `Readonly<Record<QualificationEffect, Eligibility>>` | Maps each `QualificationEffect` to its eligibility impact — `restriction` to `ineligible`, `referral` to `referral`, and `condition` to `eligible`. |
105
+
106
+ `ELIGIBILITY_PRECEDENCE` and `EFFECT_ELIGIBILITIES` are frozen. A `condition` never blocks
107
+ its subject, so a conditional ruling contributes evidence without changing eligibility.
108
+
109
+ ### Errors
110
+
111
+ | API | Kind | Summary |
112
+ | ------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
113
+ | `QualifierError` | class | Represents a coded programmer error thrown by the qualifier layer, carrying a `QualifierErrorCode` and an optional context record. |
114
+ | `isQualifierError` | function | Narrows a caught value to a `QualifierError`. |
115
+
116
+ `context` is a `QualifierErrorContext` or `undefined`, so a caught error's payload
117
+ needs no narrowing of its own. `ENGINE` marks an underlying reason engine throw that
118
+ fits no other code (for example, a missing reasoner) — `context.pass` names the pass
119
+ and `context.cause` preserves the original throw. A `DEFINITION`/`INVALID` or
120
+ `DESTROYED` engine throw maps to the matching code instead, and a `DEFINITION` throw
121
+ raised by `qualify`'s own validation carries `context.definition`.
122
+
123
+ ```ts
124
+ import { isQualifierError, QualifierError } from '@orkestrel/qualifier'
125
+
126
+ try {
127
+ throw new QualifierError('DESTROYED', 'Qualifier has been destroyed')
128
+ } catch (error) {
129
+ if (isQualifierError(error)) {
130
+ error.code // 'DESTROYED'
131
+ error.context // undefined
132
+ }
133
+ }
134
+
135
+ const engine = new QualifierError('ENGINE', "Pass 'gates' engine failure", { pass: 'gates' })
136
+ engine.context?.pass // 'gates'
137
+ ```
138
+
139
+ ### Validators
140
+
141
+ A validator is exact or open, split by who produces the value. Authored-input guards are exact:
142
+ they reject unknown keys because this package owns the stored record. Result guards are open: they
143
+ admit unknown members, prototypes, and class instances while checking every published member this
144
+ package reads. This matters when a package borrows a `QualifierInterface`; the implementation that
145
+ produces `qualify` results may live outside this package. Every validator remains total, refuses
146
+ arrays where a record is required, returns `false` for hostile reads, and never throws. A `validate`
147
+ result belongs to `@orkestrel/reason`, so narrow one with the `isReasonValidationResult` guard from
148
+ that package.
149
+
150
+ In a guard table a `Shape` cell holds the type the guard narrows to.
151
+
152
+ | API | Kind | Shape | Summary |
153
+ | --------------------------- | -------- | --------------------------------------- | ------------------------------------------------------------------------------------------- |
154
+ | `isEligibility` | const | `Eligibility` | Determines whether a value is an `Eligibility` literal. |
155
+ | `isQualificationEffect` | const | `QualificationEffect` | Determines whether a value is a `QualificationEffect` literal. |
156
+ | `isEligibilityRecord` | function | `Readonly<Record<string, Eligibility>>` | Determines whether a value is an open string-keyed record of `Eligibility` values. |
157
+ | `isPremise` | function | `Premise` | Determines whether a value is an open result-side `Premise`. |
158
+ | `isFinding` | function | `Finding` | Determines whether a value is an open result-side `Finding`. |
159
+ | `isDerivation` | function | `Derivation` | Determines whether a value is an open result-side `Derivation`. |
160
+ | `isQualificationResult` | function | `QualificationResult` | Determines whether a value is an open `QualificationResult` returned by a qualifier. |
161
+ | `isRuling` | function | `Ruling` | Determines whether a value is an exact `Ruling` record. |
162
+ | `isQualificationPass` | function | `QualificationPass` | Determines whether a value is a `QualificationPass` (a quantitative or logical definition). |
163
+ | `isQualificationDefinition` | function | `QualificationDefinition` | Determines whether a value is an exact `QualificationDefinition` record. |
164
+
165
+ ```ts
166
+ import {
167
+ isEligibility,
168
+ isEligibilityRecord,
169
+ isFinding,
170
+ isDerivation,
171
+ isPremise,
172
+ isQualificationDefinition,
173
+ isQualificationEffect,
174
+ isQualificationPass,
175
+ isQualificationResult,
176
+ isRuling,
177
+ } from '@orkestrel/qualifier'
178
+ import { createLogicalDefinition } from '@orkestrel/reason'
179
+
180
+ isEligibility('referral') // true
181
+ isQualificationEffect('condition') // true
182
+ isEligibilityRecord({ wind: 'ineligible' }) // true
183
+ isPremise({ field: 'age', comparison: 'above', actual: 30, met: true }) // true
184
+ isFinding({
185
+ id: 'f',
186
+ pass: 'gates',
187
+ rule: 'adult',
188
+ effect: 'condition',
189
+ applied: true,
190
+ premises: [],
191
+ }) // true
192
+ isDerivation({ id: 'cap', value: Number.NaN, success: true, trace: [], errors: [] }) // true
193
+ isQualificationResult({
194
+ id: 'd',
195
+ name: 'D',
196
+ eligibility: 'eligible',
197
+ scopes: {},
198
+ findings: [],
199
+ derivations: [],
200
+ success: true,
201
+ trace: [],
202
+ errors: [],
203
+ }) // true
204
+ isQualificationPass(createLogicalDefinition('gates', 'Gates', [])) // true
205
+ isRuling({ id: 'r', pass: 'gates', rule: 'licensed', effect: 'restriction' }) // true
206
+ isQualificationDefinition({ id: 'd', name: 'D', passes: [] }) // true
207
+ ```
208
+
209
+ ### Helpers
210
+
211
+ Pure exported helpers form the functional core. `Qualifier` retains only the ordered
212
+ orchestration and ownership lifecycle.
213
+
214
+ | API | Kind | Summary |
215
+ | -------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
216
+ | `interpolateMessage` | function | Interpolates `{{dotted.path}}` tokens in a message template against a subject. |
217
+ | `renderComparison` | function | Renders a `Premise` comparison as a display-neutral verb phrase. |
218
+ | `renderValue` | function | Renders a structured or scalar expected value display-neutrally. |
219
+ | `renderPremise` | function | Renders one `Premise` into a display-neutral sentence. |
220
+ | `checkToPremise` | function | Builds a `Premise` from an authored `Check` and its evaluated `CheckResult`. |
221
+ | `ruleToPremises` | function | Builds rich premises for one fired `Rule` by walking its premise atoms and re-evaluating each against the working subject. |
222
+ | `findRule` | function | Locates an authored `Rule` by id. |
223
+ | `reasonResultToProjection` | function | Projects one reason result into the internal qualification namespace. |
224
+ | `quantitativeResultToDerivation` | function | Projects a quantitative result into a `Derivation` audit record. |
225
+ | `qualificationToRecord` | function | Wraps a `QualificationContext` under `QUALIFICATION_KEY`. |
226
+ | `mergeQualificationContext` | function | Merges one pass projection into the context, copy-on-write. |
227
+ | `rulingToFinding` | function | Joins a ruling, its logical rule result, the pass, the pre-projection subject, and an evaluator into a `Finding`. |
228
+ | `deriveFindingEligibility` | function | Derives global eligibility from applied, unscoped findings. |
229
+ | `combineEligibilities` | function | Returns the most severe `Eligibility` in a list. |
230
+ | `deriveScopeEligibilities` | function | Derives one eligibility per finding scope. |
231
+ | `describeMissingReferences` | function | Describes each ruling whose pass or rule does not exist or whose pass is not logical, and each pass id shadowing the reserved `QUALIFICATION_KEY`. |
232
+ | `hasReservedKey` | function | Determines whether a subject already owns the reserved `QUALIFICATION_KEY`. |
233
+ | `assertSubject` | function | Asserts a value is a valid qualification `Subject`, narrowing it in place. |
234
+ | `mapEngineError` | function | Maps an engine throw caught while running one pass to a typed `QualifierError`. |
235
+ | `describeEmptyLogicalPasses` | function | Describes each logical pass carrying no rulings. |
236
+ | `describeUnreadDerivations` | function | Describes each quantitative pass never read by a later pass. |
237
+
238
+ Every helper carries a worked `@example` in source and is composed by `Qualifier`
239
+ rather than reimplemented by it.
240
+
241
+ The projection and derivation core turns one reason result into a working projection,
242
+ a `Derivation`, and — for logical passes — findings and eligibility:
243
+
244
+ ```ts
245
+ import {
246
+ createRuling,
247
+ deriveFindingEligibility,
248
+ deriveScopeEligibilities,
249
+ mergeQualificationContext,
250
+ qualificationToRecord,
251
+ quantitativeResultToDerivation,
252
+ reasonResultToProjection,
253
+ rulingToFinding,
254
+ } from '@orkestrel/qualifier'
255
+ import {
256
+ createAtom,
257
+ createEvaluator,
258
+ createFactorGroup,
259
+ createLogicalDefinition,
260
+ createLogicalReasoner,
261
+ createQuantitativeDefinition,
262
+ createQuantitativeReasoner,
263
+ createReason,
264
+ createRule,
265
+ createStaticFactor,
266
+ } from '@orkestrel/reason'
267
+
268
+ const engine = createReason({
269
+ reasoners: [createQuantitativeReasoner(), createLogicalReasoner()],
270
+ bail: false,
271
+ })
272
+ const evaluator = createEvaluator()
273
+
274
+ const cap = createQuantitativeDefinition('cap', 'TIV cap', [
275
+ createFactorGroup('limit', 'sum', [createStaticFactor('base', 500_000)]),
276
+ ])
277
+ const gates = createLogicalDefinition('gates', 'Eligibility gates', [
278
+ createRule(
279
+ 'licensed',
280
+ [createAtom('licensed', 'equals', false)],
281
+ createAtom('blocked', 'equals', true),
282
+ ),
283
+ ])
284
+
285
+ const subject = { licensed: false }
286
+
287
+ // A quantitative pass projects its numeric value under the pass id, and audits as a Derivation.
288
+ const capResult = engine.reason(subject, cap)
289
+ const context = mergeQualificationContext({}, 'cap', reasonResultToProjection(cap, capResult))
290
+ qualificationToRecord(context) // { qualification: { cap: 500000 } }
291
+ if (capResult.reasoning === 'quantitative') {
292
+ quantitativeResultToDerivation('cap', capResult) // { id: 'cap', value: 500000, success: true, ... }
293
+ }
294
+
295
+ // A logical pass joins each ruling to a Finding, then eligibility follows by severity.
296
+ const gatesResult = engine.reason(subject, gates)
297
+ if (gatesResult.reasoning === 'logical') {
298
+ const ruling = createRuling('license', 'gates', 'licensed', 'restriction')
299
+ const finding = rulingToFinding(ruling, gates, gatesResult, subject, evaluator)
300
+ deriveFindingEligibility([finding]) // 'ineligible'
301
+ deriveScopeEligibilities([finding]) // {} — the finding is unscoped
302
+ }
303
+ ```
304
+
305
+ The projections stay inside the qualification namespace and the working subject is
306
+ discarded after each call. The reference-describing and subject-guard helpers back
307
+ `validate` and the reserved-key rejection:
308
+
309
+ ```ts
310
+ import {
311
+ assertSubject,
312
+ createQualificationDefinition,
313
+ createRuling,
314
+ describeMissingReferences,
315
+ hasReservedKey,
316
+ } from '@orkestrel/qualifier'
317
+ import { createAtom, createLogicalDefinition, createRule } from '@orkestrel/reason'
318
+
319
+ const gates = createLogicalDefinition('gates', 'Eligibility gates', [
320
+ createRule(
321
+ 'licensed',
322
+ [createAtom('licensed', 'equals', false)],
323
+ createAtom('blocked', 'equals', true),
324
+ ),
325
+ ])
326
+ const definition = createQualificationDefinition('standard', 'Standard', [gates], {
327
+ rulings: [createRuling('license', 'gates', 'absent', 'restriction')],
328
+ })
329
+
330
+ describeMissingReferences(definition)
331
+ // ["Ruling 'license' references missing rule 'absent' in pass 'gates'"]
332
+ hasReservedKey({ id: 's1', qualification: {} }) // true
333
+ assertSubject({ id: 's1' }) // narrows to Subject; throws QualifierError('MISMATCH') on a reserved key
334
+ ```
335
+
336
+ ### Factories
337
+
338
+ | API | Kind | Summary |
339
+ | ------------------------------- | -------- | ------------------------------------------------------------------------------ |
340
+ | `createQualifier` | function | Creates one `QualifierInterface` over a reason engine. |
341
+ | `createQualificationDefinition` | function | Creates a fresh `QualificationDefinition`, omitting every absent optional key. |
342
+ | `createRuling` | function | Creates a fresh `Ruling` from the rule it reacts to and the effect it applies. |
343
+
344
+ #### Create a qualifier
345
+
346
+ This fence adds to the quickstart what the factory family itself contributes: the
347
+ optional `message` and `rulings` inputs, and the fresh value each factory returns
348
+ with every absent optional key omitted.
349
+
350
+ ```ts
351
+ import { createQualificationDefinition, createQualifier, createRuling } from '@orkestrel/qualifier'
352
+ import { createAtom, createLogicalDefinition, createRule } from '@orkestrel/reason'
353
+
354
+ const gates = createLogicalDefinition('gates', 'Eligibility gates', [
355
+ createRule(
356
+ 'licensed',
357
+ [createAtom('licensed', 'equals', false)],
358
+ createAtom('blocked', 'equals', true),
359
+ ),
360
+ ])
361
+
362
+ const bare = createRuling('license', 'gates', 'licensed', 'restriction')
363
+ const messaged = createRuling('license', 'gates', 'licensed', 'restriction', {
364
+ message: 'A license is required',
365
+ })
366
+
367
+ 'message' in bare // false — an absent optional key is omitted, never written as undefined
368
+ messaged.message // 'A license is required'
369
+
370
+ const passes = [gates]
371
+ const definition = createQualificationDefinition('standard', 'Standard eligibility', passes, {
372
+ rulings: [messaged],
373
+ })
374
+
375
+ 'description' in definition // false
376
+ definition.passes === passes // false — the factory copies what it is handed
377
+
378
+ const qualifier = createQualifier()
379
+ qualifier.qualify({ id: 'risk-1', licensed: false }, definition)
380
+ qualifier.destroy()
381
+ ```
382
+
383
+ Every factory returns a fresh value and omits absent optional keys.
384
+
385
+ ### Classes
386
+
387
+ | API | Kind | Summary |
388
+ | ----------- | ----- | ------------------------------------------------------------------- |
389
+ | `Qualifier` | class | Runs ordered passes over one reason engine and returns eligibility. |
390
+
391
+ ## Methods
392
+
393
+ #### `QualifierInterface`
394
+
395
+ `qualify` takes exactly one subject and one definition — there is no
396
+ batch-of-subjects overload. `destroy` destroys the reason engine only when the
397
+ qualifier created it; an injected engine remains caller-owned. The emitter is
398
+ destroyed last.
399
+
400
+ | Method | Returns | Summary |
401
+ | ---------- | ------------------------ | ------------------------------------------------------------------- |
402
+ | `qualify` | `QualificationResult` | Qualifies one subject against one authored definition. |
403
+ | `validate` | `ReasonValidationResult` | Validates one authored definition semantically, without running it. |
404
+ | `destroy` | `void` | Destroys this qualifier, idempotently. |
405
+
406
+ ```ts
407
+ import { createQualificationDefinition, createQualifier, createRuling } from '@orkestrel/qualifier'
408
+ import { createAtom, createLogicalDefinition, createRule } from '@orkestrel/reason'
409
+
410
+ const gates = createLogicalDefinition('gates', 'Eligibility gates', [
411
+ createRule(
412
+ 'licensed',
413
+ [createAtom('licensed', 'equals', false)],
414
+ createAtom('blocked', 'equals', true),
415
+ ),
416
+ ])
417
+ const definition = createQualificationDefinition('standard', 'Standard eligibility', [gates], {
418
+ rulings: [createRuling('license', 'gates', 'licensed', 'restriction')],
419
+ })
420
+
421
+ const qualifier = createQualifier()
422
+
423
+ qualifier.validate(definition) // { valid: true, errors: [], warnings: [] }
424
+ qualifier.qualify({ id: 'a', licensed: false }, definition) // eligibility: 'ineligible'
425
+ qualifier.destroy()
426
+ ```
427
+
428
+ ## Contract
429
+
430
+ ### Qualification order
431
+
432
+ Pass order is load-bearing.
433
+
434
+ 1. Begin with a fresh working subject copied from the caller's subject.
435
+ 2. Run the first pass through the shared reason engine.
436
+ 3. Resolve rulings against the exact subject snapshot the pass evaluated.
437
+ 4. Project the result under `qualification[pass.id]`.
438
+ 5. Rebuild the working subject copy-on-write for later passes.
439
+ 6. Continue with the next pass.
440
+ 7. Derive global eligibility, scoped eligibility, and success.
441
+ 8. Return a fresh result and discard the working subject.
442
+
443
+ A quantitative pass exposes its numeric value directly:
444
+
445
+ ```ts
446
+ qualification.cap // 500000
447
+ ```
448
+
449
+ A logical pass exposes a record containing `conclusion` and applied conclusion fields:
450
+
451
+ ```ts
452
+ qualification.gates // { conclusion: true, blocked: true }
453
+ ```
454
+
455
+ Author qualification conclusion fields as flat string keys. `reason` formats an array-path conclusion into one dotted key, so an array-path conclusion does not create a nested object in the projection.
456
+
457
+ Nested reads use `FieldPath` arrays:
458
+
459
+ ```ts
460
+ createAtom(['qualification', 'cap'], 'below', 1_000_000)
461
+ ```
462
+
463
+ A dotted string remains one literal field key and is not equivalent.
464
+
465
+ ### Eligibility
466
+
467
+ Global eligibility is derived only from **unscoped** applied findings:
468
+
469
+ | Effect | Eligibility |
470
+ | ------------- | ------------ |
471
+ | `restriction` | `ineligible` |
472
+ | `referral` | `referral` |
473
+ | `condition` | `eligible` |
474
+
475
+ A failed pass adds a synthetic referral impact. Operational failure is therefore
476
+ fail-closed: a subject with incomplete eligibility evidence must not proceed to
477
+ any downstream decision step.
478
+
479
+ Severity is deterministic:
480
+
481
+ ```text
482
+ ineligible > referral > eligible
483
+ ```
484
+
485
+ The qualifier may stop after an unscoped restriction because no later finding can
486
+ be more severe. It must not stop after a referral if later passes can still establish
487
+ an ineligible restriction.
488
+
489
+ ### Scopes
490
+
491
+ A ruling with no `scope` affects global eligibility — whether the caller may
492
+ proceed past qualification for the whole subject. A ruling with a `scope` affects
493
+ only that named scope.
494
+
495
+ ```ts
496
+ createRuling('coastal-wind', 'wind-gates', 'coastal', 'restriction', {
497
+ scope: 'wind',
498
+ message: 'Wind coverage is unavailable in the coastal band',
499
+ })
500
+ ```
501
+
502
+ The result is explicit:
503
+
504
+ ```ts
505
+ result.eligibility // 'eligible'
506
+ result.scopes.wind // 'ineligible'
507
+ ```
508
+
509
+ The caller removes `wind` from the selected scope ids before any downstream
510
+ step. It does not evaluate the wind scope and then suppress its outcome.
511
+
512
+ A missing scope entry means `eligible`. A scoped `condition` leaves the scope
513
+ eligible and contributes evidence a later consumer may act on.
514
+
515
+ ### Validation
516
+
517
+ Structural validation and semantic validation remain separate.
518
+
519
+ `isQualificationDefinition` checks exact record shape. `Qualifier.validate` checks:
520
+
521
+ - non-empty definition id and name
522
+ - each pass is a valid quantitative or logical definition
523
+ - each ruling is a well-formed ruling record
524
+ - unique pass ids
525
+ - unique ruling ids
526
+ - every ruling references an existing pass
527
+ - every ruling references a logical pass
528
+ - every ruling references an existing rule in that pass
529
+ - no pass id equals `QUALIFICATION_KEY`
530
+ - warnings for definitions with no passes
531
+ - warnings for logical passes with no rulings
532
+ - warnings for quantitative derivations never read by a later pass
533
+
534
+ `qualify` throws `QualifierError('DEFINITION')` for a semantically invalid authored
535
+ definition when `validate` is enabled. A malformed reason result is an operational
536
+ qualification failure and returns a referral result instead of throwing. A
537
+ definition with no passes is valid (a warning only) — every subject qualifies
538
+ vacuously eligible against it.
539
+
540
+ ## Patterns
541
+
542
+ ### Quantitative derivation before logical eligibility
543
+
544
+ The common pattern is to derive a threshold, derive the excess, then evaluate rules.
545
+ The derived values remain inside the qualifier namespace.
546
+
547
+ ```ts
548
+ import { createQualificationDefinition, createQualifier, createRuling } from '@orkestrel/qualifier'
549
+ import {
550
+ createAtom,
551
+ createFactorGroup,
552
+ createFieldFactor,
553
+ createLogicalDefinition,
554
+ createQuantitativeDefinition,
555
+ createRule,
556
+ createStaticFactor,
557
+ createTransform,
558
+ } from '@orkestrel/reason'
559
+
560
+ const cap = createQuantitativeDefinition('cap', 'TIV cap', [
561
+ createFactorGroup('limit', 'sum', [createStaticFactor('base', 1_000_000)]),
562
+ ])
563
+
564
+ const excess = createQuantitativeDefinition('excess', 'TIV excess', [
565
+ createFactorGroup('amount', 'sum', [
566
+ createFieldFactor('total', 'total'),
567
+ createFieldFactor('cap', ['qualification', 'cap'], {
568
+ transforms: [createTransform('multiply', -1)],
569
+ }),
570
+ ]),
571
+ ])
572
+
573
+ const gates = createLogicalDefinition('gates', 'Eligibility gates', [
574
+ createRule(
575
+ 'tiv',
576
+ [createAtom(['qualification', 'excess'], 'above', 0)],
577
+ createAtom('blocked', 'equals', true),
578
+ ),
579
+ ])
580
+
581
+ const definition = createQualificationDefinition(
582
+ 'property',
583
+ 'Property eligibility',
584
+ [cap, excess, gates],
585
+ {
586
+ rulings: [
587
+ createRuling('tiv', 'gates', 'tiv', 'restriction', {
588
+ message: 'TIV exceeds the maximum',
589
+ }),
590
+ ],
591
+ },
592
+ )
593
+
594
+ const qualifier = createQualifier()
595
+ const result = qualifier.qualify({ total: 1_250_000 }, definition)
596
+
597
+ result.eligibility // 'ineligible'
598
+ result.derivations.map((entry) => [entry.id, entry.value])
599
+ // [['cap', 1000000], ['excess', 250000]]
600
+ ```
601
+
602
+ The caller's subject stays `{ total: 1_250_000 }` — not polluted with `cap`,
603
+ `excess`, `blocked`, rule ids, or internal projection fields.
604
+
605
+ ### Scoped exclusion
606
+
607
+ A scope is an opaque string to the qualifier. The caller is responsible for
608
+ matching scope ids to whatever it selects for downstream work.
609
+
610
+ ```ts
611
+ const wind = createLogicalDefinition('wind', 'Wind eligibility', [
612
+ createRule('coastal', [createAtom('distance', 'to', 2)], createAtom('blocked', 'equals', true)),
613
+ ])
614
+
615
+ const definition = createQualificationDefinition('property', 'Property eligibility', [wind], {
616
+ rulings: [
617
+ createRuling('coastal', 'wind', 'coastal', 'restriction', {
618
+ scope: 'wind',
619
+ message: 'Wind coverage is unavailable within two miles of saltwater',
620
+ }),
621
+ ],
622
+ })
623
+
624
+ const result = qualifier.qualify({ distance: 1.5 }, definition)
625
+
626
+ result.eligibility // 'eligible'
627
+ result.scopes.wind // 'ineligible'
628
+ ```
629
+
630
+ The caller then filters by scoped eligibility:
631
+
632
+ ```ts
633
+ const selected = items.filter((item) => {
634
+ const eligibility = result.scopes[item.id]
635
+ return eligibility === undefined || eligibility === 'eligible'
636
+ })
637
+ ```
638
+
639
+ ### Conditions do not block downstream work
640
+
641
+ A conditional ruling is authored like a blocking one, with a scope and a message:
642
+
643
+ ```ts
644
+ const condition = createRuling('vacant', 'gates', 'vacant', 'condition', {
645
+ scope: 'exWind',
646
+ message: 'Vacancy terms apply',
647
+ })
648
+ ```
649
+
650
+ An applied condition produces:
651
+
652
+ ```ts
653
+ result.scopes.exWind // 'eligible'
654
+ result.findings.find((finding) => finding.id === 'vacant')?.effect // 'condition'
655
+ ```
656
+
657
+ A `condition` does not block qualification for its scope — it surfaces evidence
658
+ only. Any downstream status derived from that evidence is outside this package.
659
+
660
+ ### Referral blocks downstream work
661
+
662
+ A referral ruling names the review a subject needs, and takes no scope here:
663
+
664
+ ```ts
665
+ const referral = createRuling('roof', 'gates', 'roof', 'referral', {
666
+ message: 'Roof age requires manual review',
667
+ })
668
+ ```
669
+
670
+ An unscoped applied referral returns `eligibility: 'referral'`. A caller that runs
671
+ qualification ahead of a downstream step must treat that outcome as terminal and
672
+ skip that step.
673
+
674
+ ### Engine injection
675
+
676
+ A standalone `Qualifier` creates and owns a reason engine containing quantitative and
677
+ logical reasoners, destroying it on `destroy()`. When a caller composes multiple
678
+ packages over one engine it injects that engine through the `engine` option; an
679
+ injected engine is caller-owned and never destroyed by the qualifier. This mirrors
680
+ `QualifierOptions.engine` and the `#owned` flag on the implementation.
681
+
682
+ ```ts
683
+ import { createLogicalReasoner, createQuantitativeReasoner, createReason } from '@orkestrel/reason'
684
+ import { createQualifier } from '@orkestrel/qualifier'
685
+
686
+ const engine = createReason({
687
+ reasoners: [createQuantitativeReasoner(), createLogicalReasoner()],
688
+ bail: false,
689
+ })
690
+
691
+ const qualifier = createQualifier({ engine })
692
+ qualifier.destroy() // does not destroy the injected engine
693
+ engine.destroy()
694
+ ```
695
+
696
+ Premise evidence is rendered through an internal, stateless `@orkestrel/reason`
697
+ evaluator (`createEvaluator()`). Because that evaluator is deterministic and holds no
698
+ state, a finding can never disagree with the pass that produced it, whether the engine
699
+ is owned or injected — so there is no separate `evaluator` option to keep in sync.
700
+
701
+ The `labels` option overrides the display name a premise field renders under. Key it by
702
+ the dot-joined field path — `age`, `qualification.cap` — and the override wins over the
703
+ premise's own `label`, which in turn wins over the raw path.
704
+
705
+ ### Observing
706
+
707
+ The `on` option carries the initial emitter hooks, and `error` handles a listener
708
+ throw:
709
+
710
+ ```ts
711
+ const qualifier = createQualifier({
712
+ on: {
713
+ derive: (derivation) => audit.record('derive', derivation.id),
714
+ finding: (finding) => audit.record('finding', finding.id),
715
+ qualify: (result) => audit.record('qualify', result.eligibility),
716
+ },
717
+ error: (error, event) => logger.warn(event, error),
718
+ })
719
+ ```
720
+
721
+ Events are synchronous and observational. A throwing listener is isolated by the
722
+ emitter and never changes qualification semantics.
723
+
724
+ ### Caller composition
725
+
726
+ Qualification is typically the first step in a larger pipeline. The caller owns
727
+ orchestration — `Qualifier` only reports eligibility:
728
+
729
+ ```ts
730
+ const qualification = qualifier.qualify(subject, definition)
731
+
732
+ if (!qualification.success || qualification.eligibility !== 'eligible') {
733
+ return { qualification }
734
+ }
735
+
736
+ const selected = items.filter((item) => {
737
+ const eligibility = qualification.scopes[item.id]
738
+ return eligibility === undefined || eligibility === 'eligible'
739
+ })
740
+
741
+ return { qualification, selected }
742
+ ```
743
+
744
+ When `qualification.eligibility` is not `'eligible'`, the caller skips downstream
745
+ work. That skip is auditable proof that eligibility stopped the pipeline.
746
+
747
+ ### Batch aggregates
748
+
749
+ When qualification must consider totals across many subjects, the caller builds a
750
+ temporary aggregate subject for `qualify` and still passes the original subject to
751
+ any downstream step:
752
+
753
+ ```ts
754
+ const qualified = {
755
+ ...subject,
756
+ aggregate: {
757
+ count: subjects.length,
758
+ sums,
759
+ },
760
+ }
761
+
762
+ const qualification = qualifier.qualify(qualified, definition)
763
+ ```
764
+
765
+ `aggregate`, `outcome`, tallies, notices, authority, status, and decision never
766
+ belong in the qualification subject the caller forwards downstream.
767
+
768
+ ### Scoped eligibility drives selection
769
+
770
+ A single qualification definition can carry both global gates and per-scope scoped
771
+ rulings. Scoped restrictions remove only the named scope before downstream work; a
772
+ global restriction stops the pipeline entirely.
773
+
774
+ ```ts
775
+ const definition = createQualificationDefinition(
776
+ 'property',
777
+ 'Property eligibility',
778
+ [cap, excess, wind, gates],
779
+ {
780
+ rulings: [
781
+ createRuling('frame', 'wind', 'frame', 'restriction', {
782
+ scope: 'wind',
783
+ message: 'No wind coverage for Frame construction',
784
+ }),
785
+ createRuling('saltwater', 'wind', 'saltwater', 'restriction', {
786
+ scope: 'wind',
787
+ message: 'No wind coverage within two miles of saltwater',
788
+ }),
789
+ createRuling('excluded', 'gates', 'excluded', 'restriction', {
790
+ message: 'Occupancy type is ineligible',
791
+ }),
792
+ ],
793
+ },
794
+ )
795
+ ```
796
+
797
+ The caller never evaluates an excluded scope merely to discard its outcome:
798
+
799
+ ```ts
800
+ const result = qualifier.qualify(subject, definition)
801
+
802
+ if (result.eligibility !== 'eligible') {
803
+ return { qualification: result }
804
+ }
805
+
806
+ const selected = ['wind', 'exWind'].filter((id) => {
807
+ const eligibility = result.scopes[id]
808
+ return eligibility === undefined || eligibility === 'eligible'
809
+ })
810
+ // ['exWind'] when wind is scoped ineligible
811
+ ```
812
+
813
+ No synthetic `windBlocked` field enters the caller's subject, and no downstream work
814
+ runs for a scope the qualifier already excluded.
815
+
816
+ ## Tests
817
+
818
+ Tests mirror the source structure and drive real reasoners.
819
+
820
+ - [`tests/src/core/Qualifier.test.ts`](../tests/src/core/Qualifier.test.ts) — proves pass
821
+ order, terminal and scoped eligibility, the untouched caller subject, fresh frozen
822
+ results, reserved-key rejection, semantic validation, engine ownership, event order and
823
+ listener isolation, and each `QualifierError` code.
824
+ - [`tests/src/core/helpers.test.ts`](../tests/src/core/helpers.test.ts) — proves each
825
+ exported helper alone: interpolation, premise rendering, projection, derivation, finding
826
+ and eligibility derivation, reference and warning messages, and engine-error mapping.
827
+ - [`tests/src/core/validators.test.ts`](../tests/src/core/validators.test.ts) — proves each
828
+ guard's posture against cyclic, deeply nested, and prototype-hostile records.
829
+ - [`tests/src/core/factories.test.ts`](../tests/src/core/factories.test.ts) — proves each
830
+ factory returns a fresh value, copies what the caller supplies, and omits every absent
831
+ optional key.
832
+ - [`tests/setup.test.ts`](../tests/setup.test.ts) — proves the shared fixtures the suites
833
+ are built on: the qualification definition builders, the adversarial records, the
834
+ orderings builder, and the failing engine.
835
+ - [`tests/guides.test.ts`](../tests/guides.test.ts) — proves this guide against the barrel:
836
+ every documented name resolves, every export is documented, each flagship fence returns the
837
+ value its comments claim, and the equality gate holds — every `Summary` cell against its
838
+ declaration's description paragraph, the titled `Create a qualifier` fence against the
839
+ `@example` block of that title (pinned so the titled pair cannot be retired silently), and
840
+ the README pitch against this guide's tagline.
841
+
842
+ ## Practices
843
+
844
+ - Qualify before any downstream work or decision step.
845
+ - Treat `success: false` as terminal and fail closed to referral.
846
+ - Use unscoped rulings for global eligibility and scoped rulings for per-scope selection.
847
+ - Keep quantitative derivations under `qualification`; never flatten them onto the caller's subject.
848
+ - Pass the original subject to downstream consumers — never the working projection.
849
+ - Let the caller own aggregation, notices, authority, status, and decision.
850
+ - Inject one shared reason engine when composing multiple packages over the same engine.
851
+ - Validate untrusted definitions structurally, then semantically.
852
+ - Store plain definitions and results, never live entities.
853
+ - Destroy owned entities and emitters in dependency order.
854
+ - Add no compatibility aliases or cross-package re-exports.