@intentius/chant 0.63.0 → 0.65.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/dist/behaviour.d.ts +999 -0
- package/dist/behaviour.d.ts.map +1 -0
- package/dist/discovery/fold-import.d.ts +12 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +10 -0
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +26 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/identity.d.ts +28 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +45 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +79 -16
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/behaviour.test.ts +1961 -0
- package/src/behaviour.ts +1875 -0
- package/src/discovery/fold-import.ts +12 -0
- package/src/fold/fold.ts +10 -0
- package/src/fold/subset-doc-parity.test.ts +60 -0
- package/src/fold/subset-public-export.test.ts +27 -0
- package/src/fold/subset.ts +26 -2
- package/src/identity.ts +31 -2
- package/src/index.ts +6 -0
- package/src/lexicon.ts +46 -0
- package/src/op/activities/reconcile.test.ts +84 -0
- package/src/op/activities/reconcile.ts +90 -22
package/src/behaviour.ts
ADDED
|
@@ -0,0 +1,1875 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The behaviour prediction contract (#2356) — what a lexicon's
|
|
3
|
+
* `predictBehaviour()` is allowed to mean.
|
|
4
|
+
*
|
|
5
|
+
* `describeResources()` (./observation.ts) answers whether a declared entity
|
|
6
|
+
* exists. `observeResourcesDeep()` (./deep-observation.ts) answers what its
|
|
7
|
+
* properties currently are. Both report facts a substrate was asked for. This
|
|
8
|
+
* third axis reports something no substrate holds: what the declared estate
|
|
9
|
+
* would *do* at a stated traffic level — cost per hour, how far each entity is
|
|
10
|
+
* from saturation, the error rate to expect, whether it survives a named
|
|
11
|
+
* failure, and a smaller size that would still carry the load.
|
|
12
|
+
*
|
|
13
|
+
* None of that is a measurement, and the type is built so it cannot be read as
|
|
14
|
+
* one.
|
|
15
|
+
*
|
|
16
|
+
* ## A prediction is not a bill
|
|
17
|
+
*
|
|
18
|
+
* The one failure mode that matters here is somebody quoting a modeled figure
|
|
19
|
+
* as money owed. Four things in this module work against that, and it is worth
|
|
20
|
+
* being exact about how much they buy, because an overstated guarantee is how
|
|
21
|
+
* a consumer ends up trusting one:
|
|
22
|
+
*
|
|
23
|
+
* 1. Money appears in exactly one shape, {@link PredictedRate}, and that
|
|
24
|
+
* shape carries a literal `rate: "per-hour"` discriminant. There is no
|
|
25
|
+
* field anywhere for an amount, a period, an account, an invoice or a
|
|
26
|
+
* due date, so an elapsed charge is not expressible *in this module's
|
|
27
|
+
* types*.
|
|
28
|
+
* 2. A figure cannot exist without {@link PredictedBehaviour.at}, the traffic
|
|
29
|
+
* level it was predicted for. A bill is for an hour that happened; this is
|
|
30
|
+
* for an hour the engine was asked to imagine.
|
|
31
|
+
* 3. {@link BehaviourProvenance} is required on every entity, and its
|
|
32
|
+
* {@link BehaviourProvenance.basis} is a closed two-value enum: `modeled`
|
|
33
|
+
* off list prices, or `validated` against a real bill. A figure that will
|
|
34
|
+
* not say which of the two it is cannot be constructed.
|
|
35
|
+
* 4. A refusal is a separate member of the {@link BehaviourResult} union with
|
|
36
|
+
* no figures on it at all, so "the engine is gone" and "the engine says
|
|
37
|
+
* zero" are different objects rather than the same object with zeroes in
|
|
38
|
+
* it. This one is airtight: there is no `entities` key on the refusal arm
|
|
39
|
+
* to be empty and no total to be zero.
|
|
40
|
+
*
|
|
41
|
+
* **What this does not do is make a bill a type error.** A consumer holding a
|
|
42
|
+
* {@link PredictedBehaviour} can write
|
|
43
|
+
* `const { rate, ...rest } = cost; return { amount: rest.perHour * hours }`
|
|
44
|
+
* and TypeScript will not object; the discriminant is a field on an object, and
|
|
45
|
+
* a field can be dropped. behold's own validator rebuilds `cost` as
|
|
46
|
+
* `{ perHour, currency }` and its estate sum carries no discriminant at all, so
|
|
47
|
+
* the marker is stripped by the first consumer *by design*. What the shape
|
|
48
|
+
* genuinely buys is that a bill cannot be constructed **accidentally** — every
|
|
49
|
+
* route from a prediction to something that reads as money owed has a
|
|
50
|
+
* deliberate destructure or cast in it, and shows up in review as one. Treat
|
|
51
|
+
* rules 1 to 3 as a speed bump with a name, and rule 4 as the enforced one.
|
|
52
|
+
*
|
|
53
|
+
* ## The engine sees no credential and writes nothing
|
|
54
|
+
*
|
|
55
|
+
* {@link PredictBehaviourOptions} mirrors `observeResourcesDeep`'s options
|
|
56
|
+
* field for field, plus `traffic`, `edges` and `edgeCoverage`, and then works
|
|
57
|
+
* against credentials on two levels. The type declares every obvious name
|
|
58
|
+
* `?: never`, which catches the deliberate attempt; and
|
|
59
|
+
* {@link screenBehaviourRequest} walks the whole request at runtime, matching
|
|
60
|
+
* credential-shaped **values** as well as credential-shaped keys, which is what
|
|
61
|
+
* catches the accident.
|
|
62
|
+
*
|
|
63
|
+
* `screenBehaviourRequest` is the one entry point — its own doc comment carries
|
|
64
|
+
* the call sequence, and nothing else here restates it. The runtime walk is the
|
|
65
|
+
* load-bearing half, because the type cannot see the two channels that actually
|
|
66
|
+
* carry a secret in practice: `entities[*].props` is `Record<string, unknown>`
|
|
67
|
+
* straight out of the build, and a lexicon surfacing a connection string puts
|
|
68
|
+
* one there without deciding to. See {@link assertNoCredentialInOptions} for
|
|
69
|
+
* exactly what the walk detects and, more importantly, what it does not.
|
|
70
|
+
*
|
|
71
|
+
* `edges` is the one place the options mirror breaks, and the field's own doc
|
|
72
|
+
* says why: the epic's input is a resource graph, a deep read has no use for
|
|
73
|
+
* neighbours, and a prediction is nothing but statements about paths through
|
|
74
|
+
* the estate. It carries `IREdge` (./graph-ir.ts) rather than an edge type of
|
|
75
|
+
* this contract's own, because that is already the shape both the declared
|
|
76
|
+
* path and the live path produce.
|
|
77
|
+
*
|
|
78
|
+
* Nothing in the options is a handle. There is no client, no transport, no
|
|
79
|
+
* apply callback, no writer — only strings, a name list and the entity map the
|
|
80
|
+
* build already produced. An engine handed this cannot reach the account even
|
|
81
|
+
* if it wanted to, which is the structural half of "it never writes".
|
|
82
|
+
*
|
|
83
|
+
* ## The tri-state, and why it is not `UnobservedReason`
|
|
84
|
+
*
|
|
85
|
+
* Behaviour keeps the same three-verdict discipline `./observation.ts`
|
|
86
|
+
* established — PREDICTED, NOT-PREDICTABLE-FOR-THIS-KIND, and NOT-PREDICTED
|
|
87
|
+
* with a named reason — but on a stricter total: every entity the caller asked
|
|
88
|
+
* about lands in `entities` or in `unpredicted`, never in neither. The thin
|
|
89
|
+
* read needs a third position because "the provider says it is not there" is a
|
|
90
|
+
* real answer with no row to sit on. A prediction has no such answer. An entity
|
|
91
|
+
* either got a figure or it did not, and when it did not there is a reason,
|
|
92
|
+
* so an entity in neither map means the lexicon lost track of it.
|
|
93
|
+
*
|
|
94
|
+
* {@link BehaviourUnpredictedReason} derives from `UnobservedReason` rather
|
|
95
|
+
* than restating it, so the four shared verdicts provably keep their spelling
|
|
96
|
+
* and their meaning. It differs in two ways, both deliberate:
|
|
97
|
+
*
|
|
98
|
+
* - `no-credentials` is **excluded**. A behaviour read has no credential to
|
|
99
|
+
* be missing — see the section above — so an enum that could say it would
|
|
100
|
+
* be inviting a lexicon to send an operator hunting for a variable this
|
|
101
|
+
* contract forbids.
|
|
102
|
+
* - Four reasons about the predictor itself are **added** — `no-engine`,
|
|
103
|
+
* `engine-unreachable`, `engine-out-of-credit` and `engine-over-quota` —
|
|
104
|
+
* because the epic wants a missing engine named and `no-binding` is about
|
|
105
|
+
* the environment resolving to no target, a different axis. They are four
|
|
106
|
+
* rather than one because each has a different remedy, and a refusal that
|
|
107
|
+
* names the wrong remedy is worse than a slow one: set a variable, check an
|
|
108
|
+
* address, pay for the account, or wait for a window. The last two arrive
|
|
109
|
+
* from an engine that answered perfectly well (#2359), so folding them into
|
|
110
|
+
* `engine-unreachable` would send somebody to debug a network that is fine.
|
|
111
|
+
*
|
|
112
|
+
* ## Deltas: the invariant is here, the presentation is not
|
|
113
|
+
*
|
|
114
|
+
* The epic wants a declared prediction and a live prediction shown as a delta,
|
|
115
|
+
* and #2358 posts one on a merge request. This module deliberately ships no
|
|
116
|
+
* delta type and no differencing function. What it ships is the one thing a
|
|
117
|
+
* hand-rolled diff silently loses, which is that a figure's context does not
|
|
118
|
+
* survive subtraction: {@link compareFigures} classifies a pair `comparable`,
|
|
119
|
+
* `mixed-basis`, `mixed-level` or `mixed-engine`, and the rule this contract
|
|
120
|
+
* binds its consumers to is that anything but `comparable` must be marked
|
|
121
|
+
* wherever it is shown. A `modeled` figure minus a `validated` one is not a
|
|
122
|
+
* change in the estate; part of that difference is the gap between a price list
|
|
123
|
+
* and an invoice. Nor is a 100 rps figure minus a 1000 rps one, which is why
|
|
124
|
+
* {@link compareProvenance} — which cannot see `at` and never could — is not
|
|
125
|
+
* the function to reach for. How the mark looks is #2358's to define. Whether
|
|
126
|
+
* there is one is not.
|
|
127
|
+
*
|
|
128
|
+
* ## Shape compatibility with the overlay
|
|
129
|
+
*
|
|
130
|
+
* {@link PredictedBehaviour} is the object `chant graph --live --overlay` puts
|
|
131
|
+
* on a node as `attrs._behaviour`.
|
|
132
|
+
*
|
|
133
|
+
* On the graph, `meta._behaviour` takes {@link BehaviourReportMeta} **or the
|
|
134
|
+
* whole {@link BehaviourRefusalReport}** — not a bare {@link BehaviourRefusal}.
|
|
135
|
+
* behold reads that key as `{ engine?, version?, at?, total?, refusal? }` and
|
|
136
|
+
* branches on the presence of `refusal`, so a bare `BehaviourRefusal` has no
|
|
137
|
+
* `refusal` key, falls through to the engine check, and is dropped as
|
|
138
|
+
* "meta.engine missing" — a refusal that renders as nothing at all, which is
|
|
139
|
+
* the one outcome this contract exists to prevent. #2360 implements this
|
|
140
|
+
* sentence, so it says what behold does.
|
|
141
|
+
*
|
|
142
|
+
* behold reads those keys and does arithmetic on the engine's figures; it never
|
|
143
|
+
* produces one of its own. Fields beyond what it reads (`cause` and `source` on
|
|
144
|
+
* a refusal, `edgeCoverage` on the meta) are additive and ignorable.
|
|
145
|
+
*/
|
|
146
|
+
|
|
147
|
+
import type { UnobservedReason } from "./observation";
|
|
148
|
+
import type { IREdge } from "./graph-ir";
|
|
149
|
+
import type { DanglingRef } from "./graph-refs";
|
|
150
|
+
import {
|
|
151
|
+
CREDENTIAL_ENV_NAME,
|
|
152
|
+
CREDENTIAL_SHAPES,
|
|
153
|
+
CREDENTIAL_TOKEN_SHAPES,
|
|
154
|
+
REDACTED,
|
|
155
|
+
redactCredentialMaterial,
|
|
156
|
+
} from "./identity";
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Whether a figure came off a price list or off a bill. Closed, and required on
|
|
160
|
+
* every prediction — this is the distinction that keeps rule 1 enforceable.
|
|
161
|
+
*
|
|
162
|
+
* - `modeled` — computed from published list prices and the engine's own model.
|
|
163
|
+
* The honest default, and the word a badge shows unless told otherwise.
|
|
164
|
+
* - `validated` — reconciled against a real invoice for a comparable estate.
|
|
165
|
+
*/
|
|
166
|
+
export type BehaviourBasis = "modeled" | "validated";
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Every legal {@link BehaviourBasis}, for validation and conformance checks.
|
|
170
|
+
*
|
|
171
|
+
* Derived from a total witness rather than typed as `readonly BehaviourBasis[]`
|
|
172
|
+
* and written out by hand. A hand-written array is only checked for having
|
|
173
|
+
* legal members, never for having ALL of them, so a value added to the union
|
|
174
|
+
* leaves the array silently short and every runtime guard built on it starts
|
|
175
|
+
* rejecting a value the type accepts. Keying a `Record` off the union makes the
|
|
176
|
+
* omission a compile error at the point of the omission. Same construction for
|
|
177
|
+
* {@link RESILIENCE_VERDICTS} and {@link BEHAVIOUR_UNPREDICTED_REASONS}, and
|
|
178
|
+
* the last of those is the one that needed it: it derives from
|
|
179
|
+
* `UnobservedReason`, so a reason added *upstream* would otherwise widen this
|
|
180
|
+
* type with nothing here failing.
|
|
181
|
+
*/
|
|
182
|
+
const BEHAVIOUR_BASIS_WITNESS: Record<BehaviourBasis, true> = {
|
|
183
|
+
modeled: true,
|
|
184
|
+
validated: true,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export const BEHAVIOUR_BASES: readonly BehaviourBasis[] = Object.keys(
|
|
188
|
+
BEHAVIOUR_BASIS_WITNESS,
|
|
189
|
+
) as BehaviourBasis[];
|
|
190
|
+
|
|
191
|
+
/** True when `value` is a legal {@link BehaviourBasis}. */
|
|
192
|
+
export function isBehaviourBasis(value: unknown): value is BehaviourBasis {
|
|
193
|
+
return typeof value === "string" && (BEHAVIOUR_BASES as readonly string[]).includes(value);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Where one entity's numbers came from and how far they can be trusted.
|
|
198
|
+
* Required on every {@link PredictedBehaviour}, and per entity rather than per
|
|
199
|
+
* report, because one estate can be priced by two engines.
|
|
200
|
+
*/
|
|
201
|
+
export interface BehaviourProvenance {
|
|
202
|
+
/** The engine that produced the figures, as it names itself (`acme-sim`). */
|
|
203
|
+
engine: string;
|
|
204
|
+
/** That engine's own version string (`1.4.2`). Never inferred. */
|
|
205
|
+
version: string;
|
|
206
|
+
/**
|
|
207
|
+
* The engine's stated tolerance, echoed verbatim (`±15%`). chant does not
|
|
208
|
+
* parse it and does not invent one for an engine that states none — an engine
|
|
209
|
+
* with nothing to say here has no business publishing a figure.
|
|
210
|
+
*/
|
|
211
|
+
tolerance: string;
|
|
212
|
+
/** List prices, or a real bill. See {@link BehaviourBasis}. */
|
|
213
|
+
basis: BehaviourBasis;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The traffic level a prediction is for. A string the engine names and chant
|
|
218
|
+
* echoes, never a number chant does arithmetic on: `100 rps, p50`, `peak hour,
|
|
219
|
+
* black friday`, `steady state`. Required, because a figure without the
|
|
220
|
+
* question it answers is the figure most likely to be quoted as a bill.
|
|
221
|
+
*/
|
|
222
|
+
export interface BehaviourTrafficLevel {
|
|
223
|
+
traffic: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Money, in the only shape this module has for it: a rate for one hypothetical
|
|
228
|
+
* hour at a stated traffic level.
|
|
229
|
+
*
|
|
230
|
+
* The literal `rate: "per-hour"` is load-bearing. It makes the type structurally
|
|
231
|
+
* distinct from any billing record — nothing that models an amount charged
|
|
232
|
+
* carries that field — so a `PredictedRate` cannot be passed where a charge is
|
|
233
|
+
* wanted, and a charge cannot be passed here.
|
|
234
|
+
*/
|
|
235
|
+
export interface PredictedRate {
|
|
236
|
+
/** Discriminant. A rate for an imagined hour, never an amount charged for a real one. */
|
|
237
|
+
readonly rate: "per-hour";
|
|
238
|
+
/** The rate itself, in `currency` per hour. */
|
|
239
|
+
perHour: number;
|
|
240
|
+
/** ISO 4217 code, as the engine states it. chant converts nothing. */
|
|
241
|
+
currency: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Build a {@link PredictedRate}. Lexicons use this rather than writing the discriminant by hand. */
|
|
245
|
+
export function predictedRate(perHour: number, currency: string): PredictedRate {
|
|
246
|
+
return { rate: "per-hour", perHour, currency };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* How much of each axis is still free at the stated traffic level, as a
|
|
251
|
+
* fraction in `0..1`. Both axes are optional and an axis the engine does not
|
|
252
|
+
* model is **absent**, never `0` — zero headroom means saturated, which is the
|
|
253
|
+
* opposite claim.
|
|
254
|
+
*/
|
|
255
|
+
export type BehaviourHeadroom =
|
|
256
|
+
| { cpu: number; latency?: number }
|
|
257
|
+
| { cpu?: number; latency: number };
|
|
258
|
+
|
|
259
|
+
/** What an entity does when the named failure happens. Closed. */
|
|
260
|
+
export type ResilienceVerdict = "survives" | "degrades" | "fails";
|
|
261
|
+
|
|
262
|
+
const RESILIENCE_VERDICT_WITNESS: Record<ResilienceVerdict, true> = {
|
|
263
|
+
survives: true,
|
|
264
|
+
degrades: true,
|
|
265
|
+
fails: true,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/** Every legal {@link ResilienceVerdict}, for validation and conformance checks. */
|
|
269
|
+
export const RESILIENCE_VERDICTS: readonly ResilienceVerdict[] = Object.keys(
|
|
270
|
+
RESILIENCE_VERDICT_WITNESS,
|
|
271
|
+
) as ResilienceVerdict[];
|
|
272
|
+
|
|
273
|
+
/** True when `value` is a legal {@link ResilienceVerdict}. */
|
|
274
|
+
export function isResilienceVerdict(value: unknown): value is ResilienceVerdict {
|
|
275
|
+
return typeof value === "string" && (RESILIENCE_VERDICTS as readonly string[]).includes(value);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The verdict under one named failure, and which failure it was tested against. */
|
|
279
|
+
export interface BehaviourResilience {
|
|
280
|
+
/**
|
|
281
|
+
* The failure, named by the engine and echoed verbatim: `one zone lost`,
|
|
282
|
+
* `primary database failover`. A verdict without its failure says nothing.
|
|
283
|
+
*/
|
|
284
|
+
failure: string;
|
|
285
|
+
verdict: ResilienceVerdict;
|
|
286
|
+
/** Free text from the engine, when it has more to say than the verdict. */
|
|
287
|
+
note?: string;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** A smaller size the engine believes would still carry the stated traffic. */
|
|
291
|
+
export interface BehaviourRightSize {
|
|
292
|
+
/** The size, in the provider's own vocabulary (`t3.small`, `db-f1-micro`). */
|
|
293
|
+
suggestion: string;
|
|
294
|
+
/** Why. A suggestion nobody can evaluate is noise. */
|
|
295
|
+
reason?: string;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* One entity's prediction — the object that rides as `attrs._behaviour` on an
|
|
300
|
+
* overlay node.
|
|
301
|
+
*
|
|
302
|
+
* Everything here except `rightSize`, `resilience.note` and either
|
|
303
|
+
* {@link BehaviourHeadroom} axis is required. An entity the engine could not
|
|
304
|
+
* price does not get a thinned-out version of this block; it goes in
|
|
305
|
+
* {@link BehaviourReport.unpredicted} with a reason.
|
|
306
|
+
*/
|
|
307
|
+
export interface PredictedBehaviour {
|
|
308
|
+
/** The traffic level every figure below is for. */
|
|
309
|
+
at: BehaviourTrafficLevel;
|
|
310
|
+
/** Cost per hour at `at`. */
|
|
311
|
+
cost: PredictedRate;
|
|
312
|
+
/** Distance from saturation at `at`. A modeled axis is present; an unmodeled one is absent. */
|
|
313
|
+
headroom: BehaviourHeadroom;
|
|
314
|
+
/** Expected fraction of requests failing at `at`, in `0..1`. */
|
|
315
|
+
errorRate: number;
|
|
316
|
+
/** The verdict under a named failure. */
|
|
317
|
+
resilience: BehaviourResilience;
|
|
318
|
+
/** Optional: a smaller size that would still do. */
|
|
319
|
+
rightSize?: BehaviourRightSize;
|
|
320
|
+
/** Which engine said all of this, and on what basis. Required. */
|
|
321
|
+
provenance: BehaviourProvenance;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Why one declared entity got no prediction. Total: a lexicon that cannot
|
|
326
|
+
* predict an entity must pick one of these, and consumers may switch
|
|
327
|
+
* exhaustively.
|
|
328
|
+
*
|
|
329
|
+
* Derived from {@link UnobservedReason} so the four shared verdicts keep their
|
|
330
|
+
* exact spelling and meaning:
|
|
331
|
+
*
|
|
332
|
+
* - `read-failed` — the engine was reached and the prediction errored.
|
|
333
|
+
* - `no-binding` — the environment resolves to no concrete target to predict.
|
|
334
|
+
* - `unsupported-kind` — the engine has no model for this entity type. The
|
|
335
|
+
* entity is perfectly real and may well cost money; this engine cannot say
|
|
336
|
+
* how much, and says so instead of returning zero.
|
|
337
|
+
* - `filtered` — reached but withheld by a caller-requested filter (`owned`).
|
|
338
|
+
*
|
|
339
|
+
* `no-credentials` is excluded on purpose: the engine is never handed one, so
|
|
340
|
+
* it can never be missing one. Two reasons are added for the predictor itself:
|
|
341
|
+
*
|
|
342
|
+
* - `no-engine` — no variable in the chain named an engine. Nothing is
|
|
343
|
+
* configured; this is a setup state, not a failure.
|
|
344
|
+
* - `engine-unreachable` — a variable named an engine and it did not answer.
|
|
345
|
+
* - `engine-out-of-credit` — the engine answered, and refused because the
|
|
346
|
+
* account behind it has no balance left (#2359).
|
|
347
|
+
* - `engine-over-quota` — the engine answered, and refused because a rate or
|
|
348
|
+
* volume limit is spent (#2359).
|
|
349
|
+
* - `credential-in-request` — the request itself carried something that must
|
|
350
|
+
* not leave the process, so nothing was sent. The one refusal chant raises
|
|
351
|
+
* about itself rather than about the engine; see
|
|
352
|
+
* {@link screenBehaviourRequest}.
|
|
353
|
+
*
|
|
354
|
+
* The last four are one axis split four ways, because each has a different
|
|
355
|
+
* remedy and a refusal exists to be acted on. `no-engine` wants a variable
|
|
356
|
+
* set. `engine-unreachable` wants the address checked. `engine-out-of-credit`
|
|
357
|
+
* wants somebody to pay, and no amount of waiting fixes it.
|
|
358
|
+
* `engine-over-quota` usually wants nothing but the window to roll over, and
|
|
359
|
+
* telling somebody to top up an account that is not empty sends them to the
|
|
360
|
+
* wrong place — as does folding either into `engine-unreachable`, which points
|
|
361
|
+
* at an address that is answering perfectly well.
|
|
362
|
+
*/
|
|
363
|
+
export type BehaviourUnpredictedReason =
|
|
364
|
+
| Exclude<UnobservedReason, "no-credentials">
|
|
365
|
+
| "no-engine"
|
|
366
|
+
| "engine-unreachable"
|
|
367
|
+
| "engine-out-of-credit"
|
|
368
|
+
| "engine-over-quota"
|
|
369
|
+
| "credential-in-request";
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The total witness. This is the one that earns the construction: the type
|
|
373
|
+
* above derives from `UnobservedReason`, so adding a reason to THAT union —
|
|
374
|
+
* in ./observation.ts, for reasons that have nothing to do with prediction —
|
|
375
|
+
* silently widens this one. With a hand-written array, the widening compiled
|
|
376
|
+
* clean, every existing test stayed green, and the new reason was assignable to
|
|
377
|
+
* `BehaviourUnpredictedReason` while {@link isBehaviourUnpredictedReason}
|
|
378
|
+
* returned `false` for it and the conformance suite rejected it. Keyed off the
|
|
379
|
+
* union, the same change fails to compile here and somebody has to decide
|
|
380
|
+
* whether the new reason belongs to behaviour at all.
|
|
381
|
+
*/
|
|
382
|
+
const BEHAVIOUR_UNPREDICTED_REASON_WITNESS: Record<BehaviourUnpredictedReason, true> = {
|
|
383
|
+
"read-failed": true,
|
|
384
|
+
"no-binding": true,
|
|
385
|
+
"unsupported-kind": true,
|
|
386
|
+
filtered: true,
|
|
387
|
+
"no-engine": true,
|
|
388
|
+
"engine-unreachable": true,
|
|
389
|
+
"engine-out-of-credit": true,
|
|
390
|
+
"engine-over-quota": true,
|
|
391
|
+
"credential-in-request": true,
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
/** Every legal {@link BehaviourUnpredictedReason}, for validation and conformance checks. */
|
|
395
|
+
export const BEHAVIOUR_UNPREDICTED_REASONS: readonly BehaviourUnpredictedReason[] = Object.keys(
|
|
396
|
+
BEHAVIOUR_UNPREDICTED_REASON_WITNESS,
|
|
397
|
+
) as BehaviourUnpredictedReason[];
|
|
398
|
+
|
|
399
|
+
/** True when `value` is a legal {@link BehaviourUnpredictedReason}. */
|
|
400
|
+
export function isBehaviourUnpredictedReason(
|
|
401
|
+
value: unknown,
|
|
402
|
+
): value is BehaviourUnpredictedReason {
|
|
403
|
+
return (
|
|
404
|
+
typeof value === "string" &&
|
|
405
|
+
(BEHAVIOUR_UNPREDICTED_REASONS as readonly string[]).includes(value)
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** One declared entity that got no prediction, and why. */
|
|
410
|
+
export interface UnpredictedEntity {
|
|
411
|
+
/** Declared entity type, when the lexicon knows it (it usually does — the entity is declared). */
|
|
412
|
+
type?: string;
|
|
413
|
+
/** Total verdict. */
|
|
414
|
+
reason: BehaviourUnpredictedReason;
|
|
415
|
+
/** Human-readable detail: the kind with no model, the call that failed. */
|
|
416
|
+
detail?: string;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Report-level facts about a run that produced figures. Rides as
|
|
421
|
+
* `meta._behaviour` on the overlay graph.
|
|
422
|
+
*/
|
|
423
|
+
export interface BehaviourReportMeta {
|
|
424
|
+
/** The engine that answered for the run as a whole. */
|
|
425
|
+
engine: string;
|
|
426
|
+
/** Its version. */
|
|
427
|
+
version: string;
|
|
428
|
+
/** The traffic level the run was asked for, echoed from the request. */
|
|
429
|
+
at: BehaviourTrafficLevel;
|
|
430
|
+
/**
|
|
431
|
+
* An estate total, when the engine states one of its own. Optional, and
|
|
432
|
+
* emphatically not a field for chant or a consumer to fill in by summing —
|
|
433
|
+
* a consumer that sums does its own arithmetic and labels it as such.
|
|
434
|
+
*/
|
|
435
|
+
total?: PredictedRate;
|
|
436
|
+
/**
|
|
437
|
+
* The edge coverage the run was given, echoed from the request (#2360).
|
|
438
|
+
*
|
|
439
|
+
* Required, and it is the whole reason `edgeCoverage` is worth stating. Held
|
|
440
|
+
* only on {@link PredictBehaviourOptions} it never reached a reader, so a
|
|
441
|
+
* consumer holding a report could not tell whether a "survives one zone lost"
|
|
442
|
+
* verdict was computed over a complete graph or over one whose builder had no
|
|
443
|
+
* idea what it had missed. Under this contract's own second constraint that
|
|
444
|
+
* makes the verdict a faked number the shape renders invisible — the failure
|
|
445
|
+
* the refusal arm exists to prevent, reappearing one level down.
|
|
446
|
+
*
|
|
447
|
+
* `behaviourReport` copies it from the request, so a lexicon does not restate
|
|
448
|
+
* it and cannot restate it differently.
|
|
449
|
+
*/
|
|
450
|
+
edgeCoverage: BehaviourEdgeCoverage;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* A run that produced figures. The `behaviour: "v1"` discriminant is a wire
|
|
455
|
+
* version for the same reason `observation: "v1"` is: consumers branch on it.
|
|
456
|
+
*/
|
|
457
|
+
export interface BehaviourReport {
|
|
458
|
+
/** Discriminant + wire version. */
|
|
459
|
+
readonly behaviour: "v1";
|
|
460
|
+
/** Which engine ran, at what traffic level. */
|
|
461
|
+
meta: BehaviourReportMeta;
|
|
462
|
+
/** PREDICTED, keyed by chant entity name. */
|
|
463
|
+
entities: Record<string, PredictedBehaviour>;
|
|
464
|
+
/**
|
|
465
|
+
* NOT-PREDICTED, keyed by chant entity name, with a total reason. Together
|
|
466
|
+
* with `entities` this must cover every name the caller asked about.
|
|
467
|
+
*/
|
|
468
|
+
unpredicted?: Record<string, UnpredictedEntity>;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Why there is no report at all, and what to do about it.
|
|
473
|
+
*
|
|
474
|
+
* `reason` and `remedy` are the two strings a consumer prints where the legend
|
|
475
|
+
* would go; `cause` and `source` are additive and switchable. The refusal is
|
|
476
|
+
* the lexicon's own text in the house style, naming the variable it wanted —
|
|
477
|
+
* see {@link noBehaviourEngineMessage}.
|
|
478
|
+
*/
|
|
479
|
+
export interface BehaviourRefusal {
|
|
480
|
+
/** Total, switchable verdict. */
|
|
481
|
+
cause: BehaviourUnpredictedReason;
|
|
482
|
+
/** The sentence a consumer prints. Names what was missing. */
|
|
483
|
+
reason: string;
|
|
484
|
+
/** How to fix it. Names the variable and how to set it. */
|
|
485
|
+
remedy: string;
|
|
486
|
+
/** Which variable in the chain answered, when one did. Absent for `no-engine`. */
|
|
487
|
+
source?: string;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* A run that produced nothing, and says why.
|
|
492
|
+
*
|
|
493
|
+
* A separate member of the union rather than a flag on {@link BehaviourReport},
|
|
494
|
+
* so a refusal has no `entities` map to be empty and no `meta.total` to be
|
|
495
|
+
* zero. "The engine is unreachable" and "the engine priced this estate at
|
|
496
|
+
* nothing" are different objects, and no amount of downstream carelessness can
|
|
497
|
+
* turn the first into the second.
|
|
498
|
+
*/
|
|
499
|
+
export interface BehaviourRefusalReport {
|
|
500
|
+
/** Discriminant + wire version, shared with {@link BehaviourReport}. */
|
|
501
|
+
readonly behaviour: "v1";
|
|
502
|
+
/** The refusal. Present *instead of* every figure, never alongside one. */
|
|
503
|
+
refusal: BehaviourRefusal;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** What `predictBehaviour()` returns: figures, or a named refusal. Never both, never neither. */
|
|
507
|
+
export type BehaviourResult = BehaviourReport | BehaviourRefusalReport;
|
|
508
|
+
|
|
509
|
+
/** True when the lexicon refused rather than predicting. */
|
|
510
|
+
export function isBehaviourRefusalReport(
|
|
511
|
+
value: BehaviourResult,
|
|
512
|
+
): value is BehaviourRefusalReport {
|
|
513
|
+
return typeof value === "object" && value !== null && "refusal" in value;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* True when `value` is either arm of the versioned {@link BehaviourResult}
|
|
518
|
+
* envelope.
|
|
519
|
+
*
|
|
520
|
+
* Both the discriminant AND an arm, because the discriminant alone is not the
|
|
521
|
+
* type. `{ behaviour: "v1" }` carries the version and is neither arm: it fails
|
|
522
|
+
* {@link isBehaviourRefusalReport}, so a consumer's `if (refusal) … else …`
|
|
523
|
+
* narrows it to {@link BehaviourReport}, and `result.entities` is `undefined`
|
|
524
|
+
* at a site TypeScript has been told cannot be.
|
|
525
|
+
*/
|
|
526
|
+
export function isBehaviourResult(value: unknown): value is BehaviourResult {
|
|
527
|
+
if (typeof value !== "object" || value === null) return false;
|
|
528
|
+
const v = value as { behaviour?: unknown; refusal?: unknown; entities?: unknown; meta?: unknown };
|
|
529
|
+
if (v.behaviour !== "v1") return false;
|
|
530
|
+
if (typeof v.refusal === "object" && v.refusal !== null) return true;
|
|
531
|
+
return (
|
|
532
|
+
typeof v.entities === "object" && v.entities !== null && typeof v.meta === "object" && v.meta !== null
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Words that state nothing. Rejected wherever this contract asks an engine to
|
|
538
|
+
* *state* something — its tolerance, and the failure a verdict is about.
|
|
539
|
+
*
|
|
540
|
+
* `"none"` as a `resilience.failure` was the hole: the module's own doc says a
|
|
541
|
+
* verdict with no named failure says nothing, and `"none"` names no failure
|
|
542
|
+
* while sailing through a non-empty-string check.
|
|
543
|
+
*/
|
|
544
|
+
const STATES_NOTHING: readonly string[] = ["n/a", "na", "none", "unknown", "-", "?", "tbd", "null"];
|
|
545
|
+
|
|
546
|
+
/** True when a stated value is one of the words that state nothing. */
|
|
547
|
+
function statesNothing(value: string): boolean {
|
|
548
|
+
return STATES_NOTHING.includes(value.trim().toLowerCase().replace(/\.$/, ""));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const isFraction = (v: unknown): boolean => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 1;
|
|
552
|
+
const isFilled = (v: unknown): boolean => typeof v === "string" && v.trim() !== "";
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Every rule behold's `validateBehaviourBlock` applies, applied here instead —
|
|
556
|
+
* before the block is built rather than after it has travelled.
|
|
557
|
+
*
|
|
558
|
+
* The rule set is deliberately, literally the same set. behold
|
|
559
|
+
* (`behold/src/behaviour.ts:209`) validates each block on arrival and **drops
|
|
560
|
+
* the whole block** with a diagnostic when one fails, so a block chant's types
|
|
561
|
+
* accept and behold's validator rejects renders nothing at all, having looked
|
|
562
|
+
* perfectly legal every step of the way here. Types cannot carry most of these:
|
|
563
|
+
* `tolerance: string` accepts `""`, `errorRate: number` accepts `1.2`, and
|
|
564
|
+
* `cost.perHour: number` accepts `-4`.
|
|
565
|
+
*
|
|
566
|
+
* The mapping, by behold's own numbering, so a future change there has a named
|
|
567
|
+
* place to land here:
|
|
568
|
+
*
|
|
569
|
+
* 2 `at.traffic` non-empty · 3 `cost.perHour` finite · 4 not negative ·
|
|
570
|
+
* 5 `cost.currency` non-empty · 6 `headroom` present · 7/8 each axis a
|
|
571
|
+
* fraction · 9 at least one axis · 10 `errorRate` a fraction ·
|
|
572
|
+
* 11 `resilience.failure` non-empty · 12 verdict in the closed set ·
|
|
573
|
+
* 13 `rightSize` implies a `suggestion` · 14 `provenance.engine` non-empty ·
|
|
574
|
+
* 15 `version` non-empty · 16 `tolerance` non-empty · 17 `basis` in the closed
|
|
575
|
+
* set.
|
|
576
|
+
*
|
|
577
|
+
* Rules 9 and 16 are the two chant is stricter on. Rule 9 is also a type here
|
|
578
|
+
* ({@link BehaviourHeadroom} is a union requiring one axis), so this is the
|
|
579
|
+
* backstop for a JavaScript caller. And on 16, behold accepts any non-empty
|
|
580
|
+
* string while this rejects `n/a`, `none`, `unknown` and friends: the epic asks
|
|
581
|
+
* for the engine's *stated* tolerance, and a word meaning "I have none to
|
|
582
|
+
* state" passes behold's check while defeating its purpose.
|
|
583
|
+
*/
|
|
584
|
+
export function validateBehaviourBlock(name: string, block: PredictedBehaviour): void {
|
|
585
|
+
const bad = (why: string): never => {
|
|
586
|
+
throw new Error(
|
|
587
|
+
`predictBehaviour produced an invalid block for "${name}": ${why}. behold's own validator ` +
|
|
588
|
+
"(behold/src/behaviour.ts) drops a block failing this, so it would render nothing rather than " +
|
|
589
|
+
"render wrong — which is worse to debug, because everything here looked legal.",
|
|
590
|
+
);
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
if (!isFilled(block.at?.traffic)) bad("at.traffic is missing — name the traffic level you priced");
|
|
594
|
+
|
|
595
|
+
const perHour = block.cost?.perHour;
|
|
596
|
+
if (typeof perHour !== "number" || !Number.isFinite(perHour)) bad("cost.perHour is not a finite number");
|
|
597
|
+
if ((perHour as number) < 0) bad("cost.perHour is negative");
|
|
598
|
+
if (!isFilled(block.cost?.currency)) bad("cost.currency is missing");
|
|
599
|
+
|
|
600
|
+
const headroom = block.headroom as { cpu?: unknown; latency?: unknown } | undefined;
|
|
601
|
+
if (!headroom || typeof headroom !== "object") bad("headroom is missing");
|
|
602
|
+
const h = headroom as { cpu?: unknown; latency?: unknown };
|
|
603
|
+
if (h.cpu !== undefined && !isFraction(h.cpu)) bad("headroom.cpu is not a fraction 0..1");
|
|
604
|
+
if (h.latency !== undefined && !isFraction(h.latency)) bad("headroom.latency is not a fraction 0..1");
|
|
605
|
+
if (h.cpu === undefined && h.latency === undefined) {
|
|
606
|
+
bad("headroom carries neither cpu nor latency — an axis you did not model is absent, and a block with no axis at all says nothing");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (!isFraction(block.errorRate)) bad("errorRate is not a fraction 0..1");
|
|
610
|
+
|
|
611
|
+
if (!isFilled(block.resilience?.failure)) {
|
|
612
|
+
bad("resilience.failure is missing — a verdict with no named failure says nothing");
|
|
613
|
+
}
|
|
614
|
+
if (statesNothing(block.resilience.failure)) {
|
|
615
|
+
bad(
|
|
616
|
+
`resilience.failure ${JSON.stringify(block.resilience.failure)} names no failure. behold takes ` +
|
|
617
|
+
"any non-empty string here, so this passes its validator and renders a confident verdict about " +
|
|
618
|
+
'nothing. Name the event: "one zone lost", "primary database failover"',
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
if (!isResilienceVerdict(block.resilience?.verdict)) {
|
|
622
|
+
bad(`resilience.verdict ${JSON.stringify(block.resilience?.verdict)} is not survives/degrades/fails`);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (block.rightSize !== undefined && !isFilled(block.rightSize.suggestion)) {
|
|
626
|
+
bad("rightSize is present without a suggestion");
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const p = block.provenance;
|
|
630
|
+
if (!p || typeof p !== "object") bad("provenance is missing");
|
|
631
|
+
if (!isFilled(p?.engine)) bad("provenance.engine is missing");
|
|
632
|
+
if (!isFilled(p?.version)) bad("provenance.version is missing");
|
|
633
|
+
if (!isFilled(p?.tolerance)) {
|
|
634
|
+
bad("provenance.tolerance is missing — a figure without a stated tolerance is not a prediction");
|
|
635
|
+
}
|
|
636
|
+
if (statesNothing(p.tolerance)) {
|
|
637
|
+
bad(
|
|
638
|
+
`provenance.tolerance ${JSON.stringify(p.tolerance)} states no tolerance. An engine with nothing ` +
|
|
639
|
+
"to say about its own error bars has no business publishing a figure; say the number, however wide",
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
if (!isBehaviourBasis(p?.basis)) {
|
|
643
|
+
bad(`provenance.basis ${JSON.stringify(p?.basis)} is not modeled/validated`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** What a lexicon states about the run itself. Everything else in the meta is copied from the request. */
|
|
648
|
+
export interface BehaviourEngineStamp {
|
|
649
|
+
engine: string;
|
|
650
|
+
version: string;
|
|
651
|
+
/** An estate total, when the engine states one of its own. Never chant's sum. */
|
|
652
|
+
total?: PredictedRate;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Build a {@link BehaviourReport}, and refuse to build an invalid one.
|
|
657
|
+
*
|
|
658
|
+
* Takes the request rather than a hand-assembled meta, and derives `at`,
|
|
659
|
+
* `edgeCoverage` and the asked-for names from it. A lexicon states only what is
|
|
660
|
+
* genuinely its own — which engine answered, at what version, and a total if
|
|
661
|
+
* the engine has one — so the three fields that must agree with the request
|
|
662
|
+
* cannot be restated differently.
|
|
663
|
+
*
|
|
664
|
+
* That is a check and not a proof, and the distinction matters enough to say
|
|
665
|
+
* plainly: {@link BehaviourReport} is a plain interface, {@link isBehaviourResult}
|
|
666
|
+
* accepts a hand-built one, and a lexicon calling this with
|
|
667
|
+
* `entityNames: Object.keys(entities)` self-certifies. What passing the request
|
|
668
|
+
* buys is that the ordinary route is checked and the check names what is wrong;
|
|
669
|
+
* a consumer that needs the guarantee runs its own
|
|
670
|
+
* `validateBehaviourResult(result, askedFor)` on arrival, which is #2358's and
|
|
671
|
+
* #2360's to write.
|
|
672
|
+
*
|
|
673
|
+
* Refusals, all naming what went wrong:
|
|
674
|
+
*
|
|
675
|
+
* - an entity in neither map — the tri-state's whole point, and the one a
|
|
676
|
+
* `continue` in a lexicon's loop produces silently;
|
|
677
|
+
* - an entity in both maps — priced and unpriced at once;
|
|
678
|
+
* - a figure for something nobody asked about;
|
|
679
|
+
* - a block priced at a level the run did not ask for;
|
|
680
|
+
* - an edge-coverage claim that names no gap ({@link validateEdgeCoverage});
|
|
681
|
+
* - any block failing {@link validateBehaviourBlock}.
|
|
682
|
+
*/
|
|
683
|
+
export function behaviourReport(
|
|
684
|
+
request: Pick<PredictBehaviourOptions, "entityNames" | "traffic" | "edgeCoverage">,
|
|
685
|
+
stamp: BehaviourEngineStamp,
|
|
686
|
+
entities: Record<string, PredictedBehaviour>,
|
|
687
|
+
unpredicted?: Record<string, UnpredictedEntity>,
|
|
688
|
+
): BehaviourReport {
|
|
689
|
+
if (!isFilled(stamp?.engine)) throw new Error("predictBehaviour: meta.engine is missing");
|
|
690
|
+
if (!isFilled(stamp?.version)) throw new Error("predictBehaviour: meta.version is missing");
|
|
691
|
+
if (!isFilled(request?.traffic)) throw new Error("predictBehaviour: meta.at.traffic is missing");
|
|
692
|
+
if (stamp.total && (!Number.isFinite(stamp.total.perHour) || stamp.total.perHour < 0)) {
|
|
693
|
+
throw new Error("predictBehaviour: meta.total.perHour is not a non-negative finite number");
|
|
694
|
+
}
|
|
695
|
+
validateEdgeCoverage(request.edgeCoverage);
|
|
696
|
+
|
|
697
|
+
const meta: BehaviourReportMeta = {
|
|
698
|
+
engine: stamp.engine,
|
|
699
|
+
version: stamp.version,
|
|
700
|
+
at: { traffic: request.traffic },
|
|
701
|
+
...(stamp.total ? { total: stamp.total } : {}),
|
|
702
|
+
// Copied, not aliased. `readonly` is erased at runtime, so assigning the
|
|
703
|
+
// request's object by reference let a caller mutate
|
|
704
|
+
// `request.edgeCoverage.unresolvedKinds` after construction and change what
|
|
705
|
+
// the report claims it was computed over — the one field whose whole job is
|
|
706
|
+
// to be the report's honest account of its own inputs.
|
|
707
|
+
edgeCoverage: copyEdgeCoverage(request.edgeCoverage),
|
|
708
|
+
};
|
|
709
|
+
const entityNames = request.entityNames;
|
|
710
|
+
const asked = new Set(entityNames);
|
|
711
|
+
const holes = new Set(Object.keys(unpredicted ?? {}));
|
|
712
|
+
|
|
713
|
+
for (const name of Object.keys(entities)) {
|
|
714
|
+
if (holes.has(name)) {
|
|
715
|
+
throw new Error(
|
|
716
|
+
`predictBehaviour reported "${name}" as both priced and unpriced. An entity has one verdict.`,
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
if (!asked.has(name)) {
|
|
720
|
+
throw new Error(
|
|
721
|
+
`predictBehaviour returned a figure for "${name}", which was not in entityNames. An engine ` +
|
|
722
|
+
"answering about entities nobody asked about is answering about the wrong estate.",
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
validateBehaviourBlock(name, entities[name]);
|
|
726
|
+
if (entities[name].at.traffic !== meta.at.traffic) {
|
|
727
|
+
throw new Error(
|
|
728
|
+
`predictBehaviour priced "${name}" at ${JSON.stringify(entities[name].at.traffic)} in a run ` +
|
|
729
|
+
`whose meta.at.traffic is ${JSON.stringify(meta.at.traffic)}. One run, one level.`,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
for (const name of holes) {
|
|
735
|
+
if (!asked.has(name)) {
|
|
736
|
+
throw new Error(`predictBehaviour reported "${name}" unpredicted, and it was not in entityNames.`);
|
|
737
|
+
}
|
|
738
|
+
if (!isBehaviourUnpredictedReason(unpredicted![name]?.reason)) {
|
|
739
|
+
throw new Error(
|
|
740
|
+
`predictBehaviour gave "${name}" the reason ` +
|
|
741
|
+
`${JSON.stringify(unpredicted![name]?.reason)}, which is not one of ` +
|
|
742
|
+
`${BEHAVIOUR_UNPREDICTED_REASONS.join(", ")}.`,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const missing = entityNames.filter(
|
|
748
|
+
(name) => !Object.prototype.hasOwnProperty.call(entities, name) && !holes.has(name),
|
|
749
|
+
);
|
|
750
|
+
if (missing.length > 0) {
|
|
751
|
+
throw new Error(
|
|
752
|
+
`predictBehaviour gave no verdict at all for ${missing.map((n) => `"${n}"`).join(", ")}. Every ` +
|
|
753
|
+
"entity asked about lands in `entities` or in `unpredicted` — there is no third position, " +
|
|
754
|
+
"because a prediction has no equivalent of \"the provider says it is not there\". An entity " +
|
|
755
|
+
"you could not price is `unsupported-kind`, not an omission.",
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
behaviour: "v1",
|
|
761
|
+
meta,
|
|
762
|
+
entities,
|
|
763
|
+
...(unpredicted && Object.keys(unpredicted).length > 0 ? { unpredicted } : {}),
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** Build a {@link BehaviourRefusalReport}. */
|
|
768
|
+
export function behaviourRefusal(refusal: BehaviourRefusal): BehaviourRefusalReport {
|
|
769
|
+
return { behaviour: "v1", refusal };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/* -------------------------------------------------------------------------- */
|
|
773
|
+
/* Comparing two results */
|
|
774
|
+
/* -------------------------------------------------------------------------- */
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Whether two figures are a delta of like things.
|
|
778
|
+
*
|
|
779
|
+
* - `comparable` — same engine, same version, same tolerance, same basis. The
|
|
780
|
+
* difference between the two numbers is a difference in the estate.
|
|
781
|
+
* - `mixed-basis` — same engine and version, one figure `modeled` off list
|
|
782
|
+
* prices and the other `validated` against a bill. Subtracting these does
|
|
783
|
+
* not measure a change in the estate; part of the difference is the
|
|
784
|
+
* difference between a price list and an invoice.
|
|
785
|
+
* - `mixed-engine` — the engine, its version or its stated tolerance differs.
|
|
786
|
+
* Two models are not one scale, and a delta across them is arithmetic on
|
|
787
|
+
* numbers that were never on the same axis.
|
|
788
|
+
*/
|
|
789
|
+
export type ProvenanceComparability = "comparable" | "mixed-basis" | "mixed-engine";
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Classify a pair of *provenances*. Almost always the wrong function to call —
|
|
793
|
+
* see {@link compareFigures}, which is the one consumers want.
|
|
794
|
+
*
|
|
795
|
+
* The limit is structural rather than an oversight: `at` lives on
|
|
796
|
+
* {@link PredictedBehaviour} and not on {@link BehaviourProvenance}, so this
|
|
797
|
+
* function cannot see the traffic level and will happily answer `comparable`
|
|
798
|
+
* for a figure at 100 rps and a figure at 1000 rps. Two predictions of the same
|
|
799
|
+
* estate by the same engine at different levels are not a delta of like things;
|
|
800
|
+
* they are answers to different questions. Use this only where the two figures
|
|
801
|
+
* are already known to share a level.
|
|
802
|
+
*/
|
|
803
|
+
export function compareProvenance(
|
|
804
|
+
a: BehaviourProvenance,
|
|
805
|
+
b: BehaviourProvenance,
|
|
806
|
+
): ProvenanceComparability {
|
|
807
|
+
if (a.engine !== b.engine || a.version !== b.version || a.tolerance !== b.tolerance) {
|
|
808
|
+
return "mixed-engine";
|
|
809
|
+
}
|
|
810
|
+
return a.basis === b.basis ? "comparable" : "mixed-basis";
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** One axis on which two figures fail to be a delta of like things. */
|
|
814
|
+
export type FigureMismatch =
|
|
815
|
+
| "mixed-engine"
|
|
816
|
+
| "mixed-level"
|
|
817
|
+
| "mixed-basis"
|
|
818
|
+
| "mixed-currency"
|
|
819
|
+
| "mixed-failure";
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* The total witness, keyed by the union — the same construction the other four
|
|
823
|
+
* closed sets use, and this one needs it as much as any.
|
|
824
|
+
*
|
|
825
|
+
* {@link figureMismatches} filters {@link compareFigures}'s output through the
|
|
826
|
+
* array below. An axis added to the union and to `compareFigures` but not to
|
|
827
|
+
* the array would be reported by one and silently dropped by the other, which
|
|
828
|
+
* is exactly the "a mismatch went unsaid" failure the set return was added to
|
|
829
|
+
* prevent, reappearing in the display path.
|
|
830
|
+
*
|
|
831
|
+
* The value is the display rank rather than `true`, so the order is derived
|
|
832
|
+
* from the witness too and there is one place to state it.
|
|
833
|
+
*/
|
|
834
|
+
const FIGURE_MISMATCH_WITNESS: Record<FigureMismatch, number> = {
|
|
835
|
+
"mixed-engine": 0,
|
|
836
|
+
"mixed-level": 1,
|
|
837
|
+
"mixed-currency": 2,
|
|
838
|
+
"mixed-basis": 3,
|
|
839
|
+
"mixed-failure": 4,
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Every mismatch, in the order a consumer should show them — most fundamental
|
|
844
|
+
* first. Order is presentation; membership is the contract.
|
|
845
|
+
*/
|
|
846
|
+
export const FIGURE_MISMATCHES: readonly FigureMismatch[] = (
|
|
847
|
+
Object.keys(FIGURE_MISMATCH_WITNESS) as FigureMismatch[]
|
|
848
|
+
).sort((a, b) => FIGURE_MISMATCH_WITNESS[a] - FIGURE_MISMATCH_WITNESS[b]);
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Classify a pair of figures for delta purposes. **This is the one to call.**
|
|
852
|
+
*
|
|
853
|
+
* Returns **every** axis on which the two disagree, not the first. An earlier
|
|
854
|
+
* version returned one label and stopped at the first mismatch, which meant a
|
|
855
|
+
* pair differing in level AND basis reported `mixed-level` and dropped the
|
|
856
|
+
* basis crossing on the floor — a consumer told "different traffic level" would
|
|
857
|
+
* caption it as such and show a modeled-minus-validated difference underneath
|
|
858
|
+
* with nothing said. Precedence is a fine way to decide what to show first and
|
|
859
|
+
* a bad way to decide what to know.
|
|
860
|
+
*
|
|
861
|
+
* The axes:
|
|
862
|
+
*
|
|
863
|
+
* - `mixed-engine` — engine, version or tolerance differs. Two models are not
|
|
864
|
+
* one scale.
|
|
865
|
+
* - `mixed-level` — different `at`. The same question asked of two different
|
|
866
|
+
* worlds.
|
|
867
|
+
* - `mixed-currency` — different `cost.currency`. chant converts nothing, so
|
|
868
|
+
* USD minus EUR is not a number. behold already refuses to *sum* these
|
|
869
|
+
* (`behold/src/behaviour.ts`); permitting them to be *differenced* left this
|
|
870
|
+
* contract laxer than its own consumer.
|
|
871
|
+
* - `mixed-basis` — one figure off a price list, the other off an invoice.
|
|
872
|
+
* - `mixed-failure` — different `resilience.failure`. "Survives one zone lost"
|
|
873
|
+
* against "survives a region lost" are two verdicts about two events, and
|
|
874
|
+
* differencing the costs beside them implies they answer the same question.
|
|
875
|
+
*
|
|
876
|
+
* The rule this contract binds its consumers to, in one sentence: **a delta
|
|
877
|
+
* between two figures whose mismatch set is not empty must be marked as such
|
|
878
|
+
* wherever it is shown, and must never be presented as a plain difference.**
|
|
879
|
+
* How it is marked is #2358's to choose. Whether it must be marked is not.
|
|
880
|
+
*/
|
|
881
|
+
export function compareFigures(
|
|
882
|
+
a: PredictedBehaviour,
|
|
883
|
+
b: PredictedBehaviour,
|
|
884
|
+
): ReadonlySet<FigureMismatch> {
|
|
885
|
+
const out = new Set<FigureMismatch>();
|
|
886
|
+
if (compareProvenance(a.provenance, b.provenance) === "mixed-engine") out.add("mixed-engine");
|
|
887
|
+
if (a.at.traffic !== b.at.traffic) out.add("mixed-level");
|
|
888
|
+
if (a.cost.currency !== b.cost.currency) out.add("mixed-currency");
|
|
889
|
+
if (a.provenance.basis !== b.provenance.basis) out.add("mixed-basis");
|
|
890
|
+
if (a.resilience.failure !== b.resilience.failure) out.add("mixed-failure");
|
|
891
|
+
return out;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** The mismatches, ordered for display. A convenience over {@link compareFigures}. */
|
|
895
|
+
export function figureMismatches(a: PredictedBehaviour, b: PredictedBehaviour): FigureMismatch[] {
|
|
896
|
+
const found = compareFigures(a, b);
|
|
897
|
+
return FIGURE_MISMATCHES.filter((m) => found.has(m));
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** True when two whole figures may be shown as a plain difference, with no mark. */
|
|
901
|
+
export function isComparableFigure(a: PredictedBehaviour, b: PredictedBehaviour): boolean {
|
|
902
|
+
return compareFigures(a, b).size === 0;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** True when two provenances may be shown as a plain difference. Level-blind — see {@link compareProvenance}. */
|
|
906
|
+
export function isComparableProvenance(a: BehaviourProvenance, b: BehaviourProvenance): boolean {
|
|
907
|
+
return compareProvenance(a, b) === "comparable";
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/* -------------------------------------------------------------------------- */
|
|
911
|
+
/* Resolving an engine, and refusing when there is none */
|
|
912
|
+
/* -------------------------------------------------------------------------- */
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* The engine a lexicon predicts against, and the variable that named it.
|
|
916
|
+
*
|
|
917
|
+
* `value` is an address — a URL, a socket path, a command on `PATH`. It is
|
|
918
|
+
* **not** a credential and this contract has no channel for one; see
|
|
919
|
+
* {@link PredictBehaviourOptions}. An engine that demands authentication is out
|
|
920
|
+
* of scope for the plugin surface, and a lexicon that needs one must reach it
|
|
921
|
+
* on its own transport without routing it through here.
|
|
922
|
+
*/
|
|
923
|
+
export interface BehaviourEngineEndpoint {
|
|
924
|
+
/** The address the lexicon will predict against. */
|
|
925
|
+
value: string;
|
|
926
|
+
/** The variable it came from, so a refusal or a log line can name it. */
|
|
927
|
+
source: string;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* The variables that can name a behaviour engine, most specific first. Exported
|
|
932
|
+
* so a refusal message and a test can agree on the chain without restating it.
|
|
933
|
+
*
|
|
934
|
+
* `lexicon` scopes the first entry, which is what lets an estate priced by two
|
|
935
|
+
* engines point each lexicon at its own without a per-call flag.
|
|
936
|
+
*/
|
|
937
|
+
export function behaviourEngineVariables(lexicon: string): string[] {
|
|
938
|
+
const scope = lexicon.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
|
|
939
|
+
return [`CHANT_BEHAVIOUR_ENGINE_${scope}`, "CHANT_BEHAVIOUR_ENGINE", "BEHAVIOUR_ENGINE"];
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Resolve the engine one lexicon predicts against, most specific first. Pure —
|
|
944
|
+
* exported for testing.
|
|
945
|
+
*
|
|
946
|
+
* The chain is lexicon-scoped, then chant-scoped, then bare, for the same
|
|
947
|
+
* reason `gitlabNoteTokenFrom` reads `CHANT_GITLAB_TOKEN` before `GITLAB_TOKEN`
|
|
948
|
+
* (`./op/activities/reconcile.ts`): the narrower name exists so a project that
|
|
949
|
+
* has to differ can differ, and the wider one exists so a project that does not
|
|
950
|
+
* need to sets one variable.
|
|
951
|
+
*
|
|
952
|
+
* Returns `undefined` when nothing in the chain answered. That is `no-engine`,
|
|
953
|
+
* and the caller turns it into {@link noBehaviourEngineMessage} — never into an
|
|
954
|
+
* empty report.
|
|
955
|
+
*/
|
|
956
|
+
export function behaviourEngineFrom(
|
|
957
|
+
lexicon: string,
|
|
958
|
+
env: Record<string, string | undefined>,
|
|
959
|
+
): BehaviourEngineEndpoint | undefined {
|
|
960
|
+
for (const source of behaviourEngineVariables(lexicon)) {
|
|
961
|
+
const value = env[source]?.trim();
|
|
962
|
+
if (value) return { value, source };
|
|
963
|
+
}
|
|
964
|
+
return undefined;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** What a lexicon says when no variable in the chain names an engine. */
|
|
968
|
+
export function noBehaviourEngineMessage(lexicon: string): string {
|
|
969
|
+
const [scoped, chantWide, bare] = behaviourEngineVariables(lexicon);
|
|
970
|
+
return (
|
|
971
|
+
`predictBehaviour has the ${lexicon} estate to predict and no engine to predict it with. Set a ` +
|
|
972
|
+
`${chantWide} environment variable to the engine's address — a URL, a socket path, or a command on ` +
|
|
973
|
+
`PATH — or ${bare} where nothing else in the environment is chant's. ${scoped} is read first, for an ` +
|
|
974
|
+
"estate whose lexicons are priced by different engines. The address is not a credential: the engine " +
|
|
975
|
+
"is never handed one and never writes."
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** What a lexicon says when a variable named an engine and the engine did not answer. */
|
|
980
|
+
export function unreachableBehaviourEngineMessage(
|
|
981
|
+
lexicon: string,
|
|
982
|
+
endpoint: BehaviourEngineEndpoint,
|
|
983
|
+
detail: string,
|
|
984
|
+
): string {
|
|
985
|
+
return (
|
|
986
|
+
`predictBehaviour reached for the ${lexicon} behaviour engine at ${redactEngineAddress(endpoint.value)}, ` +
|
|
987
|
+
`named by ${endpoint.source}, and it did not answer: ${scrubEngineDetail(detail)}. No overlay is drawn and no figure is ` +
|
|
988
|
+
`guessed locally. Check the engine is up and that ${endpoint.source} names the address this ` +
|
|
989
|
+
"environment can reach."
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* The refusal for an estate with no engine configured. The whole of the
|
|
995
|
+
* `no-engine` path, so no lexicon has to assemble one by hand.
|
|
996
|
+
*/
|
|
997
|
+
export function noBehaviourEngineRefusal(lexicon: string): BehaviourRefusalReport {
|
|
998
|
+
const [, chantWide] = behaviourEngineVariables(lexicon);
|
|
999
|
+
return behaviourRefusal({
|
|
1000
|
+
cause: "no-engine",
|
|
1001
|
+
reason: noBehaviourEngineMessage(lexicon),
|
|
1002
|
+
remedy: `Set ${chantWide} to the engine's address.`,
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** The refusal for a configured engine that did not answer. */
|
|
1007
|
+
export function unreachableBehaviourEngineRefusal(
|
|
1008
|
+
lexicon: string,
|
|
1009
|
+
endpoint: BehaviourEngineEndpoint,
|
|
1010
|
+
detail: string,
|
|
1011
|
+
): BehaviourRefusalReport {
|
|
1012
|
+
return behaviourRefusal({
|
|
1013
|
+
cause: "engine-unreachable",
|
|
1014
|
+
reason: unreachableBehaviourEngineMessage(lexicon, endpoint, detail),
|
|
1015
|
+
remedy: `Check the engine at ${redactEngineAddress(endpoint.value)} is reachable, or repoint ${endpoint.source}.`,
|
|
1016
|
+
source: endpoint.source,
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* What a lexicon says when the engine answered and refused for want of money
|
|
1022
|
+
* (#2359). Distinct from unreachable on purpose: the address is fine, the
|
|
1023
|
+
* request arrived, and telling somebody to check their networking wastes the
|
|
1024
|
+
* one thing a refusal is for.
|
|
1025
|
+
*/
|
|
1026
|
+
export function outOfCreditBehaviourEngineMessage(
|
|
1027
|
+
lexicon: string,
|
|
1028
|
+
endpoint: BehaviourEngineEndpoint,
|
|
1029
|
+
detail: string,
|
|
1030
|
+
): string {
|
|
1031
|
+
return (
|
|
1032
|
+
`The ${lexicon} behaviour engine at ${redactEngineAddress(endpoint.value)}, named by ${endpoint.source}, ` +
|
|
1033
|
+
`answered and refused: the account behind it is out of credit (${scrubEngineDetail(detail)}). The address is reachable and nothing ` +
|
|
1034
|
+
"here is a networking problem. Add credit to the account this engine bills, or point " +
|
|
1035
|
+
`${endpoint.source} at an engine on an account that has some. No overlay is drawn and no figure is ` +
|
|
1036
|
+
"guessed locally."
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** What a lexicon says when the engine answered and refused for a spent limit (#2359). */
|
|
1041
|
+
export function overQuotaBehaviourEngineMessage(
|
|
1042
|
+
lexicon: string,
|
|
1043
|
+
endpoint: BehaviourEngineEndpoint,
|
|
1044
|
+
detail: string,
|
|
1045
|
+
): string {
|
|
1046
|
+
return (
|
|
1047
|
+
`The ${lexicon} behaviour engine at ${redactEngineAddress(endpoint.value)}, named by ${endpoint.source}, ` +
|
|
1048
|
+
`answered and refused: a rate or volume limit is spent (${scrubEngineDetail(detail)}). The account has credit and the address is ` +
|
|
1049
|
+
"reachable, so this usually clears when the engine's window rolls over. Wait for it, raise the limit " +
|
|
1050
|
+
`on the account, or point ${endpoint.source} at an engine with its own budget. No overlay is drawn ` +
|
|
1051
|
+
"and no figure is guessed locally."
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** The refusal for an engine that answered and said the account has no balance (#2359). */
|
|
1056
|
+
export function outOfCreditBehaviourEngineRefusal(
|
|
1057
|
+
lexicon: string,
|
|
1058
|
+
endpoint: BehaviourEngineEndpoint,
|
|
1059
|
+
detail: string,
|
|
1060
|
+
): BehaviourRefusalReport {
|
|
1061
|
+
return behaviourRefusal({
|
|
1062
|
+
cause: "engine-out-of-credit",
|
|
1063
|
+
reason: outOfCreditBehaviourEngineMessage(lexicon, endpoint, detail),
|
|
1064
|
+
remedy: `Add credit to the account behind ${endpoint.source}, or repoint it at a funded engine.`,
|
|
1065
|
+
source: endpoint.source,
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/** The refusal for an engine that answered and said a limit is spent (#2359). */
|
|
1070
|
+
export function overQuotaBehaviourEngineRefusal(
|
|
1071
|
+
lexicon: string,
|
|
1072
|
+
endpoint: BehaviourEngineEndpoint,
|
|
1073
|
+
detail: string,
|
|
1074
|
+
): BehaviourRefusalReport {
|
|
1075
|
+
return behaviourRefusal({
|
|
1076
|
+
cause: "engine-over-quota",
|
|
1077
|
+
reason: overQuotaBehaviourEngineMessage(lexicon, endpoint, detail),
|
|
1078
|
+
remedy: `Wait for the engine's window to roll over, or raise the limit on the account behind ${endpoint.source}.`,
|
|
1079
|
+
source: endpoint.source,
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/* -------------------------------------------------------------------------- */
|
|
1084
|
+
/* Rendering */
|
|
1085
|
+
/* -------------------------------------------------------------------------- */
|
|
1086
|
+
|
|
1087
|
+
const RED = "\x1b[31m";
|
|
1088
|
+
const RESET = "\x1b[0m";
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Render a refusal for a terminal, in red.
|
|
1092
|
+
*
|
|
1093
|
+
* Red rather than the amber a degradation gets, because a missing engine is not
|
|
1094
|
+
* a partial answer: every behaviour-derived figure and colour is gone, and the
|
|
1095
|
+
* one thing the reader must not do is assume the numbers are merely late.
|
|
1096
|
+
*
|
|
1097
|
+
* `color` defaults to the same rule the rest of the CLI uses (`NO_COLOR`, and a
|
|
1098
|
+
* TTY on stdout); pass it explicitly where the output is asserted on.
|
|
1099
|
+
*/
|
|
1100
|
+
export function renderBehaviourRefusal(
|
|
1101
|
+
refusal: BehaviourRefusal,
|
|
1102
|
+
options: { color?: boolean } = {},
|
|
1103
|
+
): string {
|
|
1104
|
+
const color = options.color ?? (!process.env.NO_COLOR && process.stdout.isTTY !== false);
|
|
1105
|
+
const body = `behaviour: refused (${refusal.cause}) — ${refusal.reason}\n ${refusal.remedy}`;
|
|
1106
|
+
return color ? `${RED}${body}${RESET}` : body;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/* -------------------------------------------------------------------------- */
|
|
1110
|
+
/* The request */
|
|
1111
|
+
/* -------------------------------------------------------------------------- */
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Names this contract refuses to carry, each declared `?: never` on
|
|
1115
|
+
* {@link PredictBehaviourOptions} so the deliberate attempt is a compile error.
|
|
1116
|
+
*
|
|
1117
|
+
* This list is the *narrow* half of the guard and never the whole of it. A
|
|
1118
|
+
* denylist of names cannot enforce "the engine never sees a credential" — the
|
|
1119
|
+
* same argument that made `?: never` better than omission applies to every name
|
|
1120
|
+
* the list omits, and `xApiKey`, `pat` and `Authorization` with a capital A all
|
|
1121
|
+
* sailed past an earlier version of this file. What actually enforces the rule
|
|
1122
|
+
* is {@link assertNoCredentialInOptions}, which walks values.
|
|
1123
|
+
*/
|
|
1124
|
+
export const CREDENTIAL_OPTION_KEYS: readonly string[] = [
|
|
1125
|
+
"token",
|
|
1126
|
+
"credential",
|
|
1127
|
+
"credentials",
|
|
1128
|
+
"secret",
|
|
1129
|
+
"secrets",
|
|
1130
|
+
"password",
|
|
1131
|
+
"apiKey",
|
|
1132
|
+
"accessKey",
|
|
1133
|
+
"secretKey",
|
|
1134
|
+
"sessionToken",
|
|
1135
|
+
"auth",
|
|
1136
|
+
"authorization",
|
|
1137
|
+
"bearer",
|
|
1138
|
+
"privateKey",
|
|
1139
|
+
];
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Key names that are credential-shaped on their own but not as a substring —
|
|
1143
|
+
* the two `CREDENTIAL_ENV_NAME` (./identity.ts) does not cover.
|
|
1144
|
+
*/
|
|
1145
|
+
const EXTRA_CREDENTIAL_KEYS: readonly string[] = ["pat", "cookie"];
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Shortest string worth suspecting. Borrowed from `identity.ts`'s
|
|
1149
|
+
* `MIN_CREDENTIAL_LENGTH`, and for the same reason: below it, a "secret" is a
|
|
1150
|
+
* false positive.
|
|
1151
|
+
*/
|
|
1152
|
+
const MIN_CREDENTIAL_LENGTH = 8;
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Key suffixes that name a **reference to** a credential rather than a
|
|
1156
|
+
* credential, and the handful of whole names that do the same.
|
|
1157
|
+
*
|
|
1158
|
+
* This is the correction that made the guard usable. Run over every distinct
|
|
1159
|
+
* property key in this repo's own generated schemas, a bare substring match on
|
|
1160
|
+
* `SECRET|TOKEN|PASSWORD|AUTH` refused 406 aws keys, 173 azure, 89 gcp and 7
|
|
1161
|
+
* k8s — `imagePullSecrets`, `secretName`, `secretKeyRef` (all three are
|
|
1162
|
+
* references to a Secret *by name*), `ClientToken` (an idempotency nonce on
|
|
1163
|
+
* dozens of AWS resources), `CertificateAuthorityArn`, `authorizedNetworks`,
|
|
1164
|
+
* `passwordPolicy`, `authMode`, `oauthScopes`. A `tags: { author: "…" }` was
|
|
1165
|
+
* refused because `author` contains `auth`.
|
|
1166
|
+
*
|
|
1167
|
+
* None of those hold secret material, and every one of them is ordinary in real
|
|
1168
|
+
* infrastructure, which #2357, #2359 and #2360 all assemble their requests from.
|
|
1169
|
+
*/
|
|
1170
|
+
const REFERENCE_KEY_SUFFIXES: readonly string[] = [
|
|
1171
|
+
"ref",
|
|
1172
|
+
"refs",
|
|
1173
|
+
"name",
|
|
1174
|
+
"names",
|
|
1175
|
+
"id",
|
|
1176
|
+
"ids",
|
|
1177
|
+
"arn",
|
|
1178
|
+
"arns",
|
|
1179
|
+
"count",
|
|
1180
|
+
"validity",
|
|
1181
|
+
"mode",
|
|
1182
|
+
"modes",
|
|
1183
|
+
"policy",
|
|
1184
|
+
"policies",
|
|
1185
|
+
"scope",
|
|
1186
|
+
"scopes",
|
|
1187
|
+
"network",
|
|
1188
|
+
"networks",
|
|
1189
|
+
"type",
|
|
1190
|
+
"types",
|
|
1191
|
+
"enabled",
|
|
1192
|
+
"required",
|
|
1193
|
+
"version",
|
|
1194
|
+
"uri",
|
|
1195
|
+
"url",
|
|
1196
|
+
"urls",
|
|
1197
|
+
"config",
|
|
1198
|
+
"configs",
|
|
1199
|
+
"settings",
|
|
1200
|
+
"options",
|
|
1201
|
+
];
|
|
1202
|
+
|
|
1203
|
+
/** Whole key names that are never credential material, whatever they contain. */
|
|
1204
|
+
const REFERENCE_KEY_NAMES: readonly string[] = [
|
|
1205
|
+
"imagepullsecrets",
|
|
1206
|
+
"clienttoken",
|
|
1207
|
+
// A Kubernetes boolean: "mount the service account's token into this pod".
|
|
1208
|
+
// It holds no token and never did.
|
|
1209
|
+
"automountserviceaccounttoken",
|
|
1210
|
+
"author",
|
|
1211
|
+
"authors",
|
|
1212
|
+
"authority",
|
|
1213
|
+
"secretsmanager",
|
|
1214
|
+
];
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* `CREDENTIAL_ENV_NAME` with its underscores removed, derived from the same
|
|
1218
|
+
* source so the two cannot drift.
|
|
1219
|
+
*
|
|
1220
|
+
* That pattern was written for environment variables, where the convention is
|
|
1221
|
+
* `AWS_SECRET_ACCESS_KEY`, so several of its alternatives carry an underscore:
|
|
1222
|
+
* `PRIVATE_KEY`, `ACCESS_KEY`, `SESSION_KEY`. Object keys are written
|
|
1223
|
+
* `privateKey` and `accessKey`, which match none of them. Squashing both sides
|
|
1224
|
+
* makes one rule cover `PRIVATE_KEY`, `privateKey`, `private-key` and
|
|
1225
|
+
* `privatekey`.
|
|
1226
|
+
*/
|
|
1227
|
+
const CREDENTIAL_ENV_NAME_SQUASHED = new RegExp(CREDENTIAL_ENV_NAME.source.replace(/_/g, ""), "i");
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* True when a string carries a password in userinfo — with or without a scheme.
|
|
1231
|
+
*
|
|
1232
|
+
* The scheme-less half matters: a DSN is routinely written
|
|
1233
|
+
* `app:hunter2@db.internal:5432/prod`, with the driver supplying the scheme, and
|
|
1234
|
+
* `new URL()` will not parse that at all.
|
|
1235
|
+
*/
|
|
1236
|
+
function hasUrlPassword(value: string): boolean {
|
|
1237
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
|
|
1238
|
+
try {
|
|
1239
|
+
if (new URL(value).password !== "") return true;
|
|
1240
|
+
} catch {
|
|
1241
|
+
/* fall through to the scheme-less test */
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
// `user:pass@host` with no scheme. Both halves non-empty, no whitespace, and
|
|
1245
|
+
// a host that looks like a host, so `mailto`-ish text and prose do not match.
|
|
1246
|
+
return /^[^\s:@/]+:[^\s:@/]+@[A-Za-z0-9._-]+(?::\d+)?(?:[/?#]|$)/.test(value.trim());
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* True when a key name is credential-shaped, whatever its casing or separators.
|
|
1251
|
+
*
|
|
1252
|
+
* Reuses `CREDENTIAL_ENV_NAME`, which is a case-insensitive substring match over
|
|
1253
|
+
* `SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY|APIKEY|API_KEY|ACCESS_KEY|SESSION_KEY|AUTH`.
|
|
1254
|
+
* That one expression covers `Authorization`, `xApiKey`, `clientSecret`,
|
|
1255
|
+
* `refreshToken`, `awsSecretAccessKey` and `x-api-key` — every name an earlier
|
|
1256
|
+
* fixed 14-entry list here missed — because it is chant's existing answer to
|
|
1257
|
+
* the same question and had already been widened by everyone who hit a gap.
|
|
1258
|
+
*/
|
|
1259
|
+
function isCredentialKey(key: string): boolean {
|
|
1260
|
+
const squashed = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
1261
|
+
if (REFERENCE_KEY_NAMES.includes(squashed)) return false;
|
|
1262
|
+
if (REFERENCE_KEY_SUFFIXES.some((s) => squashed.endsWith(s))) return false;
|
|
1263
|
+
if (EXTRA_CREDENTIAL_KEYS.includes(squashed)) return true;
|
|
1264
|
+
return CREDENTIAL_ENV_NAME.test(key) || CREDENTIAL_ENV_NAME_SQUASHED.test(squashed);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* True when a string is evidently a *pointer to* a secret rather than one.
|
|
1269
|
+
*
|
|
1270
|
+
* `AdminPassword` on `AWS::DirectoryService::MicrosoftAD` is a genuine
|
|
1271
|
+
* credential field, and chant source almost always fills it with a
|
|
1272
|
+
* `{{resolve:secretsmanager:…}}` string or an interpolation rather than a
|
|
1273
|
+
* literal. Refusing those would refuse the correct way to write it.
|
|
1274
|
+
*/
|
|
1275
|
+
function looksLikeSecretReference(value: string): boolean {
|
|
1276
|
+
return (
|
|
1277
|
+
/^\{\{[^}]+\}\}$/.test(value.trim()) ||
|
|
1278
|
+
/^\$\{[^}]+\}$/.test(value.trim()) ||
|
|
1279
|
+
/^!(?:Ref|GetAtt|Sub|ImportValue)\b/.test(value.trim()) ||
|
|
1280
|
+
/^arn:[a-z0-9-]*:secretsmanager:/i.test(value.trim()) ||
|
|
1281
|
+
/^projects\/[^/]+\/secrets\/[^/]+/.test(value.trim())
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* What a credential-shaped value is, or `undefined`. Named so a refusal can say
|
|
1287
|
+
* which rule fired rather than "something looked wrong".
|
|
1288
|
+
*/
|
|
1289
|
+
function credentialValueShape(value: string): string | undefined {
|
|
1290
|
+
for (const re of CREDENTIAL_SHAPES) {
|
|
1291
|
+
re.lastIndex = 0;
|
|
1292
|
+
if (re.test(value)) return "a literal credential shape (PEM key, JWT or Authorization value)";
|
|
1293
|
+
}
|
|
1294
|
+
for (const { name, re } of CREDENTIAL_TOKEN_SHAPES) {
|
|
1295
|
+
re.lastIndex = 0;
|
|
1296
|
+
if (re.test(value)) return name;
|
|
1297
|
+
}
|
|
1298
|
+
if (hasUrlPassword(value)) return "a password in a URL's userinfo";
|
|
1299
|
+
return undefined;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/** A URL-ish string with its userinfo blanked and its query dropped. */
|
|
1303
|
+
function stripUrlSecrets(value: string): string {
|
|
1304
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return value;
|
|
1305
|
+
try {
|
|
1306
|
+
const url = new URL(value);
|
|
1307
|
+
const hadUserinfo = url.username !== "" || url.password !== "";
|
|
1308
|
+
const hadQuery = url.search !== "";
|
|
1309
|
+
// The fragment too. OAuth's implicit flow puts the access token there
|
|
1310
|
+
// (`#access_token=…`), it never reaches a server, and it was untouched.
|
|
1311
|
+
const hadFragment = url.hash !== "";
|
|
1312
|
+
url.username = "";
|
|
1313
|
+
url.password = "";
|
|
1314
|
+
url.search = "";
|
|
1315
|
+
url.hash = "";
|
|
1316
|
+
// Markers are appended AFTER `toString()` and in wire order — query then
|
|
1317
|
+
// fragment. Appending the query marker to a string that still carried a
|
|
1318
|
+
// fragment used to land it inside the fragment.
|
|
1319
|
+
let out = url.toString();
|
|
1320
|
+
if (hadUserinfo) out = out.replace("://", `://${REDACTED}@`);
|
|
1321
|
+
if (hadQuery) out += `?${REDACTED}`;
|
|
1322
|
+
if (hadFragment) out += `#${REDACTED}`;
|
|
1323
|
+
return out;
|
|
1324
|
+
} catch {
|
|
1325
|
+
return value;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Blank the value of an inline command-line flag: `--token=x`, `-p x`,
|
|
1331
|
+
* `--api-key x`. A behaviour engine may be a command on `PATH`, and a command
|
|
1332
|
+
* carries its credential as an argument rather than in a URL, so the URL parse
|
|
1333
|
+
* has nothing to bite on and `redactCredentialMaterial` — which knows env
|
|
1334
|
+
* values and ten token prefixes — sees an inline flag value as neither.
|
|
1335
|
+
*/
|
|
1336
|
+
function stripFlagSecrets(value: string): string {
|
|
1337
|
+
return value
|
|
1338
|
+
.replace(
|
|
1339
|
+
/(--?[A-Za-z0-9-]*(?:secret|token|password|passwd|key|credential|auth|pat)[A-Za-z0-9-]*)([=\s])(\S+)/gi,
|
|
1340
|
+
(_m, flag: string, sep: string) => `${flag}${sep}${REDACTED}`,
|
|
1341
|
+
)
|
|
1342
|
+
.replace(/(^|\s)(-[pPkK])(\s+)(\S+)/g, (_m, lead: string, flag: string, sp: string) => `${lead}${flag}${sp}${REDACTED}`);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* An engine address, safe to interpolate into a refusal that a merge-request
|
|
1347
|
+
* comment will carry (#2358).
|
|
1348
|
+
*
|
|
1349
|
+
* `CHANT_BEHAVIOUR_ENGINE=https://svc:s3cr3t@engine.internal/predict?key=abc` is
|
|
1350
|
+
* an ordinary way to point at an authenticated endpoint, and it is the shape
|
|
1351
|
+
* this contract's own "resolve auth on your own transport" guidance produces.
|
|
1352
|
+
* Printing it verbatim in a refusal publishes it.
|
|
1353
|
+
*
|
|
1354
|
+
* Three passes, in order:
|
|
1355
|
+
*
|
|
1356
|
+
* 1. The URL parse blanks `username`/`password`, and drops the query string and
|
|
1357
|
+
* the fragment **whole** rather than by known parameter name — `?key=`,
|
|
1358
|
+
* `?token=`, `?sig=` and `#access_token=` are all common and the set is not
|
|
1359
|
+
* enumerable. The fragment matters on its own account: OAuth's implicit
|
|
1360
|
+
* flow puts the access token there.
|
|
1361
|
+
* 2. Inline command-line flag values (`--token=…`, `-p …`), for an engine that
|
|
1362
|
+
* is a command on `PATH` rather than a URL.
|
|
1363
|
+
* 3. {@link redactCredentialMaterial}, for env-held values and the token
|
|
1364
|
+
* shapes chant knows.
|
|
1365
|
+
*
|
|
1366
|
+
* **What survives:** a credential written as a bare path segment
|
|
1367
|
+
* (`https://host/predict/<token>/go`) unless it matches a known token shape.
|
|
1368
|
+
* Nothing here can tell that segment from a resource id, and an earlier version
|
|
1369
|
+
* of this doc claimed pass 3 caught it, which was wrong. If an engine
|
|
1370
|
+
* authenticates by path, do not put its address in a variable whose refusal
|
|
1371
|
+
* text is published.
|
|
1372
|
+
*/
|
|
1373
|
+
export function redactEngineAddress(
|
|
1374
|
+
value: string,
|
|
1375
|
+
env: Record<string, string | undefined> = process.env,
|
|
1376
|
+
): string {
|
|
1377
|
+
return redactCredentialMaterial(stripFlagSecrets(stripUrlSecrets(value)), env);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* Engine-supplied detail, bounded and scrubbed before it reaches a refusal.
|
|
1382
|
+
*
|
|
1383
|
+
* `detail` on a credit or quota refusal is whatever the engine said, echoed.
|
|
1384
|
+
* An engine is a third party: it can be verbose, and it can quote the request
|
|
1385
|
+
* back at you, which is how a URL or an id ends up in a public comment. So the
|
|
1386
|
+
* text is truncated, anything URL-shaped is reduced to its host, and the result
|
|
1387
|
+
* goes through {@link redactCredentialMaterial}.
|
|
1388
|
+
*/
|
|
1389
|
+
export function scrubEngineDetail(
|
|
1390
|
+
detail: string,
|
|
1391
|
+
env: Record<string, string | undefined> = process.env,
|
|
1392
|
+
): string {
|
|
1393
|
+
const withoutUrls = detail.replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, (match) => {
|
|
1394
|
+
try {
|
|
1395
|
+
return new URL(match).host || REDACTED;
|
|
1396
|
+
} catch {
|
|
1397
|
+
return REDACTED;
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
const scrubbed = redactCredentialMaterial(withoutUrls, env).replace(/\s+/g, " ").trim();
|
|
1401
|
+
return scrubbed.length > MAX_ENGINE_DETAIL ? `${scrubbed.slice(0, MAX_ENGINE_DETAIL)}…` : scrubbed;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/** How much engine-supplied text a refusal will repeat. */
|
|
1405
|
+
const MAX_ENGINE_DETAIL = 200;
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* How complete a request's edge list is (#2360).
|
|
1409
|
+
*
|
|
1410
|
+
* - `complete` — every reference between the named entities is in `edges`.
|
|
1411
|
+
* Only claim this when the builder knows it: a declared-path build walking
|
|
1412
|
+
* resolved `AttrRef`s does, a live rebuild over a partial catalog does not.
|
|
1413
|
+
* - `partial` — some references are known to be missing, and `dangling` or
|
|
1414
|
+
* `unresolvedKinds` says which. An engine may still answer, and should
|
|
1415
|
+
* discount its own confidence.
|
|
1416
|
+
* - `unknown` — the builder cannot say. Treat like `partial` and trust nothing
|
|
1417
|
+
* that depends on reachability.
|
|
1418
|
+
*/
|
|
1419
|
+
export type EdgeCoverageVerdict = "complete" | "partial" | "unknown";
|
|
1420
|
+
|
|
1421
|
+
const EDGE_COVERAGE_WITNESS: Record<EdgeCoverageVerdict, true> = {
|
|
1422
|
+
complete: true,
|
|
1423
|
+
partial: true,
|
|
1424
|
+
unknown: true,
|
|
1425
|
+
};
|
|
1426
|
+
|
|
1427
|
+
/** Every legal {@link EdgeCoverageVerdict}. */
|
|
1428
|
+
export const EDGE_COVERAGE_VERDICTS: readonly EdgeCoverageVerdict[] = Object.keys(
|
|
1429
|
+
EDGE_COVERAGE_WITNESS,
|
|
1430
|
+
) as EdgeCoverageVerdict[];
|
|
1431
|
+
|
|
1432
|
+
/** True when `value` is a legal {@link EdgeCoverageVerdict}. */
|
|
1433
|
+
export function isEdgeCoverageVerdict(value: unknown): value is EdgeCoverageVerdict {
|
|
1434
|
+
return typeof value === "string" && (EDGE_COVERAGE_VERDICTS as readonly string[]).includes(value);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* What a caller knows about the completeness of the graph it is handing over.
|
|
1439
|
+
*
|
|
1440
|
+
* Note on `complete` with a non-empty {@link dangling}: that is **not** a
|
|
1441
|
+
* contradiction and must not be "fixed". A dangling reference points at
|
|
1442
|
+
* something outside the named entity set by definition — a cross-account VPC, a
|
|
1443
|
+
* resource another team owns — so a builder can have found every edge among the
|
|
1444
|
+
* entities it was asked about and still have references leaving the estate.
|
|
1445
|
+
* `complete` is a claim about the edges *between the named entities*.
|
|
1446
|
+
*/
|
|
1447
|
+
export interface BehaviourEdgeCoverage {
|
|
1448
|
+
verdict: EdgeCoverageVerdict;
|
|
1449
|
+
/**
|
|
1450
|
+
* References that resolved to no entity in this request — the `dangling` list
|
|
1451
|
+
* `reconstructEdges` (./graph-refs.ts) already returns and used to discard.
|
|
1452
|
+
*
|
|
1453
|
+
* `DanglingRef`, not a flattened string. The record carries `from`, and `from`
|
|
1454
|
+
* is the field that says *which* entity's path leaves the estate; flattening
|
|
1455
|
+
* to the target value alone leaves an engine knowing that something dangles
|
|
1456
|
+
* and not what.
|
|
1457
|
+
*/
|
|
1458
|
+
dangling?: readonly DanglingRef[];
|
|
1459
|
+
/**
|
|
1460
|
+
* Entity types the builder has no reference rules for, so nothing was looked
|
|
1461
|
+
* for. This is the quiet one: a kind with no `RefRule` produces no edges and
|
|
1462
|
+
* no complaint.
|
|
1463
|
+
*/
|
|
1464
|
+
unresolvedKinds?: readonly string[];
|
|
1465
|
+
/**
|
|
1466
|
+
* Containment as traversable edges — populate from
|
|
1467
|
+
* `reconstructEdges().containmentEdges` (./graph-refs.ts), **not** from the
|
|
1468
|
+
* `containment: ContainmentPair[]` field beside it. The two are the same
|
|
1469
|
+
* relationships in two shapes, and only the edge shape belongs here; the
|
|
1470
|
+
* field was called `containment` and pointed #2360 straight at the wrong one.
|
|
1471
|
+
*
|
|
1472
|
+
* Carried separately from {@link PredictBehaviourOptions.edges} because
|
|
1473
|
+
* `chant graph` draws containment as a boundary rather than a line, and
|
|
1474
|
+
* putting it in `edges` would draw a line from every resource to its VPC. An
|
|
1475
|
+
* engine asked whether an estate survives one zone lost needs the membership
|
|
1476
|
+
* and `edges` will never have it.
|
|
1477
|
+
*/
|
|
1478
|
+
containmentEdges?: readonly IREdge[];
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Refuse an edge-coverage claim that does not say anything.
|
|
1483
|
+
*
|
|
1484
|
+
* `partial` means "some references are known to be missing", and a `partial`
|
|
1485
|
+
* with neither `dangling` nor `unresolvedKinds` names none of them — which is
|
|
1486
|
+
* `unknown` wearing a more confident word. Use `unknown` for "I cannot say";
|
|
1487
|
+
* `partial` is for "I can say, and here it is".
|
|
1488
|
+
*/
|
|
1489
|
+
/**
|
|
1490
|
+
* A detached copy, frozen. The report's account of its own inputs must not
|
|
1491
|
+
* change after the report exists, and `readonly` buys nothing at runtime.
|
|
1492
|
+
*/
|
|
1493
|
+
export function copyEdgeCoverage(coverage: BehaviourEdgeCoverage): BehaviourEdgeCoverage {
|
|
1494
|
+
const copy: BehaviourEdgeCoverage = {
|
|
1495
|
+
verdict: coverage.verdict,
|
|
1496
|
+
...(coverage.dangling ? { dangling: Object.freeze(coverage.dangling.map((d) => Object.freeze({ ...d }))) } : {}),
|
|
1497
|
+
...(coverage.unresolvedKinds ? { unresolvedKinds: Object.freeze([...coverage.unresolvedKinds]) } : {}),
|
|
1498
|
+
...(coverage.containmentEdges
|
|
1499
|
+
? { containmentEdges: Object.freeze(coverage.containmentEdges.map((e) => Object.freeze({ ...e }))) }
|
|
1500
|
+
: {}),
|
|
1501
|
+
};
|
|
1502
|
+
return Object.freeze(copy);
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
export function validateEdgeCoverage(coverage: BehaviourEdgeCoverage): void {
|
|
1506
|
+
if (!isEdgeCoverageVerdict(coverage?.verdict)) {
|
|
1507
|
+
throw new Error(
|
|
1508
|
+
`predictBehaviour: edgeCoverage.verdict ${JSON.stringify(coverage?.verdict)} is not ` +
|
|
1509
|
+
`${EDGE_COVERAGE_VERDICTS.join("/")}.`,
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
if (
|
|
1513
|
+
coverage.verdict === "partial" &&
|
|
1514
|
+
(coverage.dangling?.length ?? 0) === 0 &&
|
|
1515
|
+
(coverage.unresolvedKinds?.length ?? 0) === 0
|
|
1516
|
+
) {
|
|
1517
|
+
throw new Error(
|
|
1518
|
+
'predictBehaviour: edgeCoverage is "partial" and names nothing missing. Populate `dangling` ' +
|
|
1519
|
+
'or `unresolvedKinds`, or say "unknown" — a partial that lists no gap is an unknown with a ' +
|
|
1520
|
+
"more confident word on it.",
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* What core hands a lexicon's `predictBehaviour`.
|
|
1527
|
+
*
|
|
1528
|
+
* The first seven fields are `observeResourcesDeep`'s options, field for field
|
|
1529
|
+
* and doc for doc, so a caller that already drives the deep read drives this
|
|
1530
|
+
* with the same object plus `traffic`. That is the point of the mirror: the two
|
|
1531
|
+
* reads answer different questions about the same request.
|
|
1532
|
+
*
|
|
1533
|
+
* The `?: never` block is the enforcement half of "the engine never sees
|
|
1534
|
+
* credentials". Declaring the keys rather than omitting them buys a real check:
|
|
1535
|
+
* an omitted key is only caught by the excess-property check on an object
|
|
1536
|
+
* literal, and slips through a spread or a widened variable, while `never`
|
|
1537
|
+
* rejects a `string` from any position and names the field in the error.
|
|
1538
|
+
*/
|
|
1539
|
+
export interface PredictBehaviourOptions {
|
|
1540
|
+
environment: string;
|
|
1541
|
+
buildOutput: string;
|
|
1542
|
+
entityNames: string[];
|
|
1543
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
1544
|
+
/** Deployed stack to predict for, in a multi-stack project (see `stacks` in `ChantConfig`). */
|
|
1545
|
+
stack?: string;
|
|
1546
|
+
/** Region the stack is deployed in, mirroring the deep read (#1267). Omitted keeps the ambient default. */
|
|
1547
|
+
region?: string;
|
|
1548
|
+
/** Restrict to chant-owned resources (#119). An entity withheld here is `filtered`, not absent. */
|
|
1549
|
+
owned?: boolean;
|
|
1550
|
+
/**
|
|
1551
|
+
* The traffic level to predict at, verbatim: `100 rps, p50`. The one field
|
|
1552
|
+
* the deep read has no counterpart for, and the reason a prediction can never
|
|
1553
|
+
* be mistaken for an observation — an observation is not *at* anything.
|
|
1554
|
+
*
|
|
1555
|
+
* chant does not parse it, does not default it, and does not convert it. An
|
|
1556
|
+
* engine that cannot understand the level it was handed refuses; it does not
|
|
1557
|
+
* substitute one it likes better.
|
|
1558
|
+
*/
|
|
1559
|
+
traffic: string;
|
|
1560
|
+
/**
|
|
1561
|
+
* The edges between the entities above (#2355) — the half of "a resource
|
|
1562
|
+
* graph" that a bag of nodes is not.
|
|
1563
|
+
*
|
|
1564
|
+
* This is the one field where the mirror of `observeResourcesDeep`'s options
|
|
1565
|
+
* deliberately breaks, and it breaks because the two reads want different
|
|
1566
|
+
* things. A deep read answers per entity and needs no neighbours: an S3
|
|
1567
|
+
* bucket's live property tree is the same tree whether or not a Lambda reads
|
|
1568
|
+
* from it. A prediction is the opposite. Headroom, an error rate and a
|
|
1569
|
+
* resilience verdict under "one zone lost" are all statements about a path
|
|
1570
|
+
* through the estate, and an engine handed nodes alone can only price each
|
|
1571
|
+
* box in isolation, which is the arithmetic a consumer could already do for
|
|
1572
|
+
* itself.
|
|
1573
|
+
*
|
|
1574
|
+
* `IREdge` (./graph-ir.ts) rather than an edge type of this contract's own,
|
|
1575
|
+
* for one reason that outranks the tidiness of a purpose-built shape: it is
|
|
1576
|
+
* already the engine-neutral edge that BOTH paths produce. `collectEdges`
|
|
1577
|
+
* builds them from declared `AttrRef`s and lexicon-resolved entity
|
|
1578
|
+
* references on the declared path, and `reconstructEdges` (./graph-refs.ts)
|
|
1579
|
+
* rebuilds them from observed physical identifiers on the live path, which
|
|
1580
|
+
* is the path #2360 assembles this request on. A second edge type here would
|
|
1581
|
+
* put a lossy translation hop on each side, and the epic wants the declared
|
|
1582
|
+
* prediction and the live prediction shown as a delta — two shapes that have
|
|
1583
|
+
* each been through a different translation are the worst possible input to
|
|
1584
|
+
* a delta. `DependencyObservation.edges` (./lexicon.ts) already carries
|
|
1585
|
+
* `IREdge` for the same reason.
|
|
1586
|
+
*
|
|
1587
|
+
* `from` and `to` are chant entity names, the keys {@link entityNames} and
|
|
1588
|
+
* {@link entities} use. An edge naming an entity outside `entityNames`
|
|
1589
|
+
* points outside the estate the caller asked about, and an engine may ignore
|
|
1590
|
+
* it.
|
|
1591
|
+
*
|
|
1592
|
+
* Required, and an empty array is a claim rather than a shrug: it says this
|
|
1593
|
+
* estate's entities reference nothing of each other. A caller that has not
|
|
1594
|
+
* computed edges must not pass `[]` and call it a graph, for the same reason
|
|
1595
|
+
* absence and unreadness are separate verdicts everywhere else here — which
|
|
1596
|
+
* is what {@link edgeCoverage} exists to let it say instead.
|
|
1597
|
+
*/
|
|
1598
|
+
edges: readonly IREdge[];
|
|
1599
|
+
/**
|
|
1600
|
+
* How complete {@link edges} is, and what is known to be missing from it.
|
|
1601
|
+
*
|
|
1602
|
+
* `edges: []` cannot distinguish "nothing references anything" from "I could
|
|
1603
|
+
* not work out what references what", and both are ordinary outcomes on the
|
|
1604
|
+
* live path. `reconstructEdges` (./graph-refs.ts) returns `dangling` — the
|
|
1605
|
+
* references it resolved to no observed node — and drops them; a kind with no
|
|
1606
|
+
* `RefRule` in the lexicon's catalog contributes no edges at all and says
|
|
1607
|
+
* nothing about it; and containment (a subnet inside a VPC) is deliberately
|
|
1608
|
+
* not an edge, which means zone membership is absent from `edges` by design.
|
|
1609
|
+
* An engine asked "does this survive one zone lost" over a graph with no zone
|
|
1610
|
+
* membership answers confidently and wrongly.
|
|
1611
|
+
*
|
|
1612
|
+
* So the completeness is stated rather than assumed, and stated *now*, before
|
|
1613
|
+
* five consumers are written against a field that silently means "complete".
|
|
1614
|
+
*/
|
|
1615
|
+
edgeCoverage: BehaviourEdgeCoverage;
|
|
1616
|
+
|
|
1617
|
+
/** Not a channel. See {@link CREDENTIAL_OPTION_KEYS}. */
|
|
1618
|
+
token?: never;
|
|
1619
|
+
/** Not a channel. */
|
|
1620
|
+
credential?: never;
|
|
1621
|
+
/** Not a channel. */
|
|
1622
|
+
credentials?: never;
|
|
1623
|
+
/** Not a channel. */
|
|
1624
|
+
secret?: never;
|
|
1625
|
+
/** Not a channel. */
|
|
1626
|
+
secrets?: never;
|
|
1627
|
+
/** Not a channel. */
|
|
1628
|
+
password?: never;
|
|
1629
|
+
/** Not a channel. */
|
|
1630
|
+
apiKey?: never;
|
|
1631
|
+
/** Not a channel. */
|
|
1632
|
+
accessKey?: never;
|
|
1633
|
+
/** Not a channel. */
|
|
1634
|
+
secretKey?: never;
|
|
1635
|
+
/** Not a channel. */
|
|
1636
|
+
sessionToken?: never;
|
|
1637
|
+
/** Not a channel. */
|
|
1638
|
+
auth?: never;
|
|
1639
|
+
/** Not a channel. */
|
|
1640
|
+
authorization?: never;
|
|
1641
|
+
/** Not a channel. */
|
|
1642
|
+
bearer?: never;
|
|
1643
|
+
/** Not a channel. */
|
|
1644
|
+
privateKey?: never;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
/** How deep the credential walk goes before it stops descending. */
|
|
1648
|
+
/**
|
|
1649
|
+
* How deep the credential walk goes before it stops descending and says so.
|
|
1650
|
+
*
|
|
1651
|
+
* Budget the levels honestly: `options → entities → .get(name) → props` spends
|
|
1652
|
+
* three before a single declared property is reached, so the number here is not
|
|
1653
|
+
* the depth a lexicon author is thinking about. A Kubernetes workload tree
|
|
1654
|
+
* (`spec.template.spec.containers[].env[].valueFrom.secretKeyRef.key`) or a
|
|
1655
|
+
* nested CloudFormation `Properties` block runs to a dozen on its own, and
|
|
1656
|
+
* exceeding this is now a *refusal* rather than silence — so a budget set too
|
|
1657
|
+
* low refuses real estates instead of protecting them.
|
|
1658
|
+
*/
|
|
1659
|
+
const CREDENTIAL_WALK_DEPTH = 32;
|
|
1660
|
+
|
|
1661
|
+
/**
|
|
1662
|
+
* Refuse a request carrying anything credential-shaped, anywhere in it.
|
|
1663
|
+
*
|
|
1664
|
+
* The `?: never` fields on {@link PredictBehaviourOptions} stop the deliberate
|
|
1665
|
+
* attempt at compile time. This stops the accident, which is the one that
|
|
1666
|
+
* happens: it walks **the whole request** — including `entities[*].props`,
|
|
1667
|
+
* which is `Record<string, unknown>` straight out of the build and which no
|
|
1668
|
+
* type on this contract can see into, and every field of every
|
|
1669
|
+
* {@link import("./graph-ir").IREdge}.
|
|
1670
|
+
*
|
|
1671
|
+
* ## What it detects
|
|
1672
|
+
*
|
|
1673
|
+
* 1. **A credential-shaped value**, whatever the key is called: the PEM / JWT /
|
|
1674
|
+
* `Bearer` shapes in `CREDENTIAL_SHAPES`, plus the provider-prefixed tokens
|
|
1675
|
+
* in `CREDENTIAL_TOKEN_SHAPES` (GitHub, GitLab, OpenAI, Stripe, Slack, AWS,
|
|
1676
|
+
* Google, npm).
|
|
1677
|
+
* 2. **A password in userinfo**, with or without a scheme — `new URL()` for the
|
|
1678
|
+
* former and a pattern for `app:hunter2@db.internal:5432/prod`, which is how
|
|
1679
|
+
* a DSN is usually written and which `URL` will not parse at all.
|
|
1680
|
+
* 3. **A credential-shaped key**, but only when its value is a string of at
|
|
1681
|
+
* least {@link MIN_CREDENTIAL_LENGTH} characters that is not an evident
|
|
1682
|
+
* reference, and only when the key is not in the reference family
|
|
1683
|
+
* ({@link REFERENCE_KEY_SUFFIXES}).
|
|
1684
|
+
*
|
|
1685
|
+
* Rules 1 and 2 throw. Rule 3, and a structure deeper than the walk reads,
|
|
1686
|
+
* produce a refusal instead — see {@link screenBehaviourRequest} for why the
|
|
1687
|
+
* blast radii differ.
|
|
1688
|
+
*
|
|
1689
|
+
* **The name layer is a fast path, not a safety net.** After the gating above
|
|
1690
|
+
* it fires on very little, and a credential under a benign name — `dsn`,
|
|
1691
|
+
* `config`, `note` — is caught by rules 1 and 2 or not at all. That is the
|
|
1692
|
+
* intended division of labour and the reason those two exist; do not read
|
|
1693
|
+
* rule 3 as the guarantee.
|
|
1694
|
+
*
|
|
1695
|
+
* ## What it deliberately does not detect
|
|
1696
|
+
*
|
|
1697
|
+
* Rule 1 is **a denylist of known formats, not a proof**. A token from a
|
|
1698
|
+
* provider nobody has added, an internal issuer's format, a bare random string
|
|
1699
|
+
* or a base64 blob passes every rule here, as does a secret split across two
|
|
1700
|
+
* fields or encoded. There is no entropy scoring, on purpose: this walks build
|
|
1701
|
+
* output full of ids, ARNs, hashes and digests, and a heuristic that refuses
|
|
1702
|
+
* those would refuse real projects rather than protect them. So the honest
|
|
1703
|
+
* statement of the guarantee is that a credential a human would recognize on
|
|
1704
|
+
* sight will not reach the engine by accident, and that a novel or opaque
|
|
1705
|
+
* secret still can. A lexicon author putting secret material in `props` is
|
|
1706
|
+
* outside what this contract can catch, and the remedy there is not to.
|
|
1707
|
+
*/
|
|
1708
|
+
export type CredentialRule = "value-shape" | "key-name" | "walk-depth";
|
|
1709
|
+
|
|
1710
|
+
/** One thing the walk objected to, and which rule objected. */
|
|
1711
|
+
export interface CredentialFinding {
|
|
1712
|
+
/** Where in the request, as a readable path (`options.entities.get(db).props.dsn`). */
|
|
1713
|
+
path: string;
|
|
1714
|
+
rule: CredentialRule;
|
|
1715
|
+
/** What was found, in words, for the message. Never the value itself. */
|
|
1716
|
+
what: string;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
/**
|
|
1720
|
+
* Every objection the walk has to a request. Pure, and exported so a caller can
|
|
1721
|
+
* decide what to do rather than take this module's word for it.
|
|
1722
|
+
*/
|
|
1723
|
+
export function findCredentialsInOptions(options: object): CredentialFinding[] {
|
|
1724
|
+
const findings: CredentialFinding[] = [];
|
|
1725
|
+
const seen = new WeakSet<object>();
|
|
1726
|
+
|
|
1727
|
+
const walk = (value: unknown, path: string, depth: number): void => {
|
|
1728
|
+
if (depth > CREDENTIAL_WALK_DEPTH) {
|
|
1729
|
+
// Not silence. A request deeper than the walk goes is a request this
|
|
1730
|
+
// guard has not actually checked, and saying nothing about it is the
|
|
1731
|
+
// shape of failure this whole contract exists to refuse.
|
|
1732
|
+
findings.push({
|
|
1733
|
+
path,
|
|
1734
|
+
rule: "walk-depth",
|
|
1735
|
+
what: `a structure deeper than ${CREDENTIAL_WALK_DEPTH} levels, which the credential walk did not read`,
|
|
1736
|
+
});
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
if (typeof value === "string") {
|
|
1740
|
+
const shape = credentialValueShape(value);
|
|
1741
|
+
if (shape) findings.push({ path, rule: "value-shape", what: shape });
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
if (value === null || typeof value !== "object") return;
|
|
1745
|
+
if (seen.has(value)) return;
|
|
1746
|
+
seen.add(value);
|
|
1747
|
+
|
|
1748
|
+
if (Array.isArray(value)) {
|
|
1749
|
+
value.forEach((item, i) => walk(item, `${path}[${i}]`, depth + 1));
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
if (value instanceof Map) {
|
|
1753
|
+
for (const [key, item] of value) {
|
|
1754
|
+
const label = typeof key === "string" ? key : String(key);
|
|
1755
|
+
const child = `${path}.get(${label})`;
|
|
1756
|
+
// Both halves. A Map's KEY is as likely to be `DB_PASSWORD` as an
|
|
1757
|
+
// object's is — an env block is the obvious case — and walking only
|
|
1758
|
+
// values missed it entirely.
|
|
1759
|
+
if (typeof key === "string") checkKey(key, child, item);
|
|
1760
|
+
walk(item, child, depth + 1);
|
|
1761
|
+
}
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
if (value instanceof Set) {
|
|
1765
|
+
let i = 0;
|
|
1766
|
+
for (const item of value) walk(item, `${path}.item[${i++}]`, depth + 1);
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1770
|
+
const child = `${path}.${key}`;
|
|
1771
|
+
checkKey(key, child, item);
|
|
1772
|
+
walk(item, child, depth + 1);
|
|
1773
|
+
}
|
|
1774
|
+
};
|
|
1775
|
+
|
|
1776
|
+
/**
|
|
1777
|
+
* The name arm. Gated on the value being a string long enough to be a secret,
|
|
1778
|
+
* and not an evident reference to one — see {@link REFERENCE_KEY_SUFFIXES}
|
|
1779
|
+
* for why the ungated version was unusable.
|
|
1780
|
+
*/
|
|
1781
|
+
function checkKey(key: string, path: string, item: unknown): void {
|
|
1782
|
+
if (typeof item !== "string") return;
|
|
1783
|
+
if (item.length < MIN_CREDENTIAL_LENGTH) return;
|
|
1784
|
+
if (looksLikeSecretReference(item)) return;
|
|
1785
|
+
if (!isCredentialKey(key)) return;
|
|
1786
|
+
findings.push({ path, rule: "key-name", what: `a credential-shaped field name ("${key}")` });
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
walk(options, "options", 0);
|
|
1790
|
+
return findings;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
/**
|
|
1794
|
+
* Throw when the request carries something that **is** a credential.
|
|
1795
|
+
*
|
|
1796
|
+
* **Not the entry point.** This applies one of the three rules and discards the
|
|
1797
|
+
* other two, so a `key-name` hit and a `walk-depth` hit both pass it silently.
|
|
1798
|
+
* Call {@link screenBehaviourRequest}, which runs this and then acts on what is
|
|
1799
|
+
* left. This stays exported because "did the request contain an actual token"
|
|
1800
|
+
* is a question worth asking on its own, and because the split is what keeps
|
|
1801
|
+
* the blast radii different.
|
|
1802
|
+
*
|
|
1803
|
+
* Only the value-shape rule throws, and throwing is deliberate: a live token in
|
|
1804
|
+
* a request bound for a third party is not a degradation to report, it is a
|
|
1805
|
+
* stop. Throwing is the whole-lexicon failure per `lexicon.ts`, which is the
|
|
1806
|
+
* right blast radius for this and the wrong one for a suspicious field name.
|
|
1807
|
+
*/
|
|
1808
|
+
export function assertNoCredentialInOptions(options: object): void {
|
|
1809
|
+
const found = findCredentialsInOptions(options).filter((f) => f.rule === "value-shape");
|
|
1810
|
+
if (found.length === 0) return;
|
|
1811
|
+
const [first] = found;
|
|
1812
|
+
throw new Error(
|
|
1813
|
+
`predictBehaviour was passed ${first.what} at ${first.path}. The behaviour engine is never handed ` +
|
|
1814
|
+
"a credential: it is given the resource graph and a traffic level, and nothing it receives can " +
|
|
1815
|
+
"reach the account. Remove it — a lexicon that needs authenticated access to its own engine " +
|
|
1816
|
+
"resolves that on its own transport, not through this contract.",
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/**
|
|
1821
|
+
* Screen a whole request. **This is the entry point, and the only one.**
|
|
1822
|
+
*
|
|
1823
|
+
* This doc comment is the source of truth for the sequence; the module header
|
|
1824
|
+
* and the authoring page both point here rather than restating it. A lexicon's
|
|
1825
|
+
* `predictBehaviour` opens with exactly this and nothing else:
|
|
1826
|
+
*
|
|
1827
|
+
* ```ts
|
|
1828
|
+
* const refusal = screenBehaviourRequest("acme", options);
|
|
1829
|
+
* if (refusal) return refusal;
|
|
1830
|
+
* ```
|
|
1831
|
+
*
|
|
1832
|
+
* Calling {@link assertNoCredentialInOptions} instead applies one rule of three
|
|
1833
|
+
* and drops the rest, which is how a `ghp_…` token nested past the walk's depth
|
|
1834
|
+
* budget, and an `awsSecretAccessKey` in `props`, both sailed through into a
|
|
1835
|
+
* request that was then sent.
|
|
1836
|
+
*
|
|
1837
|
+
* Two outcomes, because two things are being caught and they deserve different
|
|
1838
|
+
* blast radii:
|
|
1839
|
+
*
|
|
1840
|
+
* - a value that IS a credential throws, via
|
|
1841
|
+
* {@link assertNoCredentialInOptions};
|
|
1842
|
+
* - a merely suspicious field name, or a structure too deep to have been read,
|
|
1843
|
+
* returns a {@link BehaviourRefusalReport} with cause
|
|
1844
|
+
* `credential-in-request`. No overlay is drawn and the reason names the
|
|
1845
|
+
* path, which is the contract's own answer to "no faked numbers" applied to
|
|
1846
|
+
* its own guard.
|
|
1847
|
+
*
|
|
1848
|
+
* The split exists because the name arm is a heuristic and the old version
|
|
1849
|
+
* threw on it. One `tags: { author: "…" }` anywhere in an estate killed the
|
|
1850
|
+
* entire overlay with a stack trace, which is the exact failure mode constraint
|
|
1851
|
+
* 2 was written against.
|
|
1852
|
+
*
|
|
1853
|
+
* Returns `undefined` when the request is clean, so a lexicon reads
|
|
1854
|
+
* `const refusal = screenBehaviourRequest(name, options); if (refusal) return refusal;`
|
|
1855
|
+
*/
|
|
1856
|
+
export function screenBehaviourRequest(
|
|
1857
|
+
lexicon: string,
|
|
1858
|
+
options: object,
|
|
1859
|
+
): BehaviourRefusalReport | undefined {
|
|
1860
|
+
assertNoCredentialInOptions(options);
|
|
1861
|
+
const findings = findCredentialsInOptions(options);
|
|
1862
|
+
if (findings.length === 0) return undefined;
|
|
1863
|
+
const [first] = findings;
|
|
1864
|
+
const more = findings.length > 1 ? ` (and ${findings.length - 1} more)` : "";
|
|
1865
|
+
return behaviourRefusal({
|
|
1866
|
+
cause: "credential-in-request",
|
|
1867
|
+
reason:
|
|
1868
|
+
`The ${lexicon} behaviour request carries ${first.what} at ${first.path}${more}, so it was not ` +
|
|
1869
|
+
"sent. The engine is a third party and this contract hands it the resource graph and a traffic " +
|
|
1870
|
+
"level only. No overlay is drawn and no figure is guessed locally.",
|
|
1871
|
+
remedy:
|
|
1872
|
+
"Remove the value from the declaration, or hold it in a secret reference the build does not " +
|
|
1873
|
+
"expand. If the field is a false positive, it still leaves the process in this request.",
|
|
1874
|
+
});
|
|
1875
|
+
}
|