@orkestrel/scaffold 0.0.66 → 0.0.68
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.
- package/dist/bin/main.js +67 -44
- package/dist/bin/main.js.map +1 -1
- package/dist/host/agents/templates/brief.md +9 -0
- package/dist/host/claude/agents/orkestrel.md +8 -8
- package/dist/host/claude/rules/names.md +15 -0
- package/dist/host/claude/rules/tests.md +33 -4
- package/dist/host/claude/rules/workspace.md +14 -2
- package/dist/host/dotfiles/prettierignore +3 -0
- package/dist/host/guides/README.md +65 -0
- package/dist/host/guides/abort.md +169 -0
- package/dist/host/guides/agent.md +1509 -0
- package/dist/host/guides/brief.md +1266 -0
- package/dist/host/guides/browser.md +2200 -0
- package/dist/host/guides/budget.md +196 -0
- package/dist/host/guides/codec.md +519 -0
- package/dist/host/guides/console.md +785 -0
- package/dist/host/guides/contract.md +1193 -0
- package/dist/host/guides/csv.md +541 -0
- package/dist/host/guides/database.md +2518 -0
- package/dist/host/guides/emitter.md +233 -0
- package/dist/host/guides/form.md +1791 -0
- package/dist/host/guides/html.md +717 -0
- package/dist/host/guides/indexeddb.md +505 -0
- package/dist/host/guides/interpret.md +1029 -0
- package/dist/host/guides/lsp.md +515 -0
- package/dist/host/guides/markdown.md +964 -0
- package/dist/host/guides/mcp.md +5554 -0
- package/dist/host/guides/middleware.md +927 -0
- package/dist/host/guides/msg.md +440 -0
- package/dist/host/guides/ndjson.md +120 -0
- package/dist/host/guides/ollama.md +380 -0
- package/dist/host/guides/pool.md +280 -0
- package/dist/host/guides/probe.md +1210 -0
- package/dist/host/guides/process.md +1620 -0
- package/dist/host/guides/program.md +1110 -0
- package/dist/host/guides/qualifier.md +854 -0
- package/dist/host/guides/queue.md +370 -0
- package/dist/host/guides/rater.md +330 -0
- package/dist/host/guides/reason.md +1122 -0
- package/dist/host/guides/relation.md +373 -0
- package/dist/host/guides/router.md +753 -0
- package/dist/host/guides/scaffold.md +192 -31
- package/dist/host/guides/sea.md +383 -0
- package/dist/host/guides/server.md +752 -0
- package/dist/host/guides/sqlite.md +330 -0
- package/dist/host/guides/sse.md +187 -0
- package/dist/host/guides/supervisor.md +4890 -0
- package/dist/host/guides/table.md +1556 -0
- package/dist/host/guides/template.md +280 -0
- package/dist/host/guides/terminal.md +1145 -0
- package/dist/host/guides/test.md +2969 -0
- package/dist/host/guides/timeout.md +252 -0
- package/dist/host/guides/tool.md +311 -0
- package/dist/host/guides/toolbox.md +1038 -0
- package/dist/host/guides/websocket.md +282 -0
- package/dist/host/guides/worker.md +615 -0
- package/dist/host/guides/workflow.md +1507 -0
- package/dist/host/guides/workspace.md +595 -0
- package/dist/host/manifest.json +1218 -10
- package/dist/host/tests/policy.test.ts +279 -2
- package/dist/host/tests/setupPolicy.ts +437 -6
- package/dist/src/core/index.cjs +44 -22
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +33 -9
- package/dist/src/core/index.d.ts +33 -9
- package/dist/src/core/index.js +43 -23
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +1750 -1567
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +106 -24
- package/dist/src/server/index.d.ts +106 -24
- package/dist/src/server/index.js +1751 -1570
- package/dist/src/server/index.js.map +1 -1
- package/package.json +9 -9
|
@@ -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.
|