@felspar/sdk 0.2.0 → 0.2.1

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.
@@ -1,119 +1,6 @@
1
1
  import {
2
2
  createHostedFelsparClient
3
- } from "./chunk-HACK7D24.js";
4
-
5
- // src/draft-action-gate-cases.ts
6
- var emailRecipientFields = ["to", "cc", "bcc", "replyTo", "reply_to"];
7
- var emailSubjectFields = ["subject"];
8
- var foldCase = (text2) => text2.replaceAll(/[A-Z]/gu, (letter) => letter.toLowerCase());
9
- var caseKeyOf = (rule) => rule.entity === void 0 ? foldCase : (id) => id.trim();
10
- var sameField = (names) => {
11
- const lower = new Set(names.map((name) => name.toLowerCase()));
12
- return (field) => lower.has(field.toLowerCase());
13
- };
14
- function fieldValues(input, names) {
15
- const isWanted = sameField(names);
16
- const values = [];
17
- const unreadable = [];
18
- for (const [field, value] of Object.entries(input)) {
19
- if (!isWanted(field)) {
20
- continue;
21
- }
22
- for (const item of [value].flat()) {
23
- if (typeof item === "string") {
24
- values.push(item.trim());
25
- } else if (item !== void 0 && item !== null) {
26
- unreadable.push(field);
27
- }
28
- }
29
- }
30
- return { values, unreadable };
31
- }
32
- var isAscii = (text2) => /^[!-~]+$/u.test(text2);
33
- var plainAddress = /^[a-z0-9._+-]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)$/u;
34
- var isUnder = (domain, entity) => domain === entity || domain.endsWith(`.${entity}`);
35
- function domainsIn(text2, entities) {
36
- return entities.filter((entity) => {
37
- const escaped = entity.replaceAll(".", String.raw`\.`);
38
- const whole = new RegExp(`(?:^|[^a-z0-9.-])${escaped}(?:$|[^a-z0-9-])`, "u");
39
- return whole.test(text2);
40
- });
41
- }
42
- function offTestList(recipients, rules) {
43
- const listed = new Set((rules.testRecipients ?? []).map((address) => address.trim().toLowerCase()));
44
- const outside = recipients.filter((address) => !listed.has(address));
45
- return outside.length > 0 ? `recipient not on the test list: ${outside.join(", ")}` : null;
46
- }
47
- function testCompany(recipients, subject, scope) {
48
- const outside = offTestList(recipients, scope.rules);
49
- if (outside !== null) {
50
- return { reason: outside };
51
- }
52
- const named = domainsIn(subject.toLowerCase(), scope.entities);
53
- return named.length === 1 ? { company: named[0] } : { reason: `the subject names ${named.length} decided companies, not one` };
54
- }
55
- function recipientCompany(recipients, entities) {
56
- const companies = /* @__PURE__ */ new Set();
57
- for (const address of recipients) {
58
- const domain = plainAddress.exec(address)?.[1] ?? "";
59
- const owners = entities.filter((entity) => isUnder(domain, entity));
60
- if (owners.length === 0) {
61
- return { reason: `${address} is not at a company Felspar decided on`, missing: true };
62
- }
63
- for (const owner of owners) {
64
- companies.add(owner);
65
- }
66
- }
67
- return companies.size === 1 ? { company: [...companies][0] } : { reason: "the recipients belong to more than one company" };
68
- }
69
- function recipientsOf(scope, input) {
70
- const { rules, rule } = scope;
71
- const recipientFields = rule.recipientFields ?? rules.recipientFields ?? emailRecipientFields;
72
- const recipients = fieldValues(input, recipientFields);
73
- if (recipients.unreadable.length > 0) {
74
- return { reason: `${recipients.unreadable.join(", ")} holds a value that is not an address` };
75
- }
76
- const malformed = recipients.values.find((address) => !isAscii(address) || !plainAddress.test(address.toLowerCase()));
77
- return malformed === void 0 ? { addresses: recipients.values.map((address) => address.toLowerCase()) } : { reason: `"${malformed}" is not one plain address` };
78
- }
79
- function companyFor(scope, input) {
80
- const { rules, rule } = scope;
81
- const { addresses, reason } = recipientsOf(scope, input);
82
- if (addresses === void 0) {
83
- return { reason };
84
- }
85
- if (addresses.length === 0) {
86
- return { reason: "the call has no recipients" };
87
- }
88
- if (rules.testRecipients === void 0) {
89
- return recipientCompany(addresses, scope.entities);
90
- }
91
- const subjectFields = rule.subjectFields ?? rules.subjectFields ?? emailSubjectFields;
92
- const subject = fieldValues(input, subjectFields).values.join("\n");
93
- return testCompany(addresses, subject, scope);
94
- }
95
- function fieldEntity(scope, field, input) {
96
- const { values, unreadable } = fieldValues(input, [field]);
97
- if (unreadable.length > 0 || values.length !== 1) {
98
- return { reason: `the call must set ${field} to exactly one ID` };
99
- }
100
- const [value = ""] = values;
101
- if (value.length === 0) {
102
- return { reason: `the call's ${field} is empty` };
103
- }
104
- if (scope.rules.testRecipients !== void 0) {
105
- const { addresses, reason } = recipientsOf(scope, input);
106
- const outside = addresses === void 0 ? reason : offTestList(addresses, scope.rules);
107
- if (outside !== null) {
108
- return { reason: outside };
109
- }
110
- }
111
- return { company: value };
112
- }
113
- function caseFor(scope, input) {
114
- const { entity } = scope.rule;
115
- return entity === void 0 ? companyFor(scope, input) : fieldEntity(scope, entity.field, input);
116
- }
3
+ } from "./chunk-XLKRU5RA.js";
117
4
 
118
5
  // src/draft-action-gate-outputs.ts
119
6
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -220,6 +107,10 @@ function felsparOutputCheckVerifier(configuration) {
220
107
  }
221
108
 
222
109
  // src/draft-action-gate-decisions.ts
110
+ var identityModes = ["provided", "fields", "content", "issued"];
111
+ var escalationTriggers = ["outcome", "unresolved", "attempt_limit"];
112
+ var isIdentityMode = (value) => identityModes.includes(value);
113
+ var isEscalationTrigger = (value) => escalationTriggers.includes(value);
223
114
  var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
224
115
  function parsedJson2(text2) {
225
116
  try {
@@ -269,20 +160,51 @@ function entityIdOf(input) {
269
160
  function freshnessOf(explanation) {
270
161
  return isRecord2(explanation) && typeof explanation.evidenceFreshUntil === "string" ? explanation.evidenceFreshUntil : null;
271
162
  }
272
- function decisionFromResult(entity, output) {
163
+ var isId = (value) => typeof value === "string" && value.length > 0 && value.length <= 200;
164
+ function caseOf(result) {
165
+ if (!Object.hasOwn(result, "case")) {
166
+ return void 0;
167
+ }
168
+ const found = result.case;
169
+ return isRecord2(found) && isId(found.id) && isIdentityMode(found.mode) ? { id: found.id, mode: found.mode } : null;
170
+ }
171
+ function escalationOf(result) {
172
+ const found = result.escalation;
173
+ if (!isRecord2(found) || !isId(found.id) || !isEscalationTrigger(found.trigger)) {
174
+ return void 0;
175
+ }
176
+ const why = isRecord2(found.why) && typeof found.why.text === "string" ? found.why.text : "";
177
+ return { id: found.id, trigger: found.trigger, why };
178
+ }
179
+ var requestOf = (request) => typeof request === "string" ? { entity: request } : request;
180
+ function decidedOf(result) {
181
+ const { receipt, outcome, executionObjectId } = result;
182
+ if (typeof outcome !== "string" || typeof executionObjectId !== "string" || !isRecord2(receipt)) {
183
+ return null;
184
+ }
185
+ const { receiptDigest } = receipt;
186
+ return typeof receiptDigest === "string" ? { outcome, executionObjectId, receipt: { ...receipt, receiptDigest } } : null;
187
+ }
188
+ function decisionFromResult(request, output) {
273
189
  const result = jobResponse(output)?.result;
274
- if (!isRecord2(result) || entity.length === 0) {
190
+ if (!isRecord2(result)) {
275
191
  return null;
276
192
  }
277
- const { receipt } = result;
278
- if (typeof result.outcome !== "string" || typeof result.executionObjectId !== "string" || !isRecord2(receipt) || typeof receipt.receiptDigest !== "string") {
193
+ const { entity, job } = requestOf(request);
194
+ const found = caseOf(result);
195
+ const key = found === void 0 ? entity : found?.id;
196
+ const decided = decidedOf(result);
197
+ if (decided === null || found === null || key === void 0 || key.length === 0) {
279
198
  return null;
280
199
  }
200
+ const escalation = escalationOf(result);
281
201
  return {
282
- entity,
283
- outcome: result.outcome,
284
- executionObjectId: result.executionObjectId,
285
- receipt: { ...receipt, receiptDigest: receipt.receiptDigest },
202
+ entity: key,
203
+ ...found !== void 0 && { case: found },
204
+ ...job !== void 0 && { job },
205
+ ...isId(result.releaseId) && { releaseId: result.releaseId },
206
+ ...escalation !== void 0 && { escalation },
207
+ ...decided,
286
208
  context: result.context ?? null,
287
209
  evidenceFreshUntil: freshnessOf(result.explanation)
288
210
  };
@@ -302,8 +224,8 @@ function createDecisionRecorder(limits = {}) {
302
224
  const requested = /* @__PURE__ */ new Map();
303
225
  const decided = /* @__PURE__ */ new Map();
304
226
  const checked = /* @__PURE__ */ new Map();
305
- function record(sessionId, entity, output) {
306
- const decision = decisionFromResult(entity, output);
227
+ function record(sessionId, request, output) {
228
+ const decision = decisionFromResult(request, output);
307
229
  if (decision === null) {
308
230
  return null;
309
231
  }
@@ -315,14 +237,14 @@ function createDecisionRecorder(limits = {}) {
315
237
  }
316
238
  return {
317
239
  record,
318
- noteRequest(callId, entity) {
319
- requested.set(callId, entity);
240
+ noteRequest(callId, request) {
241
+ requested.set(callId, request);
320
242
  trim(requested, maxPending);
321
243
  },
322
244
  noteResult(sessionId, callId, output) {
323
- const entity = requested.get(callId);
245
+ const noted = requested.get(callId);
324
246
  requested.delete(callId);
325
- return entity === void 0 ? null : record(sessionId, entity, output);
247
+ return noted === void 0 ? null : record(sessionId, noted, output);
326
248
  },
327
249
  decisionsIn: (sessionId) => decided.get(sessionId) ?? [],
328
250
  recordOutputCheck(sessionId, jobId, output) {
@@ -379,28 +301,208 @@ async function writesProblem(describe, decided) {
379
301
  const outputs = outcomeIn(description, decided.outcome)?.outputs ?? [];
380
302
  return outputs.some((row) => row.id === decided.output) ? null : `${decided.outcome} doesn't ask for ${decided.output}`;
381
303
  }
304
+ async function subjectOf(describe, releaseId, jobId) {
305
+ const { subject, identity } = await describedFor(describe, releaseId, jobId);
306
+ return subject === void 0 ? null : { type: subject.type, match: subject.match, mode: identity?.mode ?? "provided" };
307
+ }
382
308
  var fixOutputCheck = "Fix what check_output flagged, check it again, then retry.";
383
- async function quotedStep(step, describe, jobId) {
384
- if (step.releaseId === void 0) {
385
- return null;
309
+ var quotedText = (quoted) => typeof quoted?.text === "string" && quoted.text.length > 0 ? quoted.text : null;
310
+ function escalationIdOf(step, description) {
311
+ if (step.kind === "unresolved") {
312
+ const id2 = description?.escalations?.unresolved ?? null;
313
+ return id2 === null ? null : { id: id2, trigger: "unresolved" };
314
+ }
315
+ if (step.replayed !== void 0) {
316
+ return step.replayed.trigger === "outcome" ? { id: step.replayed.id, trigger: "outcome" } : null;
386
317
  }
387
- const description = await describedFor(describe, step.releaseId, jobId);
388
- const quoted = step.kind === "unresolved" ? description.whenUnresolved : outcomeIn(description, step.outcome)?.nextStep;
389
- return typeof quoted?.text === "string" && quoted.text.length > 0 ? quoted.text : null;
318
+ const id = description === null ? void 0 : outcomeIn(description, step.outcome)?.escalate;
319
+ return id === void 0 ? null : { id, trigger: "outcome" };
390
320
  }
391
- async function escalationFor(step, describe, jobId) {
392
- if (step?.kind === "output-check") {
393
- return fixOutputCheck;
321
+ function escalationOf2(step, description) {
322
+ const named = escalationIdOf(step, description);
323
+ if (named === null) {
324
+ return null;
394
325
  }
395
- if (step === void 0 || describe === void 0) {
326
+ const route = description?.escalations?.routes.find((row) => row.id === named.id);
327
+ const recorded = step.kind === "outcome" && step.recorded?.id === named.id ? step.recorded.why : null;
328
+ const why = quotedText(route?.quote) ?? (recorded === "" ? null : recorded);
329
+ return { ...named, why };
330
+ }
331
+ function nextStepOf(step, description) {
332
+ return step.kind === "unresolved" ? description.whenUnresolved : outcomeIn(description, step.outcome)?.nextStep;
333
+ }
334
+ async function describedStep(step, describe, jobId) {
335
+ if (describe === void 0 || step.releaseId === void 0) {
396
336
  return null;
397
337
  }
398
338
  try {
399
- return await quotedStep(step, describe, jobId);
339
+ return await describedFor(describe, step.releaseId, jobId);
400
340
  } catch {
401
341
  return null;
402
342
  }
403
343
  }
344
+ async function escalationFor(step, describe, jobId) {
345
+ if (step?.kind === "output-check") {
346
+ return { escalate: fixOutputCheck };
347
+ }
348
+ if (step === void 0) {
349
+ return {};
350
+ }
351
+ const description = await describedStep(step, describe, jobId);
352
+ const escalate = description === null ? null : quotedText(nextStepOf(step, description));
353
+ const escalation = escalationOf2(step, description);
354
+ return {
355
+ ...escalate !== null && { escalate },
356
+ ...escalation !== null && { escalation }
357
+ };
358
+ }
359
+
360
+ // src/draft-action-gate-cases.ts
361
+ var emailRecipientFields = ["to", "cc", "bcc", "replyTo", "reply_to"];
362
+ var emailSubjectFields = ["subject"];
363
+ var foldCase = (text2) => text2.replaceAll(/[A-Z]/gu, (letter) => letter.toLowerCase());
364
+ var sameField = (names) => {
365
+ const lower = new Set(names.map((name) => name.toLowerCase()));
366
+ return (field) => lower.has(field.toLowerCase());
367
+ };
368
+ function fieldValues(input, names) {
369
+ const isWanted = sameField(names);
370
+ const values = [];
371
+ const unreadable = [];
372
+ for (const [field, value] of Object.entries(input)) {
373
+ if (!isWanted(field)) {
374
+ continue;
375
+ }
376
+ for (const item of [value].flat()) {
377
+ if (typeof item === "string") {
378
+ values.push(item.trim());
379
+ } else if (item !== void 0 && item !== null) {
380
+ unreadable.push(field);
381
+ }
382
+ }
383
+ }
384
+ return { values, unreadable };
385
+ }
386
+ var isAscii = (text2) => /^[!-~]+$/u.test(text2);
387
+ var plainAddress = /^[a-z0-9._+-]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)$/u;
388
+ var isUnder = (domain, entity) => domain === entity || domain.endsWith(`.${entity}`);
389
+ function domainsIn(text2, entities) {
390
+ return entities.filter((entity) => {
391
+ const escaped = entity.replaceAll(".", String.raw`\.`);
392
+ const whole = new RegExp(`(?:^|[^a-z0-9.-])${escaped}(?:$|[^a-z0-9-])`, "u");
393
+ return whole.test(text2);
394
+ });
395
+ }
396
+ function offTestList(recipients, rules) {
397
+ const listed = new Set((rules.testRecipients ?? []).map((address) => address.trim().toLowerCase()));
398
+ const outside = recipients.filter((address) => !listed.has(address));
399
+ return outside.length > 0 ? `recipient not on the test list: ${outside.join(", ")}` : null;
400
+ }
401
+ function testCompany(recipients, subject, scope) {
402
+ const outside = offTestList(recipients, scope.rules);
403
+ if (outside !== null) {
404
+ return { reason: outside };
405
+ }
406
+ const named = domainsIn(subject.toLowerCase(), scope.entities);
407
+ return named.length === 1 ? { company: named[0] } : { reason: `the subject names ${named.length} decided companies, not one` };
408
+ }
409
+ function recipientCompany(recipients, entities) {
410
+ const companies = /* @__PURE__ */ new Set();
411
+ for (const address of recipients) {
412
+ const domain = plainAddress.exec(address)?.[1] ?? "";
413
+ const owners = entities.filter((entity) => isUnder(domain, entity));
414
+ if (owners.length === 0) {
415
+ return { reason: `${address} is not at a company Felspar decided on`, missing: true };
416
+ }
417
+ for (const owner of owners) {
418
+ companies.add(owner);
419
+ }
420
+ }
421
+ return companies.size === 1 ? { company: [...companies][0] } : { reason: "the recipients belong to more than one company" };
422
+ }
423
+ function recipientsOf(scope, input) {
424
+ const { rules, rule } = scope;
425
+ const recipientFields = rule.recipientFields ?? rules.recipientFields ?? emailRecipientFields;
426
+ const recipients = fieldValues(input, recipientFields);
427
+ if (recipients.unreadable.length > 0) {
428
+ return { reason: `${recipients.unreadable.join(", ")} holds a value that is not an address` };
429
+ }
430
+ const malformed = recipients.values.find((address) => !isAscii(address) || !plainAddress.test(address.toLowerCase()));
431
+ return malformed === void 0 ? { addresses: recipients.values.map((address) => address.toLowerCase()) } : { reason: `"${malformed}" is not one plain address` };
432
+ }
433
+ function companyFor(scope, input) {
434
+ const { rules, rule } = scope;
435
+ const { addresses, reason } = recipientsOf(scope, input);
436
+ if (addresses === void 0) {
437
+ return { reason };
438
+ }
439
+ if (addresses.length === 0) {
440
+ return { reason: "the call has no recipients" };
441
+ }
442
+ if (rules.testRecipients === void 0) {
443
+ return recipientCompany(addresses, scope.entities);
444
+ }
445
+ const subjectFields = rule.subjectFields ?? rules.subjectFields ?? emailSubjectFields;
446
+ const subject = fieldValues(input, subjectFields).values.join("\n");
447
+ return testCompany(addresses, subject, scope);
448
+ }
449
+ function testListProblem(scope, input) {
450
+ if (scope.rules.testRecipients === void 0) {
451
+ return null;
452
+ }
453
+ const { addresses, reason } = recipientsOf({ ...scope, entities: [] }, input);
454
+ return addresses === void 0 ? reason : offTestList(addresses, scope.rules);
455
+ }
456
+ function fieldEntity(scope, field, input) {
457
+ const { values, unreadable } = fieldValues(input, [field]);
458
+ if (unreadable.length > 0 || values.length !== 1) {
459
+ return { reason: `the call must set ${field} to exactly one ID` };
460
+ }
461
+ const [value = ""] = values;
462
+ if (value.length === 0) {
463
+ return { reason: `the call's ${field} is empty` };
464
+ }
465
+ const outside = testListProblem(scope, input);
466
+ return outside === null ? { company: value } : { reason: outside };
467
+ }
468
+ function caseFor(scope, input) {
469
+ const { entity } = scope.rule;
470
+ return entity === void 0 ? companyFor(scope, input) : fieldEntity(scope, entity.field, input);
471
+ }
472
+
473
+ // src/draft-action-gate-rules.ts
474
+ var isText = (value) => typeof value === "string" && value.trim().length > 0;
475
+ function ruleProblem(rule) {
476
+ if (rule.outcomes === void 0 === (rule.writes === void 0)) {
477
+ return "this tool's rule must give exactly one of outcomes or writes";
478
+ }
479
+ const { outcomes, writes, entity } = rule;
480
+ if (outcomes !== void 0 && !(Array.isArray(outcomes) && outcomes.every(isText))) {
481
+ return "this tool's rule has unreadable outcomes";
482
+ }
483
+ if (writes !== void 0 && !isText(writes)) {
484
+ return "this tool's rule has an unreadable writes";
485
+ }
486
+ if (entity !== void 0 && !isText(entity.field)) {
487
+ return "this tool's rule has an unreadable entity field";
488
+ }
489
+ const checked = rule.outputCheck?.output;
490
+ return writes !== void 0 && checked !== void 0 && checked !== writes ? "this tool's output check is for a different output than it writes" : null;
491
+ }
492
+ function fieldProblem(refuse, rule, input) {
493
+ const isAllowed = sameField(rule.fields);
494
+ const isRefused = sameField(refuse);
495
+ const unknown = Object.keys(input).filter((field) => !isAllowed(field));
496
+ if (unknown.length > 0) {
497
+ return `the call sets ${unknown.join(", ")}, which this tool's rule doesn't allow`;
498
+ }
499
+ const refused = Object.keys(input).filter((field) => isRefused(field));
500
+ return refused.length > 0 ? `the call sets ${refused.join(", ")}` : null;
501
+ }
502
+ function checkRuleOf(rule) {
503
+ const { outputCheck, writes } = rule;
504
+ return outputCheck !== void 0 && writes !== void 0 && outputCheck.output === void 0 ? { ...outputCheck, output: writes } : outputCheck;
505
+ }
404
506
 
405
507
  // src/draft-action-gate-domains.ts
406
508
  var publicSuffixes = [
@@ -512,38 +614,72 @@ function isCompanyDomain(entity, extraShared = []) {
512
614
  return domainShape.test(entity) && !builtIn.has(entity) && extraShared.every((domain) => domain.toLowerCase() !== entity);
513
615
  }
514
616
 
515
- // src/draft-action-gate-rules.ts
516
- var isText = (value) => typeof value === "string" && value.trim().length > 0;
517
- function ruleProblem(rule) {
518
- if (rule.outcomes === void 0 === (rule.writes === void 0)) {
519
- return "this tool's rule must give exactly one of outcomes or writes";
520
- }
521
- const { outcomes, writes, entity } = rule;
522
- if (outcomes !== void 0 && !(Array.isArray(outcomes) && outcomes.every(isText))) {
523
- return "this tool's rule has unreadable outcomes";
524
- }
525
- if (writes !== void 0 && !isText(writes)) {
526
- return "this tool's rule has an unreadable writes";
617
+ // src/draft-action-gate-subject.ts
618
+ var trimmed = (id) => id.trim();
619
+ var folded = (id) => foldCase(id.trim());
620
+ function keyingFor(subject, path) {
621
+ const isFolded = path === "recipients" || subject?.match === "case-insensitive";
622
+ const key = isFolded ? folded : trimmed;
623
+ return { key, decisionKey: (decision) => key(decision.case?.id ?? decision.entity) };
624
+ }
625
+ async function subjectFor(scope, decision) {
626
+ if (decision === void 0) {
627
+ return null;
527
628
  }
528
- if (entity !== void 0 && !isText(entity.field)) {
529
- return "this tool's rule has an unreadable entity field";
629
+ const { describe } = scope;
630
+ const { releaseId } = decision;
631
+ const described = describe === void 0 || releaseId === void 0 ? null : await subjectOf(describe, releaseId, scope.rules.job);
632
+ const mode = decision.case?.mode ?? "provided";
633
+ return described === null && mode !== "provided" ? { type: "case", match: "exact", mode } : described;
634
+ }
635
+ var isRecipientJob = (subject) => subject === null || subject.mode === "provided" && subject.match === "case-insensitive";
636
+ var distinct = (keys) => [...new Set(keys)];
637
+ async function pick(scope, found) {
638
+ const { company, keying, subject } = found;
639
+ const decision = scope.decisions.filter((row) => keying.decisionKey(row) === company).at(-1);
640
+ if (decision === void 0) {
641
+ return { reason: `no decision for ${company}`, missing: true };
642
+ }
643
+ const own = await subjectFor(scope, decision);
644
+ return JSON.stringify(own) === JSON.stringify(subject) ? { company, decision, keying } : { reason: `${company} was decided on a release that identifies cases differently` };
645
+ }
646
+ async function onlyCase(scope, subject, input) {
647
+ const keying = keyingFor(subject, "case");
648
+ const keys = distinct(scope.decisions.map((row) => keying.decisionKey(row)));
649
+ if (keys.length !== 1) {
650
+ return {
651
+ reason: keys.length === 0 ? `no decision for this job's ${subject.type}` : `this session decided ${keys.length} ${subject.type} cases and the call doesn't say which; give the tool's rule an entity field holding the case ID`,
652
+ ...keys.length === 0 && { missing: true }
653
+ };
530
654
  }
531
- const checked = rule.outputCheck?.output;
532
- return writes !== void 0 && checked !== void 0 && checked !== writes ? "this tool's output check is for a different output than it writes" : null;
655
+ const outside = testListProblem(scope, input);
656
+ const [company = ""] = keys;
657
+ return outside === null ? await pick(scope, { company, keying, subject }) : { reason: outside };
533
658
  }
534
- function fieldProblem(refuse, rule, input) {
535
- const isAllowed = sameField(rule.fields);
536
- const isRefused = sameField(refuse);
537
- const unknown = Object.keys(input).filter((field) => !isAllowed(field));
538
- if (unknown.length > 0) {
539
- return `the call sets ${unknown.join(", ")}, which this tool's rule doesn't allow`;
659
+ async function selected(scope, input) {
660
+ const { rules, rule } = scope;
661
+ const subject = await subjectFor(scope, scope.decisions.at(-1));
662
+ if (rule.entity !== void 0) {
663
+ const keying2 = keyingFor(subject, "case");
664
+ const found2 = caseFor({ rules, rule, entities: [] }, input);
665
+ return found2.company === void 0 ? { reason: found2.reason ?? "no case" } : await pick(scope, { company: keying2.key(found2.company), keying: keying2, subject });
666
+ }
667
+ const keying = keyingFor(subject, "recipients");
668
+ const keys = distinct(scope.decisions.map((row) => keying.decisionKey(row)));
669
+ if (subject !== null && !isRecipientJob(subject)) {
670
+ return await onlyCase(scope, subject, input);
671
+ }
672
+ const entities = keys.filter((key) => isCompanyDomain(key, rules.sharedDomains));
673
+ const found = caseFor({ rules, rule, entities }, input);
674
+ return found.company === void 0 ? { reason: found.reason ?? "no case", ...found.missing === true && { missing: true } } : await pick(scope, { company: found.company, keying, subject });
675
+ }
676
+ async function selectCase(scope, input) {
677
+ const decisions = scope.decisions.filter((row) => row.job === void 0 || row.job === scope.rules.job);
678
+ try {
679
+ return await selected({ ...scope, decisions }, input);
680
+ } catch (error) {
681
+ return { reason: `Felspar's job description could not be read: ${String(error)}` };
540
682
  }
541
- const refused = Object.keys(input).filter((field) => isRefused(field));
542
- return refused.length > 0 ? `the call sets ${refused.join(", ")}` : null;
543
- }
544
- function checkRuleOf(rule) {
545
- const { outputCheck, writes } = rule;
546
- return outputCheck !== void 0 && writes !== void 0 && outputCheck.output === void 0 ? { ...outputCheck, output: writes } : outputCheck;
547
683
  }
548
684
 
549
685
  // src/draft-action-gate-verifier.ts
@@ -562,20 +698,42 @@ function felsparVerifier(configuration) {
562
698
 
563
699
  // src/draft-action-gate.ts
564
700
  function denialText(verdict) {
565
- return verdict.escalate === void 0 ? verdict.reason : `${verdict.reason}. Next step: ${verdict.escalate}`;
701
+ const parts = [verdict.reason];
702
+ if (verdict.escalate !== void 0) {
703
+ parts.push(`Next step: ${verdict.escalate}`);
704
+ }
705
+ const { escalation } = verdict;
706
+ if (escalation !== void 0) {
707
+ const why = escalation.why === null ? "" : `: ${escalation.why}`;
708
+ parts.push(`Escalation ${escalation.id} (${escalation.trigger})${why}`);
709
+ }
710
+ return parts.join(". ");
566
711
  }
567
712
  function gatedRule(rules, toolName) {
568
713
  return Object.hasOwn(rules.tools, toolName) ? rules.tools[toolName] : void 0;
569
714
  }
570
715
  var isInput = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
571
- function replayProblem(scope, company, verified) {
572
- const { rules } = scope;
716
+ function caseProblem(decision, verified) {
717
+ const recorded = decision.case;
718
+ const replayed = verified.case;
719
+ if (recorded === void 0 && replayed === void 0) {
720
+ return null;
721
+ }
722
+ const isSame = recorded?.id === replayed?.id && recorded?.mode === replayed?.mode && replayed?.id === verified.entityId;
723
+ return isSame ? null : `the receipt is for case ${replayed?.id ?? "(none)"}, not ${recorded?.id ?? "(none)"}`;
724
+ }
725
+ function replayProblem(rules, found, verified) {
726
+ const { company, decision, keying } = found;
573
727
  if (verified.jobId !== rules.job) {
574
728
  return `the receipt is for job ${verified.jobId}, not ${rules.job}`;
575
729
  }
576
- if (caseKeyOf(scope.rule)(verified.entityId) !== company) {
730
+ if (keying.key(verified.entityId) !== company) {
577
731
  return `the receipt is for ${verified.entityId}, not ${company}`;
578
732
  }
733
+ const mismatch = caseProblem(decision, verified);
734
+ if (mismatch !== null) {
735
+ return mismatch;
736
+ }
579
737
  const { releases } = rules;
580
738
  const isUnpinned = releases !== void 0 && !releases.includes(verified.releaseId ?? "");
581
739
  return isUnpinned ? `the receipt is for release ${verified.releaseId ?? "(none)"}, which is not allowed` : null;
@@ -587,7 +745,8 @@ function staleness(decision, now) {
587
745
  }
588
746
  return Date.parse(until) > now ? null : `the decision's evidence went stale at ${until}`;
589
747
  }
590
- async function permitProblem(rule, verified, request) {
748
+ async function permitProblem(rule, decided, request) {
749
+ const { verified, decision } = decided;
591
750
  const { releaseId, outcome } = verified;
592
751
  let problem = null;
593
752
  if (rule.writes !== void 0) {
@@ -600,7 +759,18 @@ async function permitProblem(rule, verified, request) {
600
759
  } else if (rule.outcomes?.includes(outcome) !== true) {
601
760
  problem = `${verified.entityId} is ${outcome}, which doesn't allow this`;
602
761
  }
603
- return problem === null ? null : { allow: false, reason: problem, step: { kind: "outcome", releaseId, outcome } };
762
+ if (problem === null) {
763
+ return null;
764
+ }
765
+ const recorded = decision.escalation;
766
+ const step = {
767
+ kind: "outcome",
768
+ releaseId,
769
+ outcome,
770
+ ...verified.escalation !== void 0 && { replayed: verified.escalation },
771
+ ...recorded?.trigger === "outcome" && { recorded }
772
+ };
773
+ return { allow: false, reason: problem, step };
604
774
  }
605
775
  async function verifiedFor(rule, found, request) {
606
776
  const { company, decision } = found;
@@ -612,7 +782,7 @@ async function verifiedFor(rule, found, request) {
612
782
  if (verified === null) {
613
783
  return { allow: false, reason: `Felspar did not reproduce ${company}'s receipt` };
614
784
  }
615
- const problem = replayProblem({ rules: request.rules, rule }, company, verified);
785
+ const problem = replayProblem(request.rules, found, verified);
616
786
  if (problem !== null) {
617
787
  return { allow: false, reason: problem };
618
788
  }
@@ -621,7 +791,7 @@ async function verifiedFor(rule, found, request) {
621
791
  const reason = `${verified.entityId} is undecided, which doesn't allow this`;
622
792
  return { allow: false, reason, step: { kind: "unresolved", releaseId: verified.releaseId } };
623
793
  }
624
- const denied = await permitProblem(rule, { ...verified, outcome }, request);
794
+ const denied = await permitProblem(rule, { verified: { ...verified, outcome }, decision }, request);
625
795
  if (denied !== null) {
626
796
  return denied;
627
797
  }
@@ -658,20 +828,16 @@ async function decide(request) {
658
828
  if (problem !== null) {
659
829
  return { allow: false, reason: problem };
660
830
  }
661
- const keyOf = caseKeyOf(rule);
662
- const entities = rule.entity === void 0 ? [...new Set(decisions.map((row) => keyOf(row.entity)))].filter((entity) => isCompanyDomain(entity, rules.sharedDomains)) : [];
663
- const pinned = rules.releases?.length === 1 ? rules.releases[0] : void 0;
664
- const unresolved = { kind: "unresolved", releaseId: pinned };
665
- const { company, reason, missing } = caseFor({ rules, rule, entities }, input);
666
- if (company === void 0) {
831
+ const selected2 = await selectCase({ rules, rule, decisions, describe: request.describe }, input);
832
+ if (selected2.company === void 0) {
833
+ const pinned = rules.releases?.length === 1 ? rules.releases[0] : void 0;
667
834
  return {
668
835
  allow: false,
669
- reason: reason ?? "no case",
670
- ...missing === true && { step: unresolved }
836
+ reason: selected2.reason,
837
+ ...selected2.missing === true && { step: { kind: "unresolved", releaseId: pinned } }
671
838
  };
672
839
  }
673
- const decision = decisions.filter((row) => keyOf(row.entity) === company).at(-1);
674
- return decision === void 0 ? { allow: false, reason: `no decision for ${company}`, step: unresolved } : await verifiedFor(rule, { company, decision, input }, request);
840
+ return await verifiedFor(rule, { ...selected2, input }, request);
675
841
  }
676
842
  async function checkAction(request) {
677
843
  let decided;
@@ -684,8 +850,7 @@ async function checkAction(request) {
684
850
  return decided;
685
851
  }
686
852
  const { step, ...denied } = decided;
687
- const escalate = await escalationFor(step, request.describe, request.rules.job);
688
- return escalate === null ? denied : { ...denied, escalate };
853
+ return { ...denied, ...await escalationFor(step, request.describe, request.rules.job) };
689
854
  }
690
855
  function report(onVerdict, toolName, verdict) {
691
856
  if (onVerdict === void 0) {
@@ -723,8 +888,6 @@ function createActionGate(config) {
723
888
  }
724
889
 
725
890
  export {
726
- emailRecipientFields,
727
- emailSubjectFields,
728
891
  outputCheckFromResult,
729
892
  outputTextDigest,
730
893
  felsparOutputCheckVerifier,
@@ -733,6 +896,8 @@ export {
733
896
  decisionFromResult,
734
897
  createDecisionRecorder,
735
898
  felsparJobDescriber,
899
+ emailRecipientFields,
900
+ emailSubjectFields,
736
901
  sharedDomains,
737
902
  isCompanyDomain,
738
903
  felsparVerifier,