@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.
- package/LICENSE +202 -0
- package/README.md +354 -0
- package/dist/context.d.ts +136 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +30 -0
- package/dist/context.js.map +1 -0
- package/dist/docket/entry.d.ts +421 -0
- package/dist/docket/entry.d.ts.map +1 -0
- package/dist/docket/entry.js +155 -0
- package/dist/docket/entry.js.map +1 -0
- package/dist/docket/expiry.d.ts +82 -0
- package/dist/docket/expiry.d.ts.map +1 -0
- package/dist/docket/expiry.js +106 -0
- package/dist/docket/expiry.js.map +1 -0
- package/dist/docket/memory.d.ts +163 -0
- package/dist/docket/memory.d.ts.map +1 -0
- package/dist/docket/memory.js +528 -0
- package/dist/docket/memory.js.map +1 -0
- package/dist/docket/store.d.ts +387 -0
- package/dist/docket/store.d.ts.map +1 -0
- package/dist/docket/store.js +51 -0
- package/dist/docket/store.js.map +1 -0
- package/dist/errors.d.ts +153 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +164 -0
- package/dist/errors.js.map +1 -0
- package/dist/gate/coverage.d.ts +152 -0
- package/dist/gate/coverage.d.ts.map +1 -0
- package/dist/gate/coverage.js +114 -0
- package/dist/gate/coverage.js.map +1 -0
- package/dist/gate/decide.d.ts +207 -0
- package/dist/gate/decide.d.ts.map +1 -0
- package/dist/gate/decide.js +559 -0
- package/dist/gate/decide.js.map +1 -0
- package/dist/gate/gate.d.ts +212 -0
- package/dist/gate/gate.d.ts.map +1 -0
- package/dist/gate/gate.js +175 -0
- package/dist/gate/gate.js.map +1 -0
- package/dist/gate/pipeline.d.ts +285 -0
- package/dist/gate/pipeline.d.ts.map +1 -0
- package/dist/gate/pipeline.js +515 -0
- package/dist/gate/pipeline.js.map +1 -0
- package/dist/gate/policy.d.ts +272 -0
- package/dist/gate/policy.d.ts.map +1 -0
- package/dist/gate/policy.js +396 -0
- package/dist/gate/policy.js.map +1 -0
- package/dist/gate/wrap.d.ts +107 -0
- package/dist/gate/wrap.d.ts.map +1 -0
- package/dist/gate/wrap.js +164 -0
- package/dist/gate/wrap.js.map +1 -0
- package/dist/index.d.ts +95 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +111 -0
- package/dist/index.js.map +1 -0
- package/dist/model/affidavit.d.ts +354 -0
- package/dist/model/affidavit.d.ts.map +1 -0
- package/dist/model/affidavit.js +417 -0
- package/dist/model/affidavit.js.map +1 -0
- package/dist/model/amendments.d.ts +160 -0
- package/dist/model/amendments.d.ts.map +1 -0
- package/dist/model/amendments.js +183 -0
- package/dist/model/amendments.js.map +1 -0
- package/dist/model/canonical.d.ts +311 -0
- package/dist/model/canonical.d.ts.map +1 -0
- package/dist/model/canonical.js +665 -0
- package/dist/model/canonical.js.map +1 -0
- package/dist/model/money.d.ts +127 -0
- package/dist/model/money.d.ts.map +1 -0
- package/dist/model/money.js +177 -0
- package/dist/model/money.js.map +1 -0
- package/dist/model/provenance.d.ts +315 -0
- package/dist/model/provenance.d.ts.map +1 -0
- package/dist/model/provenance.js +223 -0
- package/dist/model/provenance.js.map +1 -0
- package/dist/ports.d.ts +269 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +34 -0
- package/dist/ports.js.map +1 -0
- package/dist/store-memory.d.ts +21 -0
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/store-memory.js +20 -0
- package/dist/store-memory.js.map +1 -0
- package/dist/telemetry-keys.d.ts +65 -0
- package/dist/telemetry-keys.d.ts.map +1 -0
- package/dist/telemetry-keys.js +72 -0
- package/dist/telemetry-keys.js.map +1 -0
- package/dist/telemetry.d.ts +77 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +43 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/testing.d.ts +574 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +1291 -0
- package/dist/testing.js.map +1 -0
- package/package.json +75 -0
- 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
|