@affiant/core 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +354 -0
  3. package/dist/context.d.ts +136 -0
  4. package/dist/context.d.ts.map +1 -0
  5. package/dist/context.js +30 -0
  6. package/dist/context.js.map +1 -0
  7. package/dist/docket/entry.d.ts +421 -0
  8. package/dist/docket/entry.d.ts.map +1 -0
  9. package/dist/docket/entry.js +155 -0
  10. package/dist/docket/entry.js.map +1 -0
  11. package/dist/docket/expiry.d.ts +82 -0
  12. package/dist/docket/expiry.d.ts.map +1 -0
  13. package/dist/docket/expiry.js +106 -0
  14. package/dist/docket/expiry.js.map +1 -0
  15. package/dist/docket/memory.d.ts +163 -0
  16. package/dist/docket/memory.d.ts.map +1 -0
  17. package/dist/docket/memory.js +528 -0
  18. package/dist/docket/memory.js.map +1 -0
  19. package/dist/docket/store.d.ts +387 -0
  20. package/dist/docket/store.d.ts.map +1 -0
  21. package/dist/docket/store.js +51 -0
  22. package/dist/docket/store.js.map +1 -0
  23. package/dist/errors.d.ts +153 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/errors.js +164 -0
  26. package/dist/errors.js.map +1 -0
  27. package/dist/gate/coverage.d.ts +152 -0
  28. package/dist/gate/coverage.d.ts.map +1 -0
  29. package/dist/gate/coverage.js +114 -0
  30. package/dist/gate/coverage.js.map +1 -0
  31. package/dist/gate/decide.d.ts +207 -0
  32. package/dist/gate/decide.d.ts.map +1 -0
  33. package/dist/gate/decide.js +559 -0
  34. package/dist/gate/decide.js.map +1 -0
  35. package/dist/gate/gate.d.ts +212 -0
  36. package/dist/gate/gate.d.ts.map +1 -0
  37. package/dist/gate/gate.js +175 -0
  38. package/dist/gate/gate.js.map +1 -0
  39. package/dist/gate/pipeline.d.ts +285 -0
  40. package/dist/gate/pipeline.d.ts.map +1 -0
  41. package/dist/gate/pipeline.js +515 -0
  42. package/dist/gate/pipeline.js.map +1 -0
  43. package/dist/gate/policy.d.ts +272 -0
  44. package/dist/gate/policy.d.ts.map +1 -0
  45. package/dist/gate/policy.js +396 -0
  46. package/dist/gate/policy.js.map +1 -0
  47. package/dist/gate/wrap.d.ts +107 -0
  48. package/dist/gate/wrap.d.ts.map +1 -0
  49. package/dist/gate/wrap.js +164 -0
  50. package/dist/gate/wrap.js.map +1 -0
  51. package/dist/index.d.ts +95 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +111 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/model/affidavit.d.ts +354 -0
  56. package/dist/model/affidavit.d.ts.map +1 -0
  57. package/dist/model/affidavit.js +417 -0
  58. package/dist/model/affidavit.js.map +1 -0
  59. package/dist/model/amendments.d.ts +160 -0
  60. package/dist/model/amendments.d.ts.map +1 -0
  61. package/dist/model/amendments.js +183 -0
  62. package/dist/model/amendments.js.map +1 -0
  63. package/dist/model/canonical.d.ts +311 -0
  64. package/dist/model/canonical.d.ts.map +1 -0
  65. package/dist/model/canonical.js +665 -0
  66. package/dist/model/canonical.js.map +1 -0
  67. package/dist/model/money.d.ts +127 -0
  68. package/dist/model/money.d.ts.map +1 -0
  69. package/dist/model/money.js +177 -0
  70. package/dist/model/money.js.map +1 -0
  71. package/dist/model/provenance.d.ts +315 -0
  72. package/dist/model/provenance.d.ts.map +1 -0
  73. package/dist/model/provenance.js +223 -0
  74. package/dist/model/provenance.js.map +1 -0
  75. package/dist/ports.d.ts +269 -0
  76. package/dist/ports.d.ts.map +1 -0
  77. package/dist/ports.js +34 -0
  78. package/dist/ports.js.map +1 -0
  79. package/dist/store-memory.d.ts +21 -0
  80. package/dist/store-memory.d.ts.map +1 -0
  81. package/dist/store-memory.js +20 -0
  82. package/dist/store-memory.js.map +1 -0
  83. package/dist/telemetry-keys.d.ts +65 -0
  84. package/dist/telemetry-keys.d.ts.map +1 -0
  85. package/dist/telemetry-keys.js +72 -0
  86. package/dist/telemetry-keys.js.map +1 -0
  87. package/dist/telemetry.d.ts +77 -0
  88. package/dist/telemetry.d.ts.map +1 -0
  89. package/dist/telemetry.js +43 -0
  90. package/dist/telemetry.js.map +1 -0
  91. package/dist/testing.d.ts +574 -0
  92. package/dist/testing.d.ts.map +1 -0
  93. package/dist/testing.js +1291 -0
  94. package/dist/testing.js.map +1 -0
  95. package/package.json +75 -0
  96. package/telemetry-keys.json +92 -0
@@ -0,0 +1,272 @@
1
+ /**
2
+ * The approval-policy chain: what a write needs before it may execute, and the three
3
+ * checks that stop a person-free approval from resting on something uncheckable or
4
+ * incomplete.
5
+ *
6
+ * **Rules served: AZ-4** (the four requirement kinds; a level this version does not
7
+ * run is recorded verbatim and blocked, never degraded to a weaker one), **PV-4** (a
8
+ * verdict with no person present never depends on an unbound tag above
9
+ * `Conversation`), **GT-5** (the risk function and its thresholds are host-supplied,
10
+ * this package owns only the comparison — and a Standing Order never fires while a
11
+ * proposed field the entity requires has no known value), **GT-4** (the deadline is
12
+ * the policy's to name, and a deadline that is not a deadline is refused rather than
13
+ * stamped), **CV-1** (a declared threshold with no scorer is a wire-up error, raised
14
+ * in `gate.ts` before any evaluation).
15
+ *
16
+ * ## What a policy is
17
+ *
18
+ * A policy is a host object with an id, a version, the provenance sources it
19
+ * predicates on, and an `evaluate` that returns a {@link Verdict} or `null` for "I
20
+ * have no opinion". The chain runs in order and **the first non-null verdict wins**;
21
+ * a chain that produces none defaults to {@link Verdict.requirement}
22
+ * `"ReviewerConfirmation"` — a person confirms. That default is the fail-closed
23
+ * direction: a gate with no policies at all asks a person about everything.
24
+ *
25
+ * ## Why `declaredInputs` exists
26
+ *
27
+ * PV-4 asks a question the Affidavit alone cannot answer: *did this verdict depend
28
+ * on a grade a caller could have asserted with nothing behind it?* A policy that
29
+ * predicates only on field values, or on host state, or on tags at or below
30
+ * `Conversation`, is unaffected by the rule — the turn is its own artifact. A policy
31
+ * that predicates on `UserStated`, `External` or `Computed` is claiming an artifact
32
+ * outside the conversation, and the check is that the artifact is actually pointed
33
+ * at (a binding, PV-2). So the policy declares what it predicates on, and
34
+ * the gate checks the declaration against the tags in force. This is what the rule
35
+ * means by "conformance fixtures assert on the policy's declared inputs, not on the
36
+ * Affidavit alone".
37
+ *
38
+ * ## Why a degrade rather than a refusal
39
+ *
40
+ * When any of the three checks fails the verdict does not disappear and the proposal
41
+ * is not thrown away: the requirement degrades to `"ReviewerConfirmation"` and a
42
+ * person is asked. Degrading *toward* a person is always safe; AZ-4's prohibition is
43
+ * on degrading to something weaker, which is the other direction.
44
+ *
45
+ * ## Why an empty required field blocks a person-free approval
46
+ *
47
+ * A field the entity requires, proposed with no value and tagged `Empty`, is the one
48
+ * hole a confidence number cannot describe: `aggregateConfidence` is already `0.0`
49
+ * whenever any proposed field is `Empty` (AF-2), and a host that keys its Standing
50
+ * Order on `populatedConfidence` — the minimum over the fields that *were* filled —
51
+ * reads a high number over a proposal that is missing something the write cannot do
52
+ * without. PV-4 cannot reach the case either, because `Empty` sits at the bottom of
53
+ * the ladder rather than above `Conversation`. So the rule is structural: a Standing
54
+ * Order never fires while a proposed field marked mandatory reads `Empty`, whatever
55
+ * the numbers say. A person may still approve — they can see the hole, and approving
56
+ * is of what was sworn to, not a licence to invent the missing value.
57
+ *
58
+ * An **optional** field left `Empty` does not block a Standing Order by rule. A host
59
+ * that wants it to can predicate its own policy on `populatedConfidence` or
60
+ * `emptyFieldCount`, which is where a floor belongs: this package defines no
61
+ * threshold on any of the three numbers (AF-2, GT-5).
62
+ *
63
+ * @packageDocumentation
64
+ */
65
+ import type { TurnContext } from "../context.js";
66
+ import type { RequirementKind } from "../docket/entry.js";
67
+ import type { Affidavit } from "../model/affidavit.js";
68
+ import type { ProvenanceSource } from "../model/provenance.js";
69
+ import type { RiskScorer, TelemetryPort } from "../ports.js";
70
+ /**
71
+ * What a policy says a write needs.
72
+ *
73
+ * `ttlMs` is the policy's own deadline for this write; GT-4 stamps `expiresAt` from
74
+ * it **after** the chain has run, so a policy that knows a capture is worthless in
75
+ * five minutes can say so. It must be a whole number of milliseconds, one or more:
76
+ * `0` files an entry that reads `expired` on the very read that files it, and a
77
+ * negative or `NaN` one is worse (see {@link evaluatePolicies}). `threshold` is
78
+ * meaningful on a `"StandingOrder"` verdict only — it is the ceiling a
79
+ * host-supplied {@link RiskScorer}'s score must not exceed — and naming one on any
80
+ * other requirement is a caller bug, not a request (a `RangeError`). `reason` is
81
+ * carried onto the reviewer's card so a person can see why they are being asked.
82
+ */
83
+ export interface Verdict {
84
+ /** What this write needs before it may execute (AZ-4). */
85
+ readonly requirement: RequirementKind;
86
+ /**
87
+ * The policy's deadline for this write, in milliseconds. Applied by GT-4, after
88
+ * the chain. A whole number, one or more; anything else is a policy contract
89
+ * violation and refused (`wireup-invalid`).
90
+ */
91
+ readonly ttlMs?: number;
92
+ /** The risk ceiling, on a `"StandingOrder"` verdict only (GT-5). */
93
+ readonly threshold?: number;
94
+ /** Why, in one line, for the reviewer's card. */
95
+ readonly reason?: string;
96
+ }
97
+ /**
98
+ * A host's approval policy.
99
+ *
100
+ * `declaresThreshold` is the **static** twin of {@link Verdict.threshold}: a policy
101
+ * says up front whether any verdict it can return will name a threshold, so
102
+ * `createGate` can refuse a gate whose policies need a scorer it was not given
103
+ * (CV-1) — at wire-up, before a single proposal, rather than on the unlucky request
104
+ * that first happens to reach the threshold branch. GT-5 is explicit that this is a
105
+ * configuration error and "never a silent non-fire", and a check that only fires on
106
+ * some inputs is a silent non-fire on the others.
107
+ */
108
+ export interface ApprovalPolicy {
109
+ /** The host's id for this policy. Written into a Standing Order attestation (AZ-1). */
110
+ readonly id: string;
111
+ /** The version of the policy that is speaking, so a later reader knows what it said. */
112
+ readonly version: string;
113
+ /**
114
+ * The provenance sources this policy predicates on (PV-4). Empty for a policy that
115
+ * looks only at values or host state.
116
+ */
117
+ readonly declaredInputs: readonly ProvenanceSource[];
118
+ /** Whether any verdict this policy can return names a {@link Verdict.threshold} (GT-5, CV-1). */
119
+ readonly declaresThreshold?: boolean;
120
+ /**
121
+ * The policy's default TTL, used when its verdict names none (GT-4). Held to the
122
+ * same rule as {@link Verdict.ttlMs}: a whole number of milliseconds, one or more.
123
+ * `createGate` refuses a policy carrying anything else at wire-up (CV-1).
124
+ */
125
+ readonly defaultTtlMs?: number;
126
+ /** What this policy says about `affidavit` in `ctx`, or `null` for no opinion. */
127
+ evaluate(affidavit: Affidavit, ctx: TurnContext): Promise<Verdict | null>;
128
+ }
129
+ /**
130
+ * The field and grade that failed PV-4: a tag the winning policy declared it
131
+ * predicates on, above `Conversation`, pointing at nothing.
132
+ */
133
+ export interface UnboundInput {
134
+ /** The field carrying the tag. */
135
+ readonly field: string;
136
+ /** The grade the tag claims. */
137
+ readonly source: ProvenanceSource;
138
+ }
139
+ /**
140
+ * What the chain decided, with everything the filing step needs to write the row.
141
+ *
142
+ * `requirement` rather than `verdict.requirement` because the two differ exactly
143
+ * when a degrade happened, and the row records the requirement that is **in force**.
144
+ * `degradedFrom` keeps the fact of the degrade visible; PV-4 and GT-5 are both
145
+ * "asked a person instead", and a record that did not say so would look like a
146
+ * policy that simply asked for confirmation.
147
+ */
148
+ export interface PolicyOutcome {
149
+ /** The verdict the winning policy returned, or the default. */
150
+ readonly verdict: Verdict;
151
+ /** The policy that spoke, or `null` when none did. */
152
+ readonly policy: ApprovalPolicy | null;
153
+ /** The requirement in force after PV-4 and GT-5 have had their say. */
154
+ readonly requirement: RequirementKind;
155
+ /** `verdict.ttlMs ?? policy.defaultTtlMs`, or `null` to fall through to the gate's default (GT-4). */
156
+ readonly ttlMs: number | null;
157
+ /** `"StandingOrder"` when a Standing Order was not honoured, else `null`. */
158
+ readonly degradedFrom: "StandingOrder" | null;
159
+ /** Why, in one line: the policy's own reason, or the reason the degrade happened. */
160
+ readonly reason: string | null;
161
+ /** The score the host's scorer returned, or `null` when no threshold was compared. */
162
+ readonly riskScore: number | null;
163
+ /** The tag that failed PV-4, or `null`. */
164
+ readonly unboundInput: UnboundInput | null;
165
+ /**
166
+ * The proposed fields marked mandatory that read `Empty` and so stopped a Standing
167
+ * Order from firing (GT-5), or `null` when that check did not block.
168
+ *
169
+ * `null` rather than an empty array, the same way {@link PolicyOutcome.unboundInput}
170
+ * is `null`: the property says *this is why the verdict degraded*, and a list that
171
+ * was empty on every other outcome would read as "checked and found nothing" on
172
+ * verdicts where the check never ran.
173
+ */
174
+ readonly emptyMandatoryFields: readonly string[] | null;
175
+ }
176
+ /** What {@link evaluatePolicies} is given beyond the policies themselves. */
177
+ export interface PolicyChainDeps {
178
+ /** The host's risk function (GT-5). Absent unless a policy declares a threshold. */
179
+ readonly riskScorer?: RiskScorer | undefined;
180
+ /** Where a `standing-order.blocked` event goes. */
181
+ readonly telemetry?: TelemetryPort | undefined;
182
+ /** The instant to stamp on a telemetry event. Passed in; nothing here reads a clock. */
183
+ readonly now: string;
184
+ }
185
+ /**
186
+ * Run `policies` in order and return the first non-null verdict, with PV-4 and GT-5
187
+ * applied to it; `"ReviewerConfirmation"` when no policy speaks.
188
+ *
189
+ * The three checks run **only** on a `"StandingOrder"` verdict, because they are all
190
+ * about approving with no person present, and in this order:
191
+ *
192
+ * 1. **GT-5, the empty required field.** No proposed field marked mandatory may read
193
+ * `Empty`. First because it is the cheapest read and the least conditional: it
194
+ * depends on nothing the policy declared and nothing a host port returns, so a
195
+ * proposal with a hole in it degrades identically under every wiring.
196
+ * 2. **PV-4.** Every field whose tag in force names one of the policy's
197
+ * {@link ApprovalPolicy.declaredInputs} and sits above `Conversation` must carry
198
+ * a binding. The first that does not degrades the verdict. Checked first because
199
+ * it is a pure read of the Affidavit — there is no reason to spend a host's
200
+ * scorer on a verdict that is already going to a person.
201
+ * 3. **GT-5, the threshold.** A verdict that names a {@link Verdict.threshold} fires iff
202
+ * `score <= threshold`. The comparison is written as `!(score <= threshold)` so
203
+ * that a `NaN` score — a scorer that failed to produce a number — blocks rather
204
+ * than fires. A verdict that names no threshold fires on the verdict alone.
205
+ *
206
+ * More than one check can be true of the same proposal. The first one to fire is the
207
+ * one the record names, and the row degrades exactly once: `requirement` reads
208
+ * `"ReviewerConfirmation"` and `degradedFrom` reads `"StandingOrder"` however many
209
+ * of them applied.
210
+ *
211
+ * Only the tag **in force** on each field is checked, not the superseded tags in the
212
+ * chain behind it: PV-4 asks what the verdict rests on, and a verdict rests on the
213
+ * values the Affidavit currently swears to. The displaced tags stay on the record for
214
+ * a reviewer to read.
215
+ *
216
+ * @throws AffiantError `"wireup-invalid"` in three cases, all of them a policy
217
+ * breaking its own contract: a verdict names a threshold and no scorer was
218
+ * supplied (`createGate` refuses this at wire-up from
219
+ * {@link ApprovalPolicy.declaresThreshold}; this is the backstop for a policy
220
+ * that returned a threshold without declaring it could); a verdict's `ttlMs`
221
+ * or the policy's `defaultTtlMs` is not a whole number of milliseconds, one
222
+ * or more (GT-4); or the policy's `evaluate` threw. In every case nothing is
223
+ * filed and a `policy.invalid` event is on the telemetry port before the
224
+ * throw.
225
+ * @throws RangeError if a verdict names a requirement that is not one of the four, or
226
+ * names a threshold on a requirement other than `"StandingOrder"`.
227
+ */
228
+ export declare function evaluatePolicies(policies: readonly ApprovalPolicy[], affidavit: Affidavit, ctx: TurnContext, deps: PolicyChainDeps): Promise<PolicyOutcome>;
229
+ /**
230
+ * The first field whose tag in force is a grade the policy predicates on, sits above
231
+ * `Conversation`, and points at nothing (PV-4), or `null` when every declared input
232
+ * is honourable.
233
+ *
234
+ * Exported because it is the whole of PV-4's runtime half and a fixture should be
235
+ * able to ask it directly, without staging a policy that returns a Standing Order.
236
+ */
237
+ export declare function unboundDeclaredInput(policy: ApprovalPolicy, affidavit: Affidavit): UnboundInput | null;
238
+ /**
239
+ * The proposed fields marked mandatory whose tag in force is `Empty` — a field the
240
+ * entity requires, sworn to with no known value — in the order the Affidavit lists
241
+ * them. Empty when there are none.
242
+ *
243
+ * `Empty` is the tag AF-1 puts on a proposed field whose provenance is unknown, which
244
+ * is what the pipeline writes when nothing — no interceptor, no inference, no host
245
+ * argument — produced a value for it. So this is exactly "the model could not fill a
246
+ * field the write needs", asked of the record rather than of a confidence number.
247
+ *
248
+ * Exported because it is the whole of the rule's runtime half and a fixture should be
249
+ * able to ask it directly, without staging a policy that returns a Standing Order.
250
+ */
251
+ export declare function emptyMandatoryFields(affidavit: Affidavit): readonly string[];
252
+ /**
253
+ * Whether `ttlMs` is a deadline: a finite whole number of milliseconds, one or more.
254
+ *
255
+ * Exported so `createGate` holds {@link ApprovalPolicy.defaultTtlMs} and
256
+ * `GateOptions.defaultTtlMs` to one definition rather than two that can drift.
257
+ */
258
+ export declare function isUsableTtlMs(ttlMs: unknown): ttlMs is number;
259
+ /**
260
+ * The refusal message for a policy that named an unusable deadline. One function so
261
+ * the wire-up refusal and the per-request one read identically.
262
+ */
263
+ export declare function unusableTtlMessage(policyId: string, option: "ttlMs" | "defaultTtlMs", ttlMs: unknown): string;
264
+ /**
265
+ * Why a Standing Order was not honoured, as a value an operator can alert on.
266
+ *
267
+ * Separate from the sentence in `reason`, which is written for the person reading the
268
+ * card and is free to be rephrased. A dashboard that counted degrades by matching on
269
+ * prose would break the first time the wording improved.
270
+ */
271
+ export type StandingOrderBlockedReason = "mandatory-field-empty" | "unbound-declared-input" | "risk-above-threshold";
272
+ //# sourceMappingURL=policy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/gate/policy.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,396 @@
1
+ /**
2
+ * The approval-policy chain: what a write needs before it may execute, and the three
3
+ * checks that stop a person-free approval from resting on something uncheckable or
4
+ * incomplete.
5
+ *
6
+ * **Rules served: AZ-4** (the four requirement kinds; a level this version does not
7
+ * run is recorded verbatim and blocked, never degraded to a weaker one), **PV-4** (a
8
+ * verdict with no person present never depends on an unbound tag above
9
+ * `Conversation`), **GT-5** (the risk function and its thresholds are host-supplied,
10
+ * this package owns only the comparison — and a Standing Order never fires while a
11
+ * proposed field the entity requires has no known value), **GT-4** (the deadline is
12
+ * the policy's to name, and a deadline that is not a deadline is refused rather than
13
+ * stamped), **CV-1** (a declared threshold with no scorer is a wire-up error, raised
14
+ * in `gate.ts` before any evaluation).
15
+ *
16
+ * ## What a policy is
17
+ *
18
+ * A policy is a host object with an id, a version, the provenance sources it
19
+ * predicates on, and an `evaluate` that returns a {@link Verdict} or `null` for "I
20
+ * have no opinion". The chain runs in order and **the first non-null verdict wins**;
21
+ * a chain that produces none defaults to {@link Verdict.requirement}
22
+ * `"ReviewerConfirmation"` — a person confirms. That default is the fail-closed
23
+ * direction: a gate with no policies at all asks a person about everything.
24
+ *
25
+ * ## Why `declaredInputs` exists
26
+ *
27
+ * PV-4 asks a question the Affidavit alone cannot answer: *did this verdict depend
28
+ * on a grade a caller could have asserted with nothing behind it?* A policy that
29
+ * predicates only on field values, or on host state, or on tags at or below
30
+ * `Conversation`, is unaffected by the rule — the turn is its own artifact. A policy
31
+ * that predicates on `UserStated`, `External` or `Computed` is claiming an artifact
32
+ * outside the conversation, and the check is that the artifact is actually pointed
33
+ * at (a binding, PV-2). So the policy declares what it predicates on, and
34
+ * the gate checks the declaration against the tags in force. This is what the rule
35
+ * means by "conformance fixtures assert on the policy's declared inputs, not on the
36
+ * Affidavit alone".
37
+ *
38
+ * ## Why a degrade rather than a refusal
39
+ *
40
+ * When any of the three checks fails the verdict does not disappear and the proposal
41
+ * is not thrown away: the requirement degrades to `"ReviewerConfirmation"` and a
42
+ * person is asked. Degrading *toward* a person is always safe; AZ-4's prohibition is
43
+ * on degrading to something weaker, which is the other direction.
44
+ *
45
+ * ## Why an empty required field blocks a person-free approval
46
+ *
47
+ * A field the entity requires, proposed with no value and tagged `Empty`, is the one
48
+ * hole a confidence number cannot describe: `aggregateConfidence` is already `0.0`
49
+ * whenever any proposed field is `Empty` (AF-2), and a host that keys its Standing
50
+ * Order on `populatedConfidence` — the minimum over the fields that *were* filled —
51
+ * reads a high number over a proposal that is missing something the write cannot do
52
+ * without. PV-4 cannot reach the case either, because `Empty` sits at the bottom of
53
+ * the ladder rather than above `Conversation`. So the rule is structural: a Standing
54
+ * Order never fires while a proposed field marked mandatory reads `Empty`, whatever
55
+ * the numbers say. A person may still approve — they can see the hole, and approving
56
+ * is of what was sworn to, not a licence to invent the missing value.
57
+ *
58
+ * An **optional** field left `Empty` does not block a Standing Order by rule. A host
59
+ * that wants it to can predicate its own policy on `populatedConfidence` or
60
+ * `emptyFieldCount`, which is where a floor belongs: this package defines no
61
+ * threshold on any of the three numbers (AF-2, GT-5).
62
+ *
63
+ * @packageDocumentation
64
+ */
65
+ import { REQUIREMENT_KINDS } from "../docket/entry.js";
66
+ import { AffiantError, isAffiantError } from "../errors.js";
67
+ import { isHonourable, requiresBinding } from "../model/provenance.js";
68
+ // ---------------------------------------------------------------------------
69
+ // The chain
70
+ // ---------------------------------------------------------------------------
71
+ /**
72
+ * Run `policies` in order and return the first non-null verdict, with PV-4 and GT-5
73
+ * applied to it; `"ReviewerConfirmation"` when no policy speaks.
74
+ *
75
+ * The three checks run **only** on a `"StandingOrder"` verdict, because they are all
76
+ * about approving with no person present, and in this order:
77
+ *
78
+ * 1. **GT-5, the empty required field.** No proposed field marked mandatory may read
79
+ * `Empty`. First because it is the cheapest read and the least conditional: it
80
+ * depends on nothing the policy declared and nothing a host port returns, so a
81
+ * proposal with a hole in it degrades identically under every wiring.
82
+ * 2. **PV-4.** Every field whose tag in force names one of the policy's
83
+ * {@link ApprovalPolicy.declaredInputs} and sits above `Conversation` must carry
84
+ * a binding. The first that does not degrades the verdict. Checked first because
85
+ * it is a pure read of the Affidavit — there is no reason to spend a host's
86
+ * scorer on a verdict that is already going to a person.
87
+ * 3. **GT-5, the threshold.** A verdict that names a {@link Verdict.threshold} fires iff
88
+ * `score <= threshold`. The comparison is written as `!(score <= threshold)` so
89
+ * that a `NaN` score — a scorer that failed to produce a number — blocks rather
90
+ * than fires. A verdict that names no threshold fires on the verdict alone.
91
+ *
92
+ * More than one check can be true of the same proposal. The first one to fire is the
93
+ * one the record names, and the row degrades exactly once: `requirement` reads
94
+ * `"ReviewerConfirmation"` and `degradedFrom` reads `"StandingOrder"` however many
95
+ * of them applied.
96
+ *
97
+ * Only the tag **in force** on each field is checked, not the superseded tags in the
98
+ * chain behind it: PV-4 asks what the verdict rests on, and a verdict rests on the
99
+ * values the Affidavit currently swears to. The displaced tags stay on the record for
100
+ * a reviewer to read.
101
+ *
102
+ * @throws AffiantError `"wireup-invalid"` in three cases, all of them a policy
103
+ * breaking its own contract: a verdict names a threshold and no scorer was
104
+ * supplied (`createGate` refuses this at wire-up from
105
+ * {@link ApprovalPolicy.declaresThreshold}; this is the backstop for a policy
106
+ * that returned a threshold without declaring it could); a verdict's `ttlMs`
107
+ * or the policy's `defaultTtlMs` is not a whole number of milliseconds, one
108
+ * or more (GT-4); or the policy's `evaluate` threw. In every case nothing is
109
+ * filed and a `policy.invalid` event is on the telemetry port before the
110
+ * throw.
111
+ * @throws RangeError if a verdict names a requirement that is not one of the four, or
112
+ * names a threshold on a requirement other than `"StandingOrder"`.
113
+ */
114
+ export async function evaluatePolicies(policies, affidavit, ctx, deps) {
115
+ for (const policy of policies) {
116
+ const verdict = await evaluateOne(deps, policy, affidavit, ctx);
117
+ if (verdict === null || verdict === undefined)
118
+ continue;
119
+ checkVerdict(deps, policy, verdict);
120
+ // GT-4: the fallback is read here, so it is checked here too — a policy whose
121
+ // verdict names no deadline and whose own default is unusable must not reach the
122
+ // filing step with a number that cannot be stamped.
123
+ if (verdict.ttlMs === undefined) {
124
+ checkTtlMs(deps, policy, "defaultTtlMs", policy.defaultTtlMs);
125
+ }
126
+ const ttlMs = verdict.ttlMs ?? policy.defaultTtlMs ?? null;
127
+ const base = {
128
+ verdict,
129
+ policy,
130
+ ttlMs,
131
+ reason: verdict.reason ?? null,
132
+ degradedFrom: null,
133
+ riskScore: null,
134
+ unboundInput: null,
135
+ emptyMandatoryFields: null,
136
+ };
137
+ if (verdict.requirement !== "StandingOrder") {
138
+ return { ...base, requirement: verdict.requirement };
139
+ }
140
+ // GT-5: a Standing Order never fires over a required field with no known value.
141
+ const empties = emptyMandatoryFields(affidavit);
142
+ if (empties.length > 0) {
143
+ const reason = `GT-5: ${empties.map((name) => JSON.stringify(name)).join(", ")} ` +
144
+ `${empties.length === 1 ? "is a field" : "are fields"} the entity requires and ` +
145
+ `${empties.length === 1 ? "it has" : "they have"} no known value; a Standing Order ` +
146
+ `does not fire over an empty required field, and a person is asked instead`;
147
+ emitBlocked(deps, policy, { reason, code: "mandatory-field-empty", fields: empties });
148
+ return {
149
+ ...base,
150
+ requirement: "ReviewerConfirmation",
151
+ degradedFrom: "StandingOrder",
152
+ reason,
153
+ emptyMandatoryFields: empties,
154
+ };
155
+ }
156
+ // PV-4.
157
+ const unbound = unboundDeclaredInput(policy, affidavit);
158
+ if (unbound !== null) {
159
+ const reason = `PV-4: the Standing Order predicates on ${unbound.source}, and ` +
160
+ `${JSON.stringify(unbound.field)} carries a ${unbound.source} tag with no binding; ` +
161
+ `a person is asked instead`;
162
+ emitBlocked(deps, policy, {
163
+ reason,
164
+ code: "unbound-declared-input",
165
+ field: unbound.field,
166
+ source: unbound.source,
167
+ });
168
+ return {
169
+ ...base,
170
+ requirement: "ReviewerConfirmation",
171
+ degradedFrom: "StandingOrder",
172
+ reason,
173
+ unboundInput: unbound,
174
+ };
175
+ }
176
+ // GT-5.
177
+ if (verdict.threshold !== undefined) {
178
+ const scorer = deps.riskScorer;
179
+ if (scorer === undefined) {
180
+ throw new AffiantError("wireup-invalid", `GT-5: policy ${JSON.stringify(policy.id)} returned a Standing Order with a risk ` +
181
+ `threshold and no riskScorer was supplied; this package ships no scoring formula. ` +
182
+ `Supply GateOptions.riskScorer, and declare \`declaresThreshold: true\` on the ` +
183
+ `policy so the gate can refuse this at wire-up (CV-1) rather than here.`, { policyId: policy.id, threshold: verdict.threshold });
184
+ }
185
+ const score = await scorer.score(affidavit, ctx);
186
+ if (!(score <= verdict.threshold)) {
187
+ const reason = `GT-5: the host's risk score ${String(score)} is above the Standing Order's ` +
188
+ `threshold ${String(verdict.threshold)}; a person is asked instead`;
189
+ emitBlocked(deps, policy, {
190
+ reason,
191
+ code: "risk-above-threshold",
192
+ score,
193
+ threshold: verdict.threshold,
194
+ });
195
+ return {
196
+ ...base,
197
+ requirement: "ReviewerConfirmation",
198
+ degradedFrom: "StandingOrder",
199
+ reason,
200
+ riskScore: score,
201
+ };
202
+ }
203
+ return { ...base, requirement: "StandingOrder", riskScore: score };
204
+ }
205
+ return { ...base, requirement: "StandingOrder" };
206
+ }
207
+ return {
208
+ verdict: { requirement: "ReviewerConfirmation" },
209
+ policy: null,
210
+ requirement: "ReviewerConfirmation",
211
+ ttlMs: null,
212
+ degradedFrom: null,
213
+ reason: null,
214
+ riskScore: null,
215
+ unboundInput: null,
216
+ emptyMandatoryFields: null,
217
+ };
218
+ }
219
+ /**
220
+ * Ask one policy, turning a throw out of its `evaluate` into a stated refusal.
221
+ *
222
+ * A host's policy that throws is a host bug, but it reaches the gate through the tool
223
+ * seam, and an unhandled `TypeError` out of a gated `execute` tells a host nothing it
224
+ * can branch on and tells the model nothing at all. So it becomes an
225
+ * {@link AffiantError} carrying `"wireup-invalid"` — the same code every other
226
+ * "this gate is wired wrong" refusal carries — which `wrap` hands back as
227
+ * `{ kind: "error" }`, with the original message inlined so the bug is still
228
+ * findable. **Nothing is filed:** the throw happens in step 7, before the pipeline
229
+ * reaches step 9.
230
+ *
231
+ * An {@link AffiantError} thrown by the policy itself passes through untouched, so a
232
+ * policy that deliberately refuses keeps its own code.
233
+ */
234
+ async function evaluateOne(deps, policy, affidavit, ctx) {
235
+ try {
236
+ return await policy.evaluate(affidavit, ctx);
237
+ }
238
+ catch (cause) {
239
+ if (isAffiantError(cause))
240
+ throw cause;
241
+ const reason = `CV-1: policy ${JSON.stringify(policy.id)} threw from evaluate — ${messageOf(cause)}. ` +
242
+ `A policy that cannot answer is a wiring the gate cannot run: nothing is filed, and the ` +
243
+ `call is refused rather than the throw escaping through the tool seam.`;
244
+ emitPolicyInvalid(deps, policy, "evaluate", reason);
245
+ throw new AffiantError("wireup-invalid", reason, {
246
+ policyId: policy.id,
247
+ option: "evaluate",
248
+ cause: messageOf(cause),
249
+ });
250
+ }
251
+ }
252
+ /**
253
+ * The first field whose tag in force is a grade the policy predicates on, sits above
254
+ * `Conversation`, and points at nothing (PV-4), or `null` when every declared input
255
+ * is honourable.
256
+ *
257
+ * Exported because it is the whole of PV-4's runtime half and a fixture should be
258
+ * able to ask it directly, without staging a policy that returns a Standing Order.
259
+ */
260
+ export function unboundDeclaredInput(policy, affidavit) {
261
+ const declared = new Set(policy.declaredInputs);
262
+ for (const field of affidavit.fields) {
263
+ const tag = field.provenance.current;
264
+ if (!declared.has(tag.source))
265
+ continue;
266
+ if (!requiresBinding(tag.source))
267
+ continue;
268
+ if (isHonourable(tag))
269
+ continue;
270
+ return { field: field.name, source: tag.source };
271
+ }
272
+ return null;
273
+ }
274
+ /**
275
+ * The proposed fields marked mandatory whose tag in force is `Empty` — a field the
276
+ * entity requires, sworn to with no known value — in the order the Affidavit lists
277
+ * them. Empty when there are none.
278
+ *
279
+ * `Empty` is the tag AF-1 puts on a proposed field whose provenance is unknown, which
280
+ * is what the pipeline writes when nothing — no interceptor, no inference, no host
281
+ * argument — produced a value for it. So this is exactly "the model could not fill a
282
+ * field the write needs", asked of the record rather than of a confidence number.
283
+ *
284
+ * Exported because it is the whole of the rule's runtime half and a fixture should be
285
+ * able to ask it directly, without staging a policy that returns a Standing Order.
286
+ */
287
+ export function emptyMandatoryFields(affidavit) {
288
+ const out = [];
289
+ for (const field of affidavit.fields) {
290
+ if (!field.isMandatory)
291
+ continue;
292
+ if (field.provenance.current.source !== "Empty")
293
+ continue;
294
+ out.push(field.name);
295
+ }
296
+ return out;
297
+ }
298
+ /**
299
+ * Refuse a verdict this package cannot act on.
300
+ *
301
+ * Two arms are a `RangeError` rather than an {@link AffiantError}: a policy that
302
+ * names a requirement outside the four, or hangs a risk threshold off a requirement
303
+ * that has nothing to compare, is a programming error in the host's policy and not a
304
+ * refusal the gate is handing back to a model.
305
+ *
306
+ * The third arm — an unusable deadline — is an `AffiantError` carrying
307
+ * `"wireup-invalid"`, and deliberately so. `createGate` already refuses
308
+ * `GateOptions.defaultTtlMs` with that code (CV-1); a policy naming the *same value*
309
+ * badly is the same misconfiguration arriving one layer down, and the failure it
310
+ * replaces is silent — a `ttlMs` of `0` files a Docket row that satisfies every
311
+ * invariant and reads `expired` on the read that files it, so the write the gate was
312
+ * standing in front of simply never happens. A code a host can branch on, and that
313
+ * `wrap` hands back as `{ kind: "error" }`, is the answer; a bare `RangeError` out of
314
+ * the tool seam is not.
315
+ */
316
+ function checkVerdict(deps, policy, verdict) {
317
+ if (!REQUIREMENT_KINDS.includes(verdict.requirement)) {
318
+ throw new RangeError(`AZ-4: policy ${JSON.stringify(policy.id)} returned an unknown requirement ` +
319
+ `${JSON.stringify(verdict.requirement)}; the four are ${REQUIREMENT_KINDS.join(", ")}`);
320
+ }
321
+ if (verdict.threshold !== undefined && verdict.requirement !== "StandingOrder") {
322
+ throw new RangeError(`GT-5: policy ${JSON.stringify(policy.id)} put a risk threshold on a ` +
323
+ `${verdict.requirement} verdict; a threshold is the ceiling a Standing Order fires ` +
324
+ `under, and means nothing on a requirement that asks a person`);
325
+ }
326
+ checkTtlMs(deps, policy, "ttlMs", verdict.ttlMs);
327
+ }
328
+ /**
329
+ * Whether `ttlMs` is a deadline: a finite whole number of milliseconds, one or more.
330
+ *
331
+ * Exported so `createGate` holds {@link ApprovalPolicy.defaultTtlMs} and
332
+ * `GateOptions.defaultTtlMs` to one definition rather than two that can drift.
333
+ */
334
+ export function isUsableTtlMs(ttlMs) {
335
+ return typeof ttlMs === "number" && Number.isInteger(ttlMs) && ttlMs >= 1;
336
+ }
337
+ /**
338
+ * The refusal message for a policy that named an unusable deadline. One function so
339
+ * the wire-up refusal and the per-request one read identically.
340
+ */
341
+ export function unusableTtlMessage(policyId, option, ttlMs) {
342
+ return (`GT-4: policy ${JSON.stringify(policyId)} named ${option} ${String(ttlMs)}; a deadline is a ` +
343
+ `whole number of milliseconds, one or more. A zero or negative one files an entry that is ` +
344
+ `already past its deadline, which no person can ever decide and which no rule would show as ` +
345
+ `a failure; a fractional or non-numeric one has no instant to stamp at all.`);
346
+ }
347
+ /** Refuse an unusable `ttlMs` from a verdict or from the policy's own default (GT-4). */
348
+ function checkTtlMs(deps, policy, option, ttlMs) {
349
+ if (ttlMs === undefined || isUsableTtlMs(ttlMs))
350
+ return;
351
+ const reason = unusableTtlMessage(policy.id, option, ttlMs);
352
+ emitPolicyInvalid(deps, policy, option, reason);
353
+ throw new AffiantError("wireup-invalid", reason, {
354
+ policyId: policy.id,
355
+ option,
356
+ ttlMs: String(ttlMs),
357
+ });
358
+ }
359
+ /** Emit `policy.invalid`, naming the policy and which half of its contract it broke. */
360
+ function emitPolicyInvalid(deps, policy, option, reason) {
361
+ deps.telemetry?.emit({
362
+ key: "policy.invalid",
363
+ at: deps.now,
364
+ attributes: {
365
+ "policy.id": policy.id,
366
+ "policy.version": policy.version,
367
+ option,
368
+ reason,
369
+ },
370
+ });
371
+ }
372
+ /** The message of a throw, without assuming it was an `Error`. */
373
+ function messageOf(error) {
374
+ return error instanceof Error ? error.message : String(error);
375
+ }
376
+ /** Emit `standing-order.blocked`, naming the policy and why it was not honoured. */
377
+ function emitBlocked(deps, policy, detail) {
378
+ deps.telemetry?.emit({
379
+ key: "standing-order.blocked",
380
+ at: deps.now,
381
+ attributes: {
382
+ "policy.id": policy.id,
383
+ "policy.version": policy.version,
384
+ "blocked.reason": detail.code,
385
+ reason: detail.reason,
386
+ "provenance.field": detail.field ?? null,
387
+ "provenance.source": detail.source ?? null,
388
+ // Field *names*, which are schema; never a field value. Telemetry is
389
+ // operational and the audit record is the Affidavit.
390
+ "affidavit.empty_mandatory_fields": detail.fields?.join(", ") ?? null,
391
+ "risk.score": detail.score ?? null,
392
+ "risk.threshold": detail.threshold ?? null,
393
+ },
394
+ });
395
+ }
396
+ //# sourceMappingURL=policy.js.map