@intentius/chant 0.65.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/behaviour-delta.d.ts +181 -0
  2. package/dist/behaviour-delta.d.ts.map +1 -0
  3. package/dist/behaviour-http.d.ts +106 -0
  4. package/dist/behaviour-http.d.ts.map +1 -0
  5. package/dist/behaviour-overlay.d.ts +61 -0
  6. package/dist/behaviour-overlay.d.ts.map +1 -0
  7. package/dist/behaviour.d.ts +178 -3
  8. package/dist/behaviour.d.ts.map +1 -1
  9. package/dist/cli/handlers/scenario.d.ts.map +1 -1
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/lifecycle/scenario-eval.d.ts +23 -5
  13. package/dist/lifecycle/scenario-eval.d.ts.map +1 -1
  14. package/dist/lifecycle/scenario.d.ts +45 -3
  15. package/dist/lifecycle/scenario.d.ts.map +1 -1
  16. package/dist/lifecycle/types.d.ts +17 -0
  17. package/dist/lifecycle/types.d.ts.map +1 -1
  18. package/dist/op/activities/activity-contracts.d.ts +42 -0
  19. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  20. package/dist/op/activities/index.d.ts +2 -0
  21. package/dist/op/activities/index.d.ts.map +1 -1
  22. package/dist/op/activities/predict-behaviour.d.ts +207 -0
  23. package/dist/op/activities/predict-behaviour.d.ts.map +1 -0
  24. package/dist/op/activities/reconcile.d.ts +7 -0
  25. package/dist/op/activities/reconcile.d.ts.map +1 -1
  26. package/dist/op/composites/behaviour-op.d.ts +57 -0
  27. package/dist/op/composites/behaviour-op.d.ts.map +1 -0
  28. package/dist/op/composites/index.d.ts +2 -0
  29. package/dist/op/composites/index.d.ts.map +1 -1
  30. package/dist/op/index.d.ts +2 -2
  31. package/dist/op/index.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/behaviour-delta.test.ts +331 -0
  34. package/src/behaviour-delta.ts +564 -0
  35. package/src/behaviour-http.test.ts +456 -0
  36. package/src/behaviour-http.ts +252 -0
  37. package/src/behaviour-overlay.test.ts +149 -0
  38. package/src/behaviour-overlay.ts +76 -0
  39. package/src/behaviour.test.ts +50 -0
  40. package/src/behaviour.ts +255 -3
  41. package/src/cli/handlers/scenario.test.ts +108 -0
  42. package/src/cli/handlers/scenario.ts +63 -16
  43. package/src/index.ts +2 -0
  44. package/src/lifecycle/scenario-cost.test.ts +183 -0
  45. package/src/lifecycle/scenario-eval.ts +133 -6
  46. package/src/lifecycle/scenario.ts +72 -4
  47. package/src/lifecycle/types.ts +17 -0
  48. package/src/lint/rules/op/ops012-activity-contract.test.ts +31 -0
  49. package/src/op/activities/activity-contracts.ts +49 -0
  50. package/src/op/activities/index.ts +24 -0
  51. package/src/op/activities/predict-behaviour.test.ts +255 -0
  52. package/src/op/activities/predict-behaviour.ts +468 -0
  53. package/src/op/activities/reconcile.ts +7 -2
  54. package/src/op/activity-contract-registry.test.ts +3 -0
  55. package/src/op/composites/behaviour-op.test.ts +56 -0
  56. package/src/op/composites/behaviour-op.ts +99 -0
  57. package/src/op/composites/index.ts +2 -0
  58. package/src/op/index.ts +2 -0
package/src/behaviour.ts CHANGED
@@ -142,6 +142,54 @@
142
142
  * behold reads those keys and does arithmetic on the engine's figures; it never
143
143
  * produces one of its own. Fields beyond what it reads (`cause` and `source` on
144
144
  * a refusal, `edgeCoverage` on the meta) are additive and ignorable.
145
+ *
146
+ * ## The transport is part of the contract (#2373, decided in #2359)
147
+ *
148
+ * The first version of this module named the address chain, said the address
149
+ * may be a URL, a socket path or a command on `PATH`, and stopped. augur
150
+ * (#2357) then declared a private `BehaviourEngine` and its own mapping from
151
+ * what the wire said to which of the three engine-answered refusals to build.
152
+ * With a second implementation to generalise from, the seam is here:
153
+ * {@link BehaviourTransport} is one method, `send(body)`, taking the request
154
+ * as the lexicon rendered it and returning either the engine's answer as text
155
+ * or a {@link BehaviourRefusalReport} ready to return.
156
+ *
157
+ * Two things about that shape are decisions rather than defaults.
158
+ *
159
+ * - **The transport returns text, not a report.** #2373 proposed
160
+ * `send(request) → Promise<BehaviourResult>`. A report cannot be built
161
+ * without the request's `entityNames`, `traffic` and `edgeCoverage`, and
162
+ * what an answer's fields *mean* is the lexicon's wire version, not the
163
+ * contract's. A transport that built reports would have to know both, and
164
+ * every lexicon would need its own. One that carries bytes is one HTTP
165
+ * client and one subprocess spawner for every lexicon there will ever be.
166
+ * - **The failure arm is a finished refusal, not a `{cause, detail}` pair.**
167
+ * The transport is the one party that saw the wire condition and holds the
168
+ * lexicon name and the endpoint, so it is the one party that can name the
169
+ * condition, the address and the variable together. Handing back a cause
170
+ * for the lexicon to translate would put the same three-way switch in
171
+ * every lexicon, and one of them would fold `engine-over-quota` into
172
+ * `engine-unreachable` on a bad afternoon. {@link behaviourWireRefusal}
173
+ * is that switch, written once.
174
+ *
175
+ * What the wire says maps to the causes above as follows, and
176
+ * `./behaviour-http.ts` (the first adapter) and augur's command transport both
177
+ * hold to it: an HTTP 402 is `engine-out-of-credit`; a 429 is
178
+ * `engine-over-quota`; a connection refused, a timeout, a 5xx, a redirect, or
179
+ * an answer that is not JSON is `engine-unreachable`; an unset address is
180
+ * `no-engine` before any transport is built; and an unset or rejected bearer
181
+ * token is `no-engine` too, because its remedy is the same kind — set a
182
+ * variable — and the reason says which. A transport that spawns a child hands
183
+ * it {@link behaviourEngineChildEnvironment} and nothing more, for the reason
184
+ * given on that function.
185
+ *
186
+ * A bearer token on the wire is the transport's, and is not the credential
187
+ * rule 3 is about. "The engine is never handed a credential" is a statement
188
+ * about the *request body* — the graph — which {@link screenBehaviourRequest}
189
+ * still walks first. {@link behaviourTokenFrom} resolves the token an engine
190
+ * authenticates chant with, on a chain parallel to the address chain, and the
191
+ * token goes in a header the engine reads and nowhere else: never in the body,
192
+ * never in a refusal, never in a `detail`.
145
193
  */
146
194
 
147
195
  import type { UnobservedReason } from "./observation";
@@ -339,8 +387,10 @@ export interface PredictedBehaviour {
339
387
  * `no-credentials` is excluded on purpose: the engine is never handed one, so
340
388
  * it can never be missing one. Two reasons are added for the predictor itself:
341
389
  *
342
- * - `no-engine` — no variable in the chain named an engine. Nothing is
343
- * configured; this is a setup state, not a failure.
390
+ * - `no-engine` — no variable in the chain named an engine, or — for a
391
+ * transport that authenticates — no variable in the token chain named a
392
+ * token the engine accepts. Nothing usable is configured; this is a setup
393
+ * state, not a failure, and the reason says which variable.
344
394
  * - `engine-unreachable` — a variable named an engine and it did not answer.
345
395
  * - `engine-out-of-credit` — the engine answered, and refused because the
346
396
  * account behind it has no balance left (#2359).
@@ -483,7 +533,11 @@ export interface BehaviourRefusal {
483
533
  reason: string;
484
534
  /** How to fix it. Names the variable and how to set it. */
485
535
  remedy: string;
486
- /** Which variable in the chain answered, when one did. Absent for `no-engine`. */
536
+ /**
537
+ * Which variable answered, when one did: the address variable for a refusal
538
+ * about the engine, the token variable for a refusal about a rejected
539
+ * token. Absent when no variable answered at all.
540
+ */
487
541
  source?: string;
488
542
  }
489
543
 
@@ -976,6 +1030,119 @@ export function noBehaviourEngineMessage(lexicon: string): string {
976
1030
  );
977
1031
  }
978
1032
 
1033
+ /**
1034
+ * The bearer token a transport authenticates chant to an engine with, and the
1035
+ * variable that named it. The counterpart of {@link BehaviourEngineEndpoint}
1036
+ * for the second thing a metered engine needs to know: whose account this is.
1037
+ *
1038
+ * This is **not** the credential rule 3 forbids. That rule is about the
1039
+ * request body, and {@link screenBehaviourRequest} enforces it on every
1040
+ * request before any transport is reached. The token here never enters the
1041
+ * body; it goes in a header the engine reads, and the transport that sends it
1042
+ * is the only code that ever holds it.
1043
+ */
1044
+ export interface BehaviourEngineToken {
1045
+ value: string;
1046
+ /** The variable it came from, so a refusal or a log line can name it — never the value. */
1047
+ source: string;
1048
+ }
1049
+
1050
+ /**
1051
+ * The variables that can hold a behaviour engine's token, most specific first.
1052
+ * Parallel to {@link behaviourEngineVariables} and deliberately not the same
1053
+ * chain: `CHANT_BEHAVIOUR_ENGINE` is an address, and an address is printed in
1054
+ * refusals, while a token is never printed anywhere. Reusing one chain for
1055
+ * both would put the token in every message that names the address.
1056
+ */
1057
+ export function behaviourTokenVariables(lexicon: string): string[] {
1058
+ const scope = lexicon.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
1059
+ return [`CHANT_BEHAVIOUR_TOKEN_${scope}`, "CHANT_BEHAVIOUR_TOKEN", "BEHAVIOUR_TOKEN"];
1060
+ }
1061
+
1062
+ /**
1063
+ * Resolve the token one lexicon's transport authenticates with, most specific
1064
+ * first. Pure — exported for testing. The same shape as `gitlabNoteTokenFrom`
1065
+ * (`./op/activities/reconcile.ts`): a chain, the first non-empty value wins,
1066
+ * and the result names the variable so a refusal can say which one it read.
1067
+ *
1068
+ * Returns `undefined` when nothing in the chain answered. A transport whose
1069
+ * engine bills an account turns that into {@link noBehaviourTokenRefusal}
1070
+ * before sending anything: a request sent without the token is a request the
1071
+ * engine will reject, and the refusal should name the variable rather than
1072
+ * quote the engine's 401.
1073
+ */
1074
+ export function behaviourTokenFrom(
1075
+ lexicon: string,
1076
+ env: Record<string, string | undefined>,
1077
+ ): BehaviourEngineToken | undefined {
1078
+ for (const source of behaviourTokenVariables(lexicon)) {
1079
+ const value = env[source]?.trim();
1080
+ if (value) return { value, source };
1081
+ }
1082
+ return undefined;
1083
+ }
1084
+
1085
+ /** What a transport says when the engine bills an account and no variable named a token. */
1086
+ export function noBehaviourTokenMessage(lexicon: string, endpoint: BehaviourEngineEndpoint): string {
1087
+ const [scoped, chantWide, bare] = behaviourTokenVariables(lexicon);
1088
+ return (
1089
+ `predictBehaviour has the ${lexicon} behaviour engine at ${redactEngineAddress(endpoint.value)}, ` +
1090
+ `named by ${endpoint.source}, and no token to authenticate to it with, so nothing was sent. Set a ` +
1091
+ `${chantWide} environment variable to the bearer token the engine issued — or ${bare} where nothing ` +
1092
+ `else in the environment is chant's. ${scoped} is read first, for an estate whose lexicons are ` +
1093
+ "priced by different engines on different accounts. The token goes in a header the engine reads " +
1094
+ "and nowhere else; it is never in the request body and never in a message."
1095
+ );
1096
+ }
1097
+
1098
+ /**
1099
+ * The refusal for a metered engine with no token configured. `no-engine`,
1100
+ * because that is the cause whose remedy is "set a variable" — the engine is
1101
+ * named, reachable for all anyone knows, and unusable until one more variable
1102
+ * is set. No `source`: the token chain is the chain this refusal is about, and
1103
+ * nothing in it answered.
1104
+ */
1105
+ export function noBehaviourTokenRefusal(
1106
+ lexicon: string,
1107
+ endpoint: BehaviourEngineEndpoint,
1108
+ ): BehaviourRefusalReport {
1109
+ const [, chantWide] = behaviourTokenVariables(lexicon);
1110
+ return behaviourRefusal({
1111
+ cause: "no-engine",
1112
+ reason: noBehaviourTokenMessage(lexicon, endpoint),
1113
+ remedy: `Set ${chantWide} to the bearer token the engine at ${redactEngineAddress(endpoint.value)} issued.`,
1114
+ });
1115
+ }
1116
+
1117
+ /**
1118
+ * The refusal for an engine that answered and rejected the token it was sent.
1119
+ * Also `no-engine`: the address is fine, the account is not the problem, and
1120
+ * the one action is to set the named variable to a token the engine accepts.
1121
+ * `source` is the token variable, not the address variable, because that is
1122
+ * the one a consumer would tell somebody to change.
1123
+ *
1124
+ * `detail` is whatever the engine said, and it goes through
1125
+ * {@link scrubEngineDetail}. The token's own value is never in a message: a
1126
+ * transport passes the variable's name here and keeps the value to itself.
1127
+ */
1128
+ export function rejectedBehaviourTokenRefusal(
1129
+ lexicon: string,
1130
+ endpoint: BehaviourEngineEndpoint,
1131
+ token: BehaviourEngineToken,
1132
+ detail: string,
1133
+ ): BehaviourRefusalReport {
1134
+ return behaviourRefusal({
1135
+ cause: "no-engine",
1136
+ reason:
1137
+ `The ${lexicon} behaviour engine at ${redactEngineAddress(endpoint.value)}, named by ` +
1138
+ `${endpoint.source}, answered and rejected the token ${token.source} holds ` +
1139
+ `(${scrubEngineDetail(detail)}). The address is reachable and the account is not the problem; ` +
1140
+ "the token is not one this engine accepts. No overlay is drawn and no figure is guessed locally.",
1141
+ remedy: `Set ${token.source} to a bearer token the engine at ${redactEngineAddress(endpoint.value)} accepts.`,
1142
+ source: token.source,
1143
+ });
1144
+ }
1145
+
979
1146
  /** What a lexicon says when a variable named an engine and the engine did not answer. */
980
1147
  export function unreachableBehaviourEngineMessage(
981
1148
  lexicon: string,
@@ -1080,6 +1247,91 @@ export function overQuotaBehaviourEngineRefusal(
1080
1247
  });
1081
1248
  }
1082
1249
 
1250
+ /* -------------------------------------------------------------------------- */
1251
+ /* The transport */
1252
+ /* -------------------------------------------------------------------------- */
1253
+
1254
+ /**
1255
+ * What a transport brings back: the engine's answer as the text it wrote, or a
1256
+ * refusal ready to be returned from `predictBehaviour` as it stands.
1257
+ *
1258
+ * The answer is text rather than a parsed object because what the text means
1259
+ * is the lexicon's wire version (`augur/v1`), and the lexicon is the one that
1260
+ * validates it — an answer that fails that validation is
1261
+ * {@link unreachableBehaviourEngineRefusal} with a detail naming the field,
1262
+ * built by the lexicon, since the transport has nothing to say about it.
1263
+ */
1264
+ export type BehaviourTransportOutcome =
1265
+ | { ok: true; body: string }
1266
+ | { ok: false; refusal: BehaviourRefusalReport };
1267
+
1268
+ /**
1269
+ * The seam between a lexicon and whatever answers its request (#2373).
1270
+ *
1271
+ * One method. `body` is the request as the lexicon rendered it — augur's
1272
+ * canonical JSON, say — and the transport carries it to the address it was
1273
+ * built for and brings back what came out, or a refusal naming why nothing
1274
+ * did. A transport is built knowing the lexicon and the endpoint, which is
1275
+ * what lets it build the refusal itself; see the module doc for why that is
1276
+ * better than returning a cause.
1277
+ *
1278
+ * Two ship today: `./behaviour-http.ts` dials a URL with a bearer token, and
1279
+ * augur's `commandTransport` spawns a command on `PATH` with
1280
+ * {@link behaviourEngineChildEnvironment}. A lexicon picks one by the shape of
1281
+ * the address and does not otherwise know which it got.
1282
+ */
1283
+ export interface BehaviourTransport {
1284
+ send(body: string): Promise<BehaviourTransportOutcome>;
1285
+ }
1286
+
1287
+ /**
1288
+ * The three things a wire can say that are the engine's to answer for, and
1289
+ * that each want a different refusal. `no-engine` is not here: it is decided
1290
+ * before a transport exists (an unset address) or by the transport's own
1291
+ * constructor (an unset token), never by the wire.
1292
+ */
1293
+ export type BehaviourWireCause = "engine-unreachable" | "engine-out-of-credit" | "engine-over-quota";
1294
+
1295
+ /**
1296
+ * One wire cause onto the refusal builder that names it. The whole of the
1297
+ * mapping every transport applies, so it is written once: a transport that
1298
+ * classified the wire correctly and then reached for the wrong builder would
1299
+ * send an operator to check a network that is answering.
1300
+ */
1301
+ export function behaviourWireRefusal(
1302
+ lexicon: string,
1303
+ endpoint: BehaviourEngineEndpoint,
1304
+ cause: BehaviourWireCause,
1305
+ detail: string,
1306
+ ): BehaviourRefusalReport {
1307
+ switch (cause) {
1308
+ case "engine-out-of-credit":
1309
+ return outOfCreditBehaviourEngineRefusal(lexicon, endpoint, detail);
1310
+ case "engine-over-quota":
1311
+ return overQuotaBehaviourEngineRefusal(lexicon, endpoint, detail);
1312
+ case "engine-unreachable":
1313
+ return unreachableBehaviourEngineRefusal(lexicon, endpoint, detail);
1314
+ }
1315
+ }
1316
+
1317
+ /**
1318
+ * The environment a transport hands a child process: `PATH`, and nothing else.
1319
+ *
1320
+ * Rule 3 says the engine never sees a credential, and
1321
+ * {@link screenBehaviourRequest} enforces it on the request. A child that
1322
+ * inherits `process.env` walks straight around that: the request is spotless
1323
+ * and the child holds `AWS_SECRET_ACCESS_KEY` anyway. So a transport that
1324
+ * spawns builds the environment from this and nothing more — `PATH` because
1325
+ * the address is resolved against it, and no allowlist beyond that, because
1326
+ * every name added is a name a credential could be sitting under. augur's
1327
+ * command transport pins this by test (#2372); this is the rule it pins.
1328
+ */
1329
+ export function behaviourEngineChildEnvironment(
1330
+ env: Record<string, string | undefined> = process.env,
1331
+ ): Record<string, string> {
1332
+ return { PATH: env.PATH ?? "" };
1333
+ }
1334
+
1083
1335
  /* -------------------------------------------------------------------------- */
1084
1336
  /* Rendering */
1085
1337
  /* -------------------------------------------------------------------------- */
@@ -8,6 +8,8 @@ import { Scenario, snapshot } from "../../lifecycle/scenario";
8
8
  import { EffectReceipt, receiptExpectation, type EffectReceiptDeclaration } from "../../effect-receipt";
9
9
  import type { ResourceMetadata } from "../../lexicon";
10
10
  import type { LifecycleSnapshot } from "../../lifecycle/types";
11
+ import { behaviourReport, noBehaviourEngineRefusal, predictedRate } from "../../behaviour";
12
+ import type { PredictedBehaviour } from "../../behaviour";
11
13
 
12
14
  const buildMock = vi.fn();
13
15
  const fetchLifecycleMock = vi.fn();
@@ -359,6 +361,112 @@ describe("runScenarioCheck", () => {
359
361
  expect(out).toContain("seeded");
360
362
  });
361
363
 
364
+ // ── The cost clause end to end (#2358) ────────────────────────────────
365
+ //
366
+ // `../../lifecycle/scenario-cost.test.ts` drives the evaluator directly.
367
+ // These four go through the handler instead, because the half the evaluator
368
+ // never sees is the half that reads the block off a fixture on disk: a
369
+ // recorded prediction is a `behaviour` key on the `LifecycleSnapshot`, and
370
+ // the clause is only offline and credential-free if `chant scenario check`
371
+ // can bound a change from that file alone.
372
+
373
+ const TRAFFIC = "1000 rps, p99";
374
+
375
+ /** One entity's figure at {@link TRAFFIC}, at `perHour` USD. */
376
+ function figure(perHour: number): PredictedBehaviour {
377
+ return {
378
+ at: { traffic: TRAFFIC },
379
+ cost: predictedRate(perHour, "USD"),
380
+ headroom: { cpu: 0.4 },
381
+ errorRate: 0.002,
382
+ resilience: { failure: "one zone lost", verdict: "survives" },
383
+ provenance: { engine: "acme-sim", version: "1.4.2", tolerance: "±15%", basis: "modeled" },
384
+ };
385
+ }
386
+
387
+ /** A snapshot carrying a recorded prediction over one entity, as `chant lifecycle snapshot` would write it. */
388
+ function pricedSnap(name: string, perHour: number): LifecycleSnapshot {
389
+ return snap({
390
+ resources: { [name]: meta() },
391
+ behaviour: behaviourReport(
392
+ { entityNames: [name], traffic: TRAFFIC, edgeCoverage: { verdict: "unknown" } },
393
+ { engine: "acme-sim", version: "1.4.2" },
394
+ { [name]: figure(perHour) },
395
+ ),
396
+ });
397
+ }
398
+
399
+ test("a cost bound turns red when the fixture's prediction exceeds it, naming both rates and the level", async () => {
400
+ const fixturePath = await writeFixture(pricedSnap("bucket", 0.9));
401
+ const scenario = Scenario("stays under a dollar an hour", {
402
+ given: snapshot(fixturePath),
403
+ expect: { noop: true, cost: { maxPerHour: 0.5, currency: "USD" } },
404
+ });
405
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }, { budget: scenario }));
406
+
407
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
408
+
409
+ expect(exit).toBe(1);
410
+ const out = combined();
411
+ expect(out).toContain("FAIL");
412
+ expect(out).toContain("cost:");
413
+ expect(out).toContain("exceeded");
414
+ expect(out).toContain("0.9 USD/hour");
415
+ expect(out).toContain("0.5 USD/hour");
416
+ expect(out).toContain(TRAFFIC);
417
+ // The other clause is unaffected: the plan itself is still neutral.
418
+ expect(out).not.toContain("noop:");
419
+ });
420
+
421
+ test("the same bound passes under the rate, and the pass says which figure it read", async () => {
422
+ const fixturePath = await writeFixture(pricedSnap("bucket", 0.2));
423
+ const scenario = Scenario("stays under a dollar an hour", {
424
+ given: snapshot(fixturePath),
425
+ expect: { cost: { maxPerHour: 0.5, currency: "USD" } },
426
+ });
427
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }, { budget: scenario }));
428
+
429
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
430
+
431
+ expect(exit).toBe(0);
432
+ expect(combined()).toContain("PASS");
433
+ });
434
+
435
+ test("a fixture with no behaviour block fails the bound by name, never passes on nothing", async () => {
436
+ const fixturePath = await writeFixture(snap({ resources: { bucket: meta() } }));
437
+ const scenario = Scenario("stays under a dollar an hour", {
438
+ given: snapshot(fixturePath),
439
+ expect: { cost: { maxPerHour: 0.5, currency: "USD" } },
440
+ });
441
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }, { budget: scenario }));
442
+
443
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
444
+
445
+ expect(exit).toBe(1);
446
+ const out = combined();
447
+ expect(out).toContain("FAIL");
448
+ expect(out).toContain("carries no `behaviour` block");
449
+ });
450
+
451
+ test("a fixture whose recorded prediction is a refusal fails with the refusal's own cause", async () => {
452
+ const fixturePath = await writeFixture(
453
+ snap({ resources: { bucket: meta() }, behaviour: noBehaviourEngineRefusal("augur") }),
454
+ );
455
+ const scenario = Scenario("stays under a dollar an hour", {
456
+ given: snapshot(fixturePath),
457
+ expect: { cost: { maxPerHour: 0.5, currency: "USD" } },
458
+ });
459
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }, { budget: scenario }));
460
+
461
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
462
+
463
+ expect(exit).toBe(1);
464
+ const out = combined();
465
+ expect(out).toContain("FAIL");
466
+ expect(out).toContain("no-engine");
467
+ expect(out).toContain("CHANT_BEHAVIOUR_ENGINE");
468
+ });
469
+
362
470
  test("a receipt whose live value matches its expectation is a genuine noop pass", async () => {
363
471
  // The positive control for the two reproductions above: a receipt that
364
472
  // HAS fired, with the right value, is still a clean noop — the pipeline
@@ -12,7 +12,8 @@ import {
12
12
  type ReceiptReading,
13
13
  } from "../../lifecycle/receipt-plan";
14
14
  import { collectEffectReceipts, isEffectReceipt, type EffectReceiptDeclaration } from "../../effect-receipt";
15
- import { evaluateScenario, type ScenarioVerdict } from "../../lifecycle/scenario-eval";
15
+ import { evaluateScenario, type ScenarioBehaviourFixture, type ScenarioVerdict } from "../../lifecycle/scenario-eval";
16
+ import { validateBehaviourResult } from "../../behaviour-delta";
16
17
  import { collectScenarios, type ScenarioDeclaration, type ScenarioGiven } from "../../lifecycle/scenario";
17
18
  import { isResourceDeclarable } from "../../declarable";
18
19
  import { loadChantConfig } from "../../config";
@@ -153,6 +154,46 @@ interface GivenResolution {
153
154
  perLexicon: Map<string, LifecycleSnapshot>;
154
155
  /** Set when the fixture itself could not be resolved — the scenario fails on this alone. */
155
156
  error?: string;
157
+ /**
158
+ * The fixture's recorded prediction, for a `cost` clause (#2358): the one
159
+ * `behaviour` block the fixture carries, held to the contract on the way
160
+ * in, or the reason there is none. Always set once the fixture resolved,
161
+ * so a `cost` clause against a fixture with no block fails by name rather
162
+ * than on `undefined`.
163
+ */
164
+ behaviour: ScenarioBehaviourFixture;
165
+ }
166
+
167
+ /**
168
+ * Read the `behaviour` block off the fixture's snapshots. One block, not one
169
+ * per lexicon: a prediction is over the whole estate (augur reads every
170
+ * lexicon's entities and holds none of its own), so it belongs to no single
171
+ * lexicon's file, and two files carrying two different blocks is a fixture
172
+ * that answers twice. Validated on arrival — a hand-edited fixture with a
173
+ * negative rate or a bare `{ behaviour: "v1" }` is refused as not a
174
+ * prediction, never read as one.
175
+ */
176
+ function behaviourFrom(snapshots: Iterable<LifecycleSnapshot>, where: string): ScenarioBehaviourFixture {
177
+ const found: unknown[] = [];
178
+ for (const snap of snapshots) {
179
+ if (snap.behaviour !== undefined) found.push(snap.behaviour);
180
+ }
181
+ if (found.length === 0) return { missing: `given ${where} carries no \`behaviour\` block` };
182
+ const distinct = new Set(found.map((b) => JSON.stringify(b)));
183
+ if (distinct.size > 1) {
184
+ return {
185
+ missing: `given ${where} carries ${found.length} different \`behaviour\` blocks across its lexicon snapshots; a prediction is one answer over the whole estate`,
186
+ };
187
+ }
188
+ const block = found[0] as { entities?: Record<string, unknown>; unpredicted?: Record<string, unknown> };
189
+ const names = [...Object.keys(block?.entities ?? {}), ...Object.keys(block?.unpredicted ?? {})];
190
+ try {
191
+ return { result: validateBehaviourResult(block, names) };
192
+ } catch (err) {
193
+ return {
194
+ missing: `given ${where} carries a \`behaviour\` block that is not a valid prediction: ${(err as Error).message}`,
195
+ };
196
+ }
156
197
  }
157
198
 
158
199
  /** Read `given`'s fixture data. Offline: a file read for `snapshot(path)`, a
@@ -162,41 +203,47 @@ async function resolveGiven(given: ScenarioGiven): Promise<GivenResolution> {
162
203
  if (given.kind === "file") {
163
204
  const abs = resolve(given.path);
164
205
  let raw: string;
206
+ const unresolved = (error: string, env = ""): GivenResolution => ({
207
+ env,
208
+ perLexicon: new Map(),
209
+ error,
210
+ behaviour: { missing: error },
211
+ });
165
212
  try {
166
213
  raw = await readFile(abs, "utf8");
167
214
  } catch {
168
- return { env: "", perLexicon: new Map(), error: `fixture not found: ${given.path}` };
215
+ return unresolved(`fixture not found: ${given.path}`);
169
216
  }
170
217
  let snap: LifecycleSnapshot;
171
218
  try {
172
219
  snap = JSON.parse(raw) as LifecycleSnapshot;
173
220
  } catch {
174
- return { env: "", perLexicon: new Map(), error: `fixture is not valid JSON: ${given.path}` };
221
+ return unresolved(`fixture is not valid JSON: ${given.path}`);
175
222
  }
176
223
  if (typeof snap.lexicon !== "string" || typeof snap.environment !== "string" || typeof snap.resources !== "object") {
177
- return {
178
- env: typeof snap.environment === "string" ? snap.environment : "",
179
- perLexicon: new Map(),
180
- error: `${given.path} is not a LifecycleSnapshot — missing lexicon/environment/resources`,
181
- };
224
+ return unresolved(
225
+ `${given.path} is not a LifecycleSnapshot — missing lexicon/environment/resources`,
226
+ typeof snap.environment === "string" ? snap.environment : "",
227
+ );
182
228
  }
183
- return { env: snap.environment, perLexicon: new Map([[snap.lexicon, snap]]) };
229
+ return {
230
+ env: snap.environment,
231
+ perLexicon: new Map([[snap.lexicon, snap]]),
232
+ behaviour: behaviourFrom([snap], given.path),
233
+ };
184
234
  }
185
235
 
186
236
  const stored = await readEnvironmentSnapshots(given.env);
187
237
  if (stored.size === 0) {
188
- return {
189
- env: given.env,
190
- perLexicon: new Map(),
191
- error: `no recorded snapshot for environment "${given.env}" on chant/lifecycle — record one with \`chant lifecycle snapshot ${given.env}\``,
192
- };
238
+ const error = `no recorded snapshot for environment "${given.env}" on chant/lifecycle — record one with \`chant lifecycle snapshot ${given.env}\``;
239
+ return { env: given.env, perLexicon: new Map(), error, behaviour: { missing: error } };
193
240
  }
194
241
  const perLexicon = new Map<string, LifecycleSnapshot>();
195
242
  for (const [key, content] of stored) {
196
243
  const snap = JSON.parse(content) as LifecycleSnapshot;
197
244
  perLexicon.set(snap.lexicon ?? key, snap);
198
245
  }
199
- return { env: given.env, perLexicon };
246
+ return { env: given.env, perLexicon, behaviour: behaviourFrom(perLexicon.values(), `env "${given.env}"`) };
200
247
  }
201
248
 
202
249
  /**
@@ -317,7 +364,7 @@ async function evaluateOneScenario(
317
364
  mergeReceiptEntries(merged, receipts, receiptEntries);
318
365
  }
319
366
 
320
- return { env: resolved.env, verdict: evaluateScenario(merged, scenario.expect) };
367
+ return { env: resolved.env, verdict: evaluateScenario(merged, scenario.expect, resolved.behaviour) };
321
368
  }
322
369
 
323
370
  /** Fallback for `chant scenario <unknown subcommand>` — mirrors `runLifecycleUnknown`. */
package/src/index.ts CHANGED
@@ -63,6 +63,8 @@ export * from "./identity";
63
63
  export * from "./apply";
64
64
  export * from "./deep-observation";
65
65
  export * from "./behaviour";
66
+ export * from "./behaviour-http";
67
+ export * from "./behaviour-delta";
66
68
  export * from "./claimed-fields";
67
69
  export * from "./fold-provenance";
68
70
  export * from "./owner-chain";