@enricai/barnacle 1.9.5 → 1.9.7

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.
@@ -22,19 +22,26 @@
22
22
  */
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
24
  exports.resolveStepPayloadField = resolveStepPayloadField;
25
+ exports.extractStepPersonaValue = extractStepPersonaValue;
26
+ exports.deriveFillLabelField = deriveFillLabelField;
27
+ exports.harvestPersonaBindings = harvestPersonaBindings;
25
28
  exports.inferZodSchemaFromSamples = inferZodSchemaFromSamples;
26
29
  exports.selectPayloadAction = selectPayloadAction;
27
30
  exports.selectReturnAction = selectReturnAction;
28
31
  exports.selectEffectiveResponseBody = selectEffectiveResponseBody;
32
+ exports.extractEntryUrlParams = extractEntryUrlParams;
29
33
  exports.resolveManifestActionSequence = resolveManifestActionSequence;
30
34
  exports.extractActionSequence = extractActionSequence;
31
35
  exports.extractGraphQLActionSequence = extractGraphQLActionSequence;
32
36
  exports.detectFormSchemaFieldNames = detectFormSchemaFieldNames;
37
+ exports.indexEnumEnumNamesSchemas = indexEnumEnumNamesSchemas;
38
+ exports.indexLabelValueOptionCodes = indexLabelValueOptionCodes;
39
+ exports.buildSelectOptionResolutions = buildSelectOptionResolutions;
33
40
  exports.walkSetCookiePairs = walkSetCookiePairs;
34
41
  exports.indexStateValues = indexStateValues;
35
42
  exports.compileActionSteps = compileActionSteps;
36
43
  exports.collectHeaderBindings = collectHeaderBindings;
37
- exports.loadQuestionPromptKeywords = loadQuestionPromptKeywords;
44
+ exports.deriveProducerBoundaryBindings = deriveProducerBoundaryBindings;
38
45
  exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
39
46
  exports.emitContractTs = emitContractTs;
40
47
  exports.emitConfigManifest = emitConfigManifest;
@@ -112,6 +119,122 @@ function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary =
112
119
  }
113
120
  return null;
114
121
  }
122
+ /**
123
+ * Extracts the concrete persona VALUE a flow step fills — the recon-supplied
124
+ * constant that appears verbatim in the captured request body — so the body
125
+ * emitter can bind it to `${payload.<field>}`. This is a stricter job than
126
+ * {@link buildStepInstructionExpr}'s browser-flow splice, which only needs *a*
127
+ * span to replace: here the extracted string must equal the wire value exactly,
128
+ * or the value-identity substitution silently misses.
129
+ *
130
+ * Two grammar facts, both verified against real ATS flows, drive the rule:
131
+ * 1. The possessive apostrophe in "the candidate's first name 'Reginald'"
132
+ * opens a false quote — a naive `/'[^']*'/` yields `s first name `, not
133
+ * `Reginald`. Neutralizing `\w's` → `\ws` before matching removes it.
134
+ * 2. A `Select`/`Choose` step names the ANSWER first, then the question
135
+ * ("Select 'No' for the 'sponsorship' question"), so the value is the
136
+ * FIRST quoted token; a `Fill`/`Enter`/`Type` step names the field label
137
+ * first and the value last, so it is the LAST quoted token.
138
+ * A `${RECON_EMAIL}` token (or the literal email `env` value) short-circuits to
139
+ * the env-resolved address, matching recon-browser's own env substitution.
140
+ *
141
+ * @param instruction the flow step's plain-English instruction
142
+ * @param env process env (or a stub) supplying `${VAR}` token values, e.g. RECON_EMAIL
143
+ * @returns the persona value, or null when the step carries no spliceable constant
144
+ */
145
+ function extractStepPersonaValue(instruction, env) {
146
+ const emailToken = `$${"{RECON_EMAIL}"}`;
147
+ const reconEmail = env.RECON_EMAIL;
148
+ if (reconEmail && (instruction.includes(emailToken) || instruction.includes(reconEmail))) {
149
+ return reconEmail;
150
+ }
151
+ // Resolve any other `${UPPER_SNAKE}` env token the same way recon-browser's
152
+ // substituteFlowEnvVars does, so an env-supplied value (e.g. RECON_PHONE) is
153
+ // matched against the wire body by its runtime form, not the literal token.
154
+ const envToken = /\$\{([A-Z_][A-Z0-9_]*)\}/.exec(instruction);
155
+ if (envToken) {
156
+ const resolved = env[envToken[1]];
157
+ if (resolved)
158
+ return resolved;
159
+ }
160
+ // Neutralize the possessive apostrophe so it isn't read as a quote delimiter.
161
+ const cleaned = instruction.replace(/(\w)'s\b/g, "$1s");
162
+ const quotes = [...cleaned.matchAll(/'([^']*)'/g)].map((m) => m[1]);
163
+ if (quotes.length === 0)
164
+ return null;
165
+ const value = /^\s*(select|choose|pick)\b/i.test(instruction)
166
+ ? quotes[0]
167
+ : quotes[quotes.length - 1];
168
+ return value.length > 0 ? value : null;
169
+ }
170
+ /**
171
+ * Derives a payload field name from the field LABEL in a fill/enter/type
172
+ * instruction, for steps the consumer vocabulary does not cover.
173
+ *
174
+ * WHY: `fill in the <LABEL> field with '<VALUE>'` is self-describing — the label
175
+ * names the caller coordinate regardless of domain, so a vocabulary miss on a
176
+ * legitimate identity field (a "middle name" a recruiting vocab forgot to list)
177
+ * need not freeze the recon persona's value into every submission. This reads
178
+ * only the generic instruction grammar; it hardcodes no field or site name.
179
+ *
180
+ * Scoped to Fill/Enter/Type by design. Those name the field label FIRST and a
181
+ * quoted caller VALUE last, so a label→field claim is safe. Select/Choose name
182
+ * the ANSWER first and often only a facet second ("select the departure port
183
+ * from the Country dropdown") — deriving a field from the label there re-opens
184
+ * the exact off-domain false-splice `ReconVocabulary.subject` exists to prevent,
185
+ * so Select/Choose is deliberately excluded and stays vocabulary-gated.
186
+ *
187
+ * @param instruction the flow step's plain-English instruction
188
+ * @returns the PascalCase field name, or null when the shape does not match
189
+ */
190
+ function deriveFillLabelField(instruction) {
191
+ if (!/^\s*(?:fill(?:\s+in)?|enter|type)\b/i.test(instruction))
192
+ return null;
193
+ if (!/'[^']*'/.test(instruction))
194
+ return null;
195
+ const label = /\b(?:fill(?:\s+in)?|enter|type)\s+(?:in\s+)?the\s+(.+?)\s+field\b/i.exec(instruction)?.[1];
196
+ if (label === undefined)
197
+ return null;
198
+ return fieldNameToPascalCase(label, null);
199
+ }
200
+ /**
201
+ * Builds the map from a recon persona VALUE (as it appears in the captured
202
+ * request body) to the `payload.<Field>` accessor that should replace it, by
203
+ * pairing each flow step's resolved field ({@link resolveStepPayloadField})
204
+ * with its extracted value ({@link extractStepPersonaValue}). This is the
205
+ * value→field reconciliation the browser flow and payload schema already do,
206
+ * finally applied to the request-body templates.
207
+ *
208
+ * Earliest step wins on a duplicate value. Site-agnostic: the field mapping
209
+ * lives entirely in the consumer's `--vocabulary`, with a generic
210
+ * label-derivation ({@link deriveFillLabelField}) fallback for fill steps the
211
+ * vocabulary doesn't recognize — the vocabulary always wins when it has a match.
212
+ */
213
+ function harvestPersonaBindings(flowSteps, vocabulary, env) {
214
+ const bindings = new Map();
215
+ for (const step of flowSteps) {
216
+ const isObj = typeof step !== "string";
217
+ const instruction = isObj ? step.step : step;
218
+ const vocabField = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary);
219
+ // Vocabulary wins outright. Only on a miss do we fall back to deriving the
220
+ // field from the instruction's own label — and never when the author opted
221
+ // the step out (`payloadFieldNone`) or the vocabulary explicitly excluded it.
222
+ const field = vocabField ??
223
+ (isObj && step.payloadFieldNone
224
+ ? null
225
+ : vocabulary.exclusions.some((rx) => rx.test(instruction))
226
+ ? null
227
+ : deriveFillLabelField(instruction));
228
+ if (field === null)
229
+ continue;
230
+ const value = extractStepPersonaValue(instruction, env);
231
+ if (value === null)
232
+ continue;
233
+ if (!bindings.has(value))
234
+ bindings.set(value, `payload.${field}`);
235
+ }
236
+ return bindings;
237
+ }
115
238
  /**
116
239
  * How deep to infer before collapsing to z.unknown(). Deep enough to reach the
117
240
  * fields that carry meaning on real inventory APIs — a cruise sailing's price
@@ -320,6 +443,40 @@ function deriveBaseUrl(captures) {
320
443
  }
321
444
  return "https://example.com";
322
445
  }
446
+ /**
447
+ * Extracts caller-supplied job/context coordinates from the recon ENTRY URL's
448
+ * query string, mapping each param VALUE to a `payload.<param>` accessor. The
449
+ * first capture is the landing navigation, and a submission flow's job context
450
+ * (`?jobSeqNo=...`, `?jobId=...`) rides its query string — it belongs to the
451
+ * target posting, not the recon run, so it must be caller-supplied. Values
452
+ * below {@link MIN_STATE_VALUE_LENGTH} and cache-buster keys are skipped, since
453
+ * a 1–7 char value collides with arbitrary substrings elsewhere in the body.
454
+ *
455
+ * Site-agnostic: reads only the entry URL's own query keys; no site-specific
456
+ * param knowledge. Downstream, `interpolateStateValues`' length-descending pass
457
+ * composes embedded substrings (a jobId inside a longer jobSeqNo) automatically.
458
+ */
459
+ function extractEntryUrlParams(entryUrl) {
460
+ const params = new Map();
461
+ let u;
462
+ try {
463
+ u = new URL(entryUrl);
464
+ }
465
+ catch {
466
+ return params;
467
+ }
468
+ for (const [key, value] of u.searchParams) {
469
+ if (value.length < MIN_STATE_VALUE_LENGTH)
470
+ continue;
471
+ if (CACHE_BUSTER_QUERY_KEYS.has(key))
472
+ continue;
473
+ if (!isValidJsIdentifier(key))
474
+ continue;
475
+ if (!params.has(value))
476
+ params.set(value, `payload.${key}`);
477
+ }
478
+ return params;
479
+ }
323
480
  const IGNORE_REQUEST_HEADERS = new Set([
324
481
  "host",
325
482
  "content-length",
@@ -1207,6 +1364,408 @@ function applyRawOptionIdPayloadSubstitutions(rawBody, fieldNameMap, fieldOption
1207
1364
  }
1208
1365
  return result;
1209
1366
  }
1367
+ /** i18n label placeholders (e.g. `{{apply.option.label.gender.a}}`) are not
1368
+ * human-facing answers and can never be matched against a flow step's quoted
1369
+ * label, so a schema whose labels are all templated yields no usable
1370
+ * label→code mapping. */
1371
+ function isI18nLabel(label) {
1372
+ return label.includes("{{");
1373
+ }
1374
+ /**
1375
+ * Indexes the JSON-Schema `enum`/`enumNames` PARALLEL-ARRAY convention across
1376
+ * every response body: an object carrying a string `name` plus equal-length
1377
+ * `enum` (option codes) and `enumNames` (option labels) declares one dropdown's
1378
+ * label→code mapping, disambiguated per-question by `name`. This is the PRIMARY
1379
+ * label→code source for ATS demographic/eligibility dropdowns whose submitted
1380
+ * value is an opaque code, not the label.
1381
+ *
1382
+ * The same `name` can appear in several captures with progressively fuller
1383
+ * option lists (a later page reveals the "decline to answer" choice), so the
1384
+ * entry with the MOST non-i18n labels wins rather than first-seen — the flow's
1385
+ * answer might be the choice only the fuller list carries.
1386
+ *
1387
+ * Site-agnostic: `enum`/`enumNames` are generic JSON-Schema keys; the field
1388
+ * identities are discovered from the response, never hardcoded.
1389
+ */
1390
+ function indexEnumEnumNamesSchemas(captures) {
1391
+ const out = new Map();
1392
+ const walk = (value) => {
1393
+ if (Array.isArray(value)) {
1394
+ for (const item of value)
1395
+ walk(item);
1396
+ return;
1397
+ }
1398
+ if (value === null || typeof value !== "object")
1399
+ return;
1400
+ const obj = value;
1401
+ const name = obj.name;
1402
+ const en = obj.enum;
1403
+ const nm = obj.enumNames;
1404
+ if (typeof name === "string" &&
1405
+ Array.isArray(en) &&
1406
+ Array.isArray(nm) &&
1407
+ en.length === nm.length &&
1408
+ en.length > 0 &&
1409
+ en.every((c) => typeof c === "string") &&
1410
+ nm.every((l) => typeof l === "string")) {
1411
+ const codes = en;
1412
+ const labels = nm;
1413
+ const usable = labels.filter((l) => !isI18nLabel(l)).length;
1414
+ const prior = out.get(name);
1415
+ const priorUsable = prior ? prior.labels.filter((l) => !isI18nLabel(l)).length : -1;
1416
+ if (usable > priorUsable)
1417
+ out.set(name, { codes, labels });
1418
+ }
1419
+ for (const v of Object.values(obj))
1420
+ walk(v);
1421
+ };
1422
+ for (const capture of captures)
1423
+ walk(capture.responseBody);
1424
+ return out;
1425
+ }
1426
+ /**
1427
+ * Indexes the `{label,value}`-shaped option-object convention: arrays of
1428
+ * objects each carrying both a `label` and a `value` string (state/country
1429
+ * pickers ship these). Builds a GLOBAL label→code map used as the fallback
1430
+ * source for dropdowns whose flow step carries no `id=` hint (so the
1431
+ * enum/enumNames index can't be keyed) — the answer label is looked up
1432
+ * directly. First non-i18n binding wins on a duplicate label.
1433
+ *
1434
+ * Site-agnostic: `label`/`value` are generic option-object keys; no field name
1435
+ * is assumed.
1436
+ */
1437
+ function indexLabelValueOptionCodes(captures) {
1438
+ const out = new Map();
1439
+ const walk = (value) => {
1440
+ if (Array.isArray(value)) {
1441
+ for (const item of value) {
1442
+ if (item !== null && typeof item === "object" && !Array.isArray(item)) {
1443
+ const r = item;
1444
+ const label = r.label;
1445
+ const code = r.value;
1446
+ if (typeof label === "string" &&
1447
+ typeof code === "string" &&
1448
+ label.length > 0 &&
1449
+ code.length > 0 &&
1450
+ !isI18nLabel(label) &&
1451
+ !out.has(label)) {
1452
+ out.set(label, code);
1453
+ }
1454
+ }
1455
+ walk(item);
1456
+ }
1457
+ return;
1458
+ }
1459
+ if (value === null || typeof value !== "object")
1460
+ return;
1461
+ for (const v of Object.values(value))
1462
+ walk(v);
1463
+ };
1464
+ for (const capture of captures)
1465
+ walk(capture.responseBody);
1466
+ return out;
1467
+ }
1468
+ /**
1469
+ * Reconciles each flow SELECT step to a submitted option CODE so the body-slot
1470
+ * literal (`"applyHealthCareExclusion":"5395"`) can be rewritten to a
1471
+ * caller-driven `${OPT_<Name>[payload.<Name>]}` lookup. Real ATS bodies carry
1472
+ * codes, not labels; the flow carries labels — this bridges them via the two
1473
+ * generic label→code conventions ({@link indexEnumEnumNamesSchemas} primary,
1474
+ * {@link indexLabelValueOptionCodes} fallback).
1475
+ *
1476
+ * The wire KEY is discovered from the step's `id=<field>` hint when present
1477
+ * (the enum/enumNames schema is keyed by that same field name); steps without
1478
+ * an `id=` (state/country) fall back to the vocabulary-resolved persona field
1479
+ * lowercased, matched against the `{label,value}` map by label. A dropdown
1480
+ * whose labels are all i18n placeholders (e.g. gender) yields no enum and is
1481
+ * reported separately for the raw-code channel.
1482
+ *
1483
+ * Returns structured resolutions plus the set of i18n-only wire keys (raw-code
1484
+ * fallbacks). Site-agnostic: field identities come from the flow's own `id=`
1485
+ * hints and the consumer vocabulary, never a hardcoded key.
1486
+ */
1487
+ function buildSelectOptionResolutions(flowSteps, captures, vocabulary, env) {
1488
+ const enumSchemas = indexEnumEnumNamesSchemas(captures);
1489
+ const labelValue = indexLabelValueOptionCodes(captures);
1490
+ const resolutions = [];
1491
+ const rawCodeFields = new Map();
1492
+ const seenWireKeys = new Set();
1493
+ for (const step of flowSteps) {
1494
+ const instruction = typeof step === "string" ? step : step.step;
1495
+ if (!/^\s*(select|choose|pick)\b/i.test(instruction) && !/\bselect\b/i.test(instruction)) {
1496
+ continue;
1497
+ }
1498
+ const idMatch = /id=(\w+)/.exec(instruction);
1499
+ // A Select step names the ANSWER first, then the question — so the answer is
1500
+ // the FIRST quoted token (apostrophe-aware). extractStepPersonaValue only
1501
+ // returns first-quote when the sentence STARTS with select/choose/pick; a
1502
+ // dropdown step phrased "On the X step, select 'No' in the '…?' dropdown"
1503
+ // starts with "On", so read the first quote directly here for id= steps.
1504
+ const firstQuoteLabel = (() => {
1505
+ const cleaned = instruction.replace(/(\w)'s\b/g, "$1s");
1506
+ const m = /'([^']*)'/.exec(cleaned);
1507
+ return m && m[1].length > 0 ? m[1] : null;
1508
+ })();
1509
+ const label = idMatch ? firstQuoteLabel : extractStepPersonaValue(instruction, env);
1510
+ if (label === null)
1511
+ continue;
1512
+ // Primary: an id= hint names the wire key AND the enum/enumNames schema key.
1513
+ if (idMatch) {
1514
+ const wireKey = idMatch[1];
1515
+ if (seenWireKeys.has(wireKey))
1516
+ continue;
1517
+ const schema = enumSchemas.get(wireKey);
1518
+ if (!schema)
1519
+ continue;
1520
+ const semanticName = fieldNameToPascalCase(wireKey, null);
1521
+ if (semanticName === null)
1522
+ continue;
1523
+ const idx = schema.labels.indexOf(label);
1524
+ const usableOptions = schema.labels
1525
+ .map((l, i) => ({ label: l, code: schema.codes[i] }))
1526
+ .filter((o) => !isI18nLabel(o.label));
1527
+ if (idx >= 0 && !isI18nLabel(schema.labels[idx])) {
1528
+ resolutions.push({
1529
+ wireKey,
1530
+ semanticName,
1531
+ code: schema.codes[idx],
1532
+ label,
1533
+ options: usableOptions,
1534
+ });
1535
+ seenWireKeys.add(wireKey);
1536
+ continue;
1537
+ }
1538
+ // Label is i18n-only (or the answer maps to a templated label): the field
1539
+ // still must not stay frozen — surface the recon-observed code so the raw
1540
+ // channel emits a caller-supplied default.
1541
+ if (usableOptions.length === 0 && schema.codes.length > 0) {
1542
+ const fallbackIdx = idx >= 0 ? idx : schema.codes.length - 1;
1543
+ rawCodeFields.set(semanticName, { wireKey, code: schema.codes[fallbackIdx] });
1544
+ seenWireKeys.add(wireKey);
1545
+ }
1546
+ continue;
1547
+ }
1548
+ // Fallback: no id= — resolve the wire key from the vocabulary persona field
1549
+ // (lowercased) and the code from the global {label,value} map by label.
1550
+ const field = resolveStepPayloadField(instruction, typeof step === "string" ? undefined : step.payloadField, typeof step === "string" ? undefined : step.payloadFieldNone, vocabulary);
1551
+ if (field === null)
1552
+ continue;
1553
+ const code = labelValue.get(label);
1554
+ if (code === undefined)
1555
+ continue;
1556
+ const wireKey = field.toLowerCase();
1557
+ if (seenWireKeys.has(wireKey))
1558
+ continue;
1559
+ const semanticName = fieldNameToPascalCase(wireKey, null);
1560
+ if (semanticName === null)
1561
+ continue;
1562
+ // The {label,value} map only reliably yields the one answered label→code
1563
+ // pair here; emit a single-choice enum so the field still binds (the caller
1564
+ // can widen it). Co-located labels aren't safely attributable to this field.
1565
+ resolutions.push({ wireKey, semanticName, code, label, options: [{ label, code }] });
1566
+ seenWireKeys.add(wireKey);
1567
+ }
1568
+ return { resolutions, rawCodeFields };
1569
+ }
1570
+ /**
1571
+ * Rewrites plain-JSON dropdown body slots (`"<wireKey>":"<code>"`) to a
1572
+ * caller-driven `${OPT_<Name>[payload.<Name>]}` lookup, anchored on the wire
1573
+ * KEY rather than the UUID field-id marker
1574
+ * {@link applyFormSchemaOptionIdSubstitutions} uses. This is the plain-JSON
1575
+ * ATS case where the submitted body is a flat `{ "field": "code" }` map with no
1576
+ * schema envelope, so the key/value pair — both drawn from recon input — is the
1577
+ * only closed-set anchor available.
1578
+ *
1579
+ * Records each rewritten field's semanticName into `outDiscoveredOptionFields`
1580
+ * so emitContractTs lights up its OPT_<Name> const + z.enum payload entry, and
1581
+ * mutates `fieldOptionsMap` so that emit finds the option mapping. Closed-set:
1582
+ * both the key and the code come from the recon-derived resolutions.
1583
+ */
1584
+ function applyGenericOptionCodeSubstitutions(rawBody, resolutions, fieldOptionsMap, outDiscoveredOptionFields) {
1585
+ if (resolutions.length === 0)
1586
+ return rawBody;
1587
+ let result = rawBody;
1588
+ for (const res of resolutions) {
1589
+ const slot = `"${res.wireKey}":"${res.code}"`;
1590
+ if (!result.includes(slot))
1591
+ continue;
1592
+ const replacement = `"${res.wireKey}":"$${"{"}OPT_${res.semanticName}[payload.${res.semanticName}]${"}"}"`;
1593
+ result = result.split(slot).join(replacement);
1594
+ // Mutate the option map so emitContractTs emits OPT_<Name> + the z.enum.
1595
+ if (!fieldOptionsMap.has(res.wireKey)) {
1596
+ fieldOptionsMap.set(res.wireKey, {
1597
+ semanticName: res.semanticName,
1598
+ options: res.options.map((o) => ({ value: o.label, optionId: o.code })),
1599
+ });
1600
+ }
1601
+ outDiscoveredOptionFields.add(res.semanticName);
1602
+ }
1603
+ return result;
1604
+ }
1605
+ /**
1606
+ * Rewrites the body slot of an i18n-only dropdown (one whose labels are all
1607
+ * templated placeholders, so no OPT_<Name> enum is possible) from its frozen
1608
+ * recon code to a caller-supplied `${payload.<Name>Code}`. This is the
1609
+ * plain-JSON, wire-key-anchored twin of {@link applyRawOptionIdPayloadSubstitutions}
1610
+ * (which is UUID/form-schema anchored) — without it a field like gender would
1611
+ * submit the recon persona's frozen choice for every caller.
1612
+ */
1613
+ function applyGenericRawCodeSubstitutions(rawBody, rawCodeFields) {
1614
+ if (rawCodeFields.size === 0)
1615
+ return rawBody;
1616
+ let result = rawBody;
1617
+ for (const [semanticName, { wireKey, code }] of rawCodeFields) {
1618
+ const slot = `"${wireKey}":"${code}"`;
1619
+ if (!result.includes(slot))
1620
+ continue;
1621
+ const replacement = `"${wireKey}":"$${"{"}payload.${semanticName}Code${"}"}"`;
1622
+ result = result.split(slot).join(replacement);
1623
+ }
1624
+ return result;
1625
+ }
1626
+ /** Counts an object's DIRECT primitive-valued children (string/number/boolean/
1627
+ * null). The form envelope is the object with the most of these — its scalar
1628
+ * children are the fields every other binding pass individually parameterizes,
1629
+ * so it must never be swallowed wholesale. */
1630
+ function directPrimitiveChildCount(obj) {
1631
+ let n = 0;
1632
+ for (const v of Object.values(obj)) {
1633
+ if (v === null || (typeof v !== "object" && typeof v !== "function"))
1634
+ n++;
1635
+ }
1636
+ return n;
1637
+ }
1638
+ /**
1639
+ * Locates the FORM ENVELOPE inside a submit body — the nested object that
1640
+ * actually holds the scalar form fields (which every other pass binds one by
1641
+ * one) — by descending through wrapper objects and picking the object with the
1642
+ * most direct primitive children. Returns its dotted path from the body root
1643
+ * (empty when the root itself is the envelope). Site-agnostic: no key names are
1644
+ * assumed; the envelope is found by shape.
1645
+ */
1646
+ function locateFormEnvelopePath(parsedBody) {
1647
+ const candidates = [];
1648
+ const visit = (value, path) => {
1649
+ if (value === null || typeof value !== "object" || Array.isArray(value))
1650
+ return;
1651
+ const obj = value;
1652
+ candidates.push({ path, primitives: directPrimitiveChildCount(obj) });
1653
+ for (const [k, v] of Object.entries(obj))
1654
+ visit(v, [...path, k]);
1655
+ };
1656
+ visit(parsedBody, []);
1657
+ if (candidates.length === 0)
1658
+ return [];
1659
+ const maxP = Math.max(...candidates.map((c) => c.primitives));
1660
+ // The analytics blob (`eventData`) mirrors the form, so the object with the
1661
+ // MOST primitives can be a deep descendant of the true envelope. Pick the
1662
+ // SHALLOWEST primitive-rich object (≥ half the max) instead — that is the
1663
+ // form envelope itself, whose analytics mirror sits below it. Tie-break on a
1664
+ // higher primitive count. Threshold is relative, not a magic key name.
1665
+ const rich = candidates.filter((c) => c.primitives >= Math.max(1, maxP / 2));
1666
+ // No object carries a scalar field (maxP === 0): the body root is the only
1667
+ // sensible envelope — operate at the top level.
1668
+ if (rich.length === 0)
1669
+ return [];
1670
+ rich.sort((a, b) => a.path.length - b.path.length || b.primitives - a.primitives);
1671
+ return rich[0].path;
1672
+ }
1673
+ /**
1674
+ * Parameterizes whole nested caller-supplied structures sitting BESIDE the
1675
+ * scalar form fields — the array-valued work/education history
1676
+ * (`experienceData`/`educationData`/`dqData`) and the opaque `eventData`
1677
+ * analytics object — replacing each `"key":<json>` span with
1678
+ * `"key":${JSON.stringify(payload.<key>)}` and recording the key's inferred Zod
1679
+ * schema so emitContractTs adds it to the payload contract.
1680
+ *
1681
+ * These blocks are caller data the recon merely captured a frozen sample of;
1682
+ * freezing them would submit one applicant's history for every caller. Crucially
1683
+ * the FORM ENVELOPE object itself (the one carrying firstName/state/… that the
1684
+ * persona and dropdown passes bind field-by-field) is NEVER swallowed — that
1685
+ * would collapse the whole form to one opaque `${JSON.stringify(payload.formData)}`
1686
+ * and defeat every other binding. {@link locateFormEnvelopePath} finds it by
1687
+ * shape; only its non-scalar SIBLING children are parameterized. A
1688
+ * brace/bracket-depth scanner finds the exact JSON span (the captured body is
1689
+ * well-formed).
1690
+ *
1691
+ * NOTE on `eventData`: it becomes an opaque `${JSON.stringify(payload.eventData)}`
1692
+ * passthrough. Its nested volatiles (apTxnId, per-step timestamps) therefore
1693
+ * become the CALLER's responsibility to mint fresh — acceptable because the
1694
+ * whole blob is caller-supplied; the generator can't reach inside a value it
1695
+ * has delegated wholesale.
1696
+ *
1697
+ * Site-agnostic: operates only on the recon body's own shape.
1698
+ */
1699
+ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys) {
1700
+ if (parsedBody === null || typeof parsedBody !== "object" || Array.isArray(parsedBody)) {
1701
+ return template;
1702
+ }
1703
+ // Resolve the envelope object whose non-scalar children are caller structures.
1704
+ const envelopePath = locateFormEnvelopePath(parsedBody);
1705
+ let envelope = parsedBody;
1706
+ for (const seg of envelopePath) {
1707
+ if (envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)) {
1708
+ envelope = envelope[seg];
1709
+ }
1710
+ }
1711
+ if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) {
1712
+ return template;
1713
+ }
1714
+ let result = template;
1715
+ for (const [key, value] of Object.entries(envelope)) {
1716
+ const isNonEmptyArray = Array.isArray(value) && value.length > 0;
1717
+ const isNestedObject = value !== null &&
1718
+ typeof value === "object" &&
1719
+ !Array.isArray(value) &&
1720
+ Object.keys(value).length > 0;
1721
+ if (!isNonEmptyArray && !isNestedObject)
1722
+ continue;
1723
+ const keyMarker = `"${key}":`;
1724
+ const markerIdx = result.indexOf(keyMarker);
1725
+ if (markerIdx === -1)
1726
+ continue;
1727
+ const spanStart = markerIdx + keyMarker.length;
1728
+ const open = result[spanStart];
1729
+ if (open !== "[" && open !== "{")
1730
+ continue;
1731
+ const close = open === "[" ? "]" : "}";
1732
+ let depth = 0;
1733
+ let inString = false;
1734
+ let escaped = false;
1735
+ let spanEnd = -1;
1736
+ for (let i = spanStart; i < result.length; i++) {
1737
+ const ch = result[i];
1738
+ if (inString) {
1739
+ if (escaped)
1740
+ escaped = false;
1741
+ else if (ch === "\\")
1742
+ escaped = true;
1743
+ else if (ch === '"')
1744
+ inString = false;
1745
+ continue;
1746
+ }
1747
+ if (ch === '"')
1748
+ inString = true;
1749
+ else if (ch === open)
1750
+ depth++;
1751
+ else if (ch === close) {
1752
+ depth--;
1753
+ if (depth === 0) {
1754
+ spanEnd = i + 1;
1755
+ break;
1756
+ }
1757
+ }
1758
+ }
1759
+ if (spanEnd === -1)
1760
+ continue;
1761
+ const replacement = `$${"{"}JSON.stringify(payload.${key})${"}"}`;
1762
+ result = result.slice(0, spanStart) + replacement + result.slice(spanEnd);
1763
+ if (!outStructuredKeys.has(key)) {
1764
+ outStructuredKeys.set(key, inferZodSchema(value));
1765
+ }
1766
+ }
1767
+ return result;
1768
+ }
1210
1769
  /** Maximum length to guard against indexing massive blobs (HTML fragments,
1211
1770
  * embedded base64 images, etc.) that aren't candidates for state threading. */
1212
1771
  const MAX_STATE_VALUE_LENGTH = 256;
@@ -1557,6 +2116,26 @@ function collectHeaderBindings(actionSteps) {
1557
2116
  }
1558
2117
  return [...byKey.values()];
1559
2118
  }
2119
+ /**
2120
+ * Reads the concrete string a response-body produce points at, by walking the
2121
+ * capture's response body along the produce path. Returns null when any segment
2122
+ * is absent or the leaf isn't a string. Shared by state-threading and the
2123
+ * producer-boundary binding so both resolve produced values identically.
2124
+ */
2125
+ function resolveResponsePathValue(responseBody, path) {
2126
+ let cursor = responseBody;
2127
+ for (const segment of path) {
2128
+ if (cursor !== null &&
2129
+ typeof cursor === "object" &&
2130
+ segment in cursor) {
2131
+ cursor = cursor[segment];
2132
+ }
2133
+ else {
2134
+ return null;
2135
+ }
2136
+ }
2137
+ return typeof cursor === "string" ? cursor : null;
2138
+ }
1560
2139
  /**
1561
2140
  * Replaces occurrences of state values in `template` with `${varName}`
1562
2141
  * interpolations. Returns a JS template-literal string fragment (no backticks).
@@ -1577,20 +2156,9 @@ function interpolateStateValues(template, priorSteps, payloadAccessorByValue = n
1577
2156
  // interpolate here.
1578
2157
  if (p.kind === "header")
1579
2158
  continue;
1580
- let cursor = step.capture.responseBody;
1581
- for (const segment of p.path) {
1582
- if (cursor !== null &&
1583
- typeof cursor === "object" &&
1584
- segment in cursor) {
1585
- cursor = cursor[segment];
1586
- }
1587
- else {
1588
- cursor = null;
1589
- break;
1590
- }
1591
- }
1592
- if (typeof cursor === "string")
1593
- varNameByValue.set(cursor, p.name);
2159
+ const value = resolveResponsePathValue(step.capture.responseBody, p.path);
2160
+ if (value !== null)
2161
+ varNameByValue.set(value, p.name);
1594
2162
  }
1595
2163
  }
1596
2164
  let result = template;
@@ -1613,6 +2181,144 @@ function interpolateStateValues(template, priorSteps, payloadAccessorByValue = n
1613
2181
  }
1614
2182
  return result;
1615
2183
  }
2184
+ /**
2185
+ * Finds the request-body coordinates a PRODUCING step must source from the
2186
+ * caller's payload instead of a frozen capture literal.
2187
+ *
2188
+ * A response-produced state var (see {@link compileActionSteps}' produces[]) is
2189
+ * threaded as `${var}` in every step AFTER its producer. In the producer itself
2190
+ * the value predates its own response, so {@link interpolateStateValues} has no
2191
+ * prior binding for it and the frozen recon literal (the recon persona's
2192
+ * jobId/jobSeqNo/jobTitle/jobLocation) leaks into every caller's submission. The
2193
+ * value's real origin for the producer is the same coordinate the caller
2194
+ * supplies — a payload field.
2195
+ *
2196
+ * WHY it keys off produces[] ∩ the producer's own body, never a field/site name:
2197
+ * the signal is purely structural — "a value this flow threads downstream AND
2198
+ * re-sends in the very step that first emitted it". The field name is the
2199
+ * produced var name verbatim (`pathToVarName`'s output = the wire key), so the
2200
+ * producer's payload field and the downstream `${var}` describe one logical
2201
+ * coordinate and share the same runtime value (the caller passes it, the site
2202
+ * echoes it).
2203
+ *
2204
+ * A coordinate that a HIGHER-priority source already maps to a `payload.<field>`
2205
+ * (an entry-URL param — e.g. a jobSeqNo) is NOT skipped: it is re-emitted here so
2206
+ * the whole-value pass binds it atomically on the producer step, reusing that
2207
+ * source's accessor. Otherwise state threading fragments the composite (a prefix
2208
+ * that a prior step produced) before the length-descending payload pass can match
2209
+ * it, and the collision guard then refuses the embedded remainder — stranding the
2210
+ * middle of the coordinate frozen. A value mapped to a NON-payload target (the
2211
+ * threaded txn id) is left untouched. UUID-shaped values are excluded entirely —
2212
+ * a re-sent UUID is a volatile/threaded id owned by another pass, not a caller
2213
+ * coordinate — and only WHOLE request-body leaves qualify, so every returned
2214
+ * value is one the whole-value pass will bind (and whose field must be declared).
2215
+ *
2216
+ * @param actions the compiled action steps (carry produces[] + request bodies)
2217
+ * @param alreadyBound capture value → existing accessor; a `payload.*` accessor
2218
+ * is reused, a non-payload one (e.g. `txnId`) vetoes the value
2219
+ * @returns capture value → { accessor: "payload.<field>"; field; producerIndex }
2220
+ */
2221
+ function deriveProducerBoundaryBindings(actions, alreadyBound) {
2222
+ const bindings = new Map();
2223
+ for (let i = 0; i < actions.length; i++) {
2224
+ const step = actions[i];
2225
+ const bodyLeafValues = jsonBodyLeafValues(step.capture.requestPostData);
2226
+ for (const p of step.produces) {
2227
+ if (p.kind === "header")
2228
+ continue;
2229
+ const value = resolveResponsePathValue(step.capture.responseBody, p.path);
2230
+ if (value === null || value.length < MIN_STATE_VALUE_LENGTH)
2231
+ continue;
2232
+ if (bindings.has(value))
2233
+ continue;
2234
+ // A UUID re-sent across steps is never a stable caller coordinate — it's a
2235
+ // per-call volatile id or the threaded transaction id (which the server may
2236
+ // echo, so it looks "produced"). Both are owned by their own passes (the
2237
+ // volatile regen / the hoisted `txnId`); binding one to a payload field
2238
+ // would freeze the recon's single id into every caller's submission.
2239
+ if (UUID_REGEX.test(value))
2240
+ continue;
2241
+ // A value already mapped to a non-payload target (the threaded txn id) must
2242
+ // stay that target; only a `payload.*` accessor is reusable here.
2243
+ const existing = alreadyBound.get(value);
2244
+ if (existing !== undefined && !existing.startsWith("payload."))
2245
+ continue;
2246
+ // Producer-boundary reuse: the value must re-appear as a WHOLE JSON leaf in
2247
+ // THIS step's own request body. Whole-leaf (not substring) keeps "in this
2248
+ // map" ⟺ "the whole-value pass will bind this value's `"<key>":"<value>"`
2249
+ // slot" ⟺ "its field must be declared"; a substring match would declare a
2250
+ // field the pass never references. A composite that embeds a shorter
2251
+ // coordinate (a jobId inside a jobSeqNo) still binds — each is its own whole
2252
+ // leaf, and the longer one's whole-value bind carries the embedded copy. A
2253
+ // non-JSON (multipart) body has no parseable leaves and the whole-value pass
2254
+ // can't rewrite it, so it never qualifies (`bodyLeafValues === null`).
2255
+ if (bodyLeafValues === null || !bodyLeafValues.some((leaf) => leaf === value))
2256
+ continue;
2257
+ // Reuse the higher-priority source's field when present; otherwise the
2258
+ // produced var name IS the wire key (pathToVarName). Use it verbatim so it
2259
+ // stays consistent with the downstream `${<key>N}` var; don't PascalCase it
2260
+ // (that lowercases camelCase, e.g. jobId→Jobid, and diverges from both the
2261
+ // state var and the entry-URL-param raw-key convention).
2262
+ const field = existing !== undefined ? existing.slice("payload.".length) : p.name;
2263
+ if (!isValidJsIdentifier(field))
2264
+ continue;
2265
+ // `pathToVarName` returns the sentinel `"value"` when a produce path has no
2266
+ // identifier segment (all array indices) — a meaningless caller field name.
2267
+ // Freeze such a value (it surfaces via the unbound-literal TODO for the
2268
+ // author to name) rather than shipping `payload.value`. A reused
2269
+ // higher-priority accessor is a real declared field, so only veto the
2270
+ // sentinel when the name came from `p.name`.
2271
+ if (existing === undefined && field === "value")
2272
+ continue;
2273
+ bindings.set(value, { accessor: `payload.${field}`, field, producerIndex: i });
2274
+ }
2275
+ }
2276
+ return bindings;
2277
+ }
2278
+ /**
2279
+ * Binds a caller coordinate in a step's body BEFORE state threading runs, via a
2280
+ * JSON-key-anchored WHOLE-value rewrite (`"<key>":"<value>"` →
2281
+ * `"<key>":"${payload.<field>}"`).
2282
+ *
2283
+ * WHY before {@link interpolateStateValues} and not via its payload pass: a
2284
+ * composite coordinate like a jobLocation `"Torrington, Connecticut, United
2285
+ * States"` or a jobSeqNo `"HHKHHEUS26158515EXTERNALENUS"` contains inner tokens a
2286
+ * genuinely-prior step produces as its own state var (a `label`, a `refNum`).
2287
+ * Pass-1 state threading would fragment the string (`"Torrington, ${label}"`,
2288
+ * `"${refNum}26158515EXTERNALENUS"`) before the length-descending payload pass
2289
+ * could match the full literal — and the collision guard then refuses to bind the
2290
+ * embedded remainder, stranding it frozen. Binding the whole coordinate first —
2291
+ * the same "swallow whole before inner passes reach in" discipline as
2292
+ * {@link applyStructuredValuePayloadSubstitutions} — keeps it atomic. Anchored on
2293
+ * the exact `"<key>":` slot, so it only fires on a value's own JSON slot.
2294
+ *
2295
+ * `producerScoped` bindings fire only on their producing step
2296
+ * (`producerIndex === stepIndex`): a later step re-sending the same coordinate
2297
+ * threads the produced state var, the established behavior; only the producer,
2298
+ * which cannot thread its own not-yet-existent response, needs the payload bind.
2299
+ * `entryUrlBindings` (a caller coordinate lifted from the entry URL) fire on
2300
+ * EVERY step — they are the caller's data on every request, never a produced var.
2301
+ */
2302
+ function applyWholeValuePayloadSubstitutions(template, parsedBody, producerScoped, entryUrlBindings, stepIndex) {
2303
+ if (producerScoped.size === 0 && entryUrlBindings.size === 0)
2304
+ return template;
2305
+ let result = template;
2306
+ for (const { value, path } of walkStringLeaves(parsedBody)) {
2307
+ const scoped = producerScoped.get(value);
2308
+ const accessor = scoped !== undefined && scoped.producerIndex === stepIndex
2309
+ ? scoped.accessor
2310
+ : entryUrlBindings.get(value);
2311
+ if (accessor === undefined)
2312
+ continue;
2313
+ const key = path[path.length - 1] ?? "";
2314
+ if (key.length === 0)
2315
+ continue;
2316
+ const target = `"${key}":${JSON.stringify(value)}`;
2317
+ const replacement = `"${key}":"\${${accessor}}"`;
2318
+ result = result.split(target).join(replacement);
2319
+ }
2320
+ return result;
2321
+ }
1616
2322
  /**
1617
2323
  * Substitutes literal JSON key/value pairs in a body template with payload
1618
2324
  * interpolations. Catches short strings (e.g. Culture: "en"), booleans
@@ -1695,172 +2401,91 @@ function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies
1695
2401
  }
1696
2402
  return result;
1697
2403
  }
1698
- // ── base64 Content parameterization ──────────────────────────────────────────
1699
2404
  /**
1700
- * Maps a site's screening-question prompts to the payload field that answers
1701
- * them, as `{ payloadField: [keyword, …] }`.
2405
+ * Documented closed set of JSON-key-name fragments (matched case-insensitively)
2406
+ * that mark a value as a per-request TIMESTAMP the plugin must generate fresh at
2407
+ * call time, not replay from the capture. Closed set per the no-regex-on-open-
2408
+ * sets feedback, mirroring {@link CACHE_BUSTER_QUERY_KEYS}'s posture. A frozen
2409
+ * capture timestamp would make every submission claim the recon instant.
2410
+ */
2411
+ const VOLATILE_TIMESTAMP_KEY_FRAGMENTS = ["timestamp", "esign", "signeddate", "signedat"];
2412
+ /** JSON-key-name suffix marking a value as a per-request time the plugin must
2413
+ * regenerate (e.g. `stepStartTime`, `submissionTime`). Separate from the
2414
+ * fragment set so it anchors on the suffix and doesn't match `runtime`/`downtime`. */
2415
+ const VOLATILE_TIME_KEY_SUFFIX = "time";
2416
+ function isVolatileTimestampKey(key) {
2417
+ const k = key.toLowerCase();
2418
+ if (VOLATILE_TIMESTAMP_KEY_FRAGMENTS.some((frag) => k.includes(frag)))
2419
+ return true;
2420
+ return k.endsWith(VOLATILE_TIME_KEY_SUFFIX) && k !== "time";
2421
+ }
2422
+ /**
2423
+ * Rewrites per-request VOLATILE values in a body template so the generated
2424
+ * plugin produces them at call time instead of replaying the capture's:
2425
+ * - a UUID-valued leaf → a fresh `crypto.randomUUID()`
2426
+ * - a timestamp/eSign/-time-named leaf → a fresh `new Date().toISOString()`
1702
2427
  *
1703
- * Empty by default and supplied by the operator via `RECON_QUESTION_KEYWORDS`
1704
- * (JSON) the engine cannot know what any site asks or what a caller's payload
1705
- * calls things. It previously hardcoded one product's field names, which capped
1706
- * discovery at those questions and silently dropped every other site's.
1707
- */
1708
- function loadQuestionPromptKeywords() {
1709
- const raw = process.env.RECON_QUESTION_KEYWORDS;
1710
- if (!raw)
1711
- return {};
1712
- try {
1713
- return JSON.parse(raw);
1714
- }
1715
- catch (err) {
1716
- logger.warn(`RECON_QUESTION_KEYWORDS is not valid JSON, ignoring: ${(0, errors_1.toErrorMessage)(err)}`);
1717
- return {};
2428
+ * Walks the PARSED body (so keys are known) and rewrites JSON-key-anchored
2429
+ * (`"key":JSON.stringify(value)`), the same closed-set idiom as
2430
+ * {@link applyPayloadKeyValueSubstitutions}, recursing to ANY depth so nested
2431
+ * analytics/step blobs (`eventData`, `stepInfo[]`) are neutralized too. Values
2432
+ * in `shieldedUuids` (schema field-id/option-id anchors) or `boundValues` (a
2433
+ * value already substituted to `${payload…}`/`${txnId}`/state) are left alone
2434
+ * an already-threaded transaction id or a bound email is not volatile.
2435
+ *
2436
+ * `crypto`/`Date` are bare Node/JS globals in the generated file (which already
2437
+ * uses `Buffer` bare); the `${…}` fragments are assembled by concatenation so
2438
+ * Biome's noTemplateCurlyInString doesn't flag THIS file's source.
2439
+ */
2440
+ function applyVolatileFieldSubstitutions(template, parsedBody, shieldedUuids, boundValues) {
2441
+ const uuidGen = `$${"{"}crypto.randomUUID()${"}"}`;
2442
+ const isoGen = `$${"{"}new Date().toISOString()${"}"}`;
2443
+ let result = template;
2444
+ for (const { value, path } of walkAllPrimitiveLeaves(parsedBody)) {
2445
+ if (typeof value !== "string" || value.length === 0)
2446
+ continue;
2447
+ if (shieldedUuids.has(value) || boundValues.has(value))
2448
+ continue;
2449
+ const key = path[path.length - 1] ?? "";
2450
+ const replacement = UUID_REGEX.test(value)
2451
+ ? uuidGen
2452
+ : isVolatileTimestampKey(key)
2453
+ ? isoGen
2454
+ : null;
2455
+ if (replacement === null)
2456
+ continue;
2457
+ const target = `"${JSON.stringify(value).slice(1, -1)}"`;
2458
+ result = result.split(target).join(`"${replacement}"`);
1718
2459
  }
2460
+ return result;
1719
2461
  }
1720
- const QUESTION_PROMPT_KEYWORDS = loadQuestionPromptKeywords();
1721
2462
  /**
1722
- * Scans captures for a `recruitingCEQuestions` GET response and builds a
1723
- * mapping from question prompts to payload.Answers field names using keyword
1724
- * overlap scoring. Returns null if no questions capture is found.
2463
+ * Collects captured string leaves that survived every binding/generation pass as
2464
+ * still-literal the values a reviewer must look at because they couldn't be
2465
+ * traced to a payload field, a generator, or a schema anchor. Returns the JSON
2466
+ * key names (deduped, in first-seen order) so the emitter can prepend a single
2467
+ * `// TODO: unbound captured literal` marker; it never mutates the body, so the
2468
+ * file still compiles. Short values (< {@link MIN_STATE_VALUE_LENGTH}) are
2469
+ * skipped — they are the legitimately-constant enum-like fields.
1725
2470
  */
1726
- function buildQuestionnaireMapping(captures) {
1727
- const questionCapture = captures.find((c) => c.method === "GET" && c.url.includes("recruitingCEQuestions"));
1728
- if (!questionCapture)
1729
- return null;
1730
- const resp = typeof questionCapture.responseBody === "string"
1731
- ? JSON.parse(questionCapture.responseBody)
1732
- : questionCapture.responseBody;
1733
- if (!resp || !Array.isArray(resp.items))
1734
- return null;
1735
- const mappings = [];
1736
- const unmapped = [];
1737
- for (const item of resp.items) {
1738
- const prompt = String(item.Prompt ?? "").toLowerCase();
1739
- const qid = item.AttributeName;
1740
- const uiType = String(item.UIDisplayType ?? "");
1741
- if (!qid || uiType === "TextBox")
2471
+ function collectUnboundLiterals(finalTemplate, parsedBody, shieldedUuids) {
2472
+ const unbound = [];
2473
+ const seen = new Set();
2474
+ for (const { value, path } of walkAllPrimitiveLeaves(parsedBody)) {
2475
+ if (typeof value !== "string" || value.length < MIN_STATE_VALUE_LENGTH)
1742
2476
  continue;
1743
- let bestField = null;
1744
- let bestScore = 0;
1745
- for (const [field, keywords] of Object.entries(QUESTION_PROMPT_KEYWORDS)) {
1746
- const score = keywords.filter((kw) => prompt.includes(kw)).length;
1747
- if (score > bestScore) {
1748
- bestScore = score;
1749
- bestField = field;
1750
- }
1751
- }
1752
- // A question the keyword map cannot place is the interesting case: it is a
1753
- // question this site asks and the caller has no field for. Report it —
1754
- // dropping it silently is how a generated plugin ends up submitting nothing
1755
- // for a required question.
1756
- if (!bestField || bestScore < 2) {
1757
- unmapped.push(`${qid}: ${String(item.Prompt ?? "")}`);
2477
+ if (shieldedUuids.has(value))
1758
2478
  continue;
1759
- }
1760
- if (mappings.some((m) => m.payloadField === bestField))
2479
+ const key = path[path.length - 1] ?? "";
2480
+ if (seen.has(key))
1761
2481
  continue;
1762
- const answers = {};
1763
- for (const a of (item.answers ?? [])) {
1764
- const meaning = String(a.Meaning ?? "");
1765
- const code = a.LookupCode;
1766
- if (meaning && code)
1767
- answers[meaning] = code;
1768
- }
1769
- mappings.push({ questionId: qid, payloadField: bestField, answers });
1770
- }
1771
- if (unmapped.length > 0) {
1772
- logger.warn(`${unmapped.length} screening question(s) matched no payload field and will be unanswered — add keywords to RECON_QUESTION_KEYWORDS: ${unmapped.join(" | ")}`);
1773
- }
1774
- return mappings.length > 0 ? mappings : null;
1775
- }
1776
- /**
1777
- * Builds the TypeScript source for a `buildBase64Content` function that
1778
- * constructs the base64-encoded Content JSON from payload values and returns
1779
- * it as a base64 string. The function replaces persona-specific values
1780
- * with payload references and maps questionnaire answers via a static
1781
- * lookup table derived from the recon captures.
1782
- */
1783
- function emitBuildBase64ContentFunction(base64, personaValues, questionMapping, pascal) {
1784
- const decoded = Buffer.from(base64, "base64").toString("utf8");
1785
- const content = JSON.parse(decoded);
1786
- const candidate = content.candidate;
1787
- const basic = candidate.basicInformation;
1788
- const phone = basic.phone;
1789
- const application = content.application;
1790
- const esig = application.eSignature;
1791
- basic.firstName = "__PAYLOAD_FirstName__";
1792
- basic.lastName = "__PAYLOAD_LastName__";
1793
- basic.email = "__PAYLOAD_Email__";
1794
- if (esig)
1795
- esig.fullName = "__PAYLOAD_SignatureFullName__";
1796
- if (basic.displayName && typeof basic.displayName === "string")
1797
- basic.displayName = "__PAYLOAD_DisplayName__";
1798
- if (phone && typeof phone.number === "string" && phone.number)
1799
- phone.number = "__PAYLOAD_Phone__";
1800
- for (const [personaVal, _payloadRef] of personaValues) {
1801
- if (typeof basic.email === "string" && basic.email === personaVal)
1802
- basic.email = "__PAYLOAD_Email__";
1803
- }
1804
- const questionnaires = candidate.questionnaires;
1805
- if (questionnaires && questionMapping) {
1806
- for (const q of questionnaires) {
1807
- q.questionnaireId = -1;
1808
- for (const question of q.questions) {
1809
- const mapping = questionMapping.find((m) => m.questionId === question.questionId);
1810
- if (mapping) {
1811
- question.answer = `__QMAP_${mapping.payloadField}__`;
1812
- }
1813
- }
1814
- }
1815
- }
1816
- const attachments = candidate.attachments;
1817
- if (attachments) {
1818
- for (const att of attachments) {
1819
- if (att.id && att.id !== "draft-json-undefined") {
1820
- att.id = "__PAYLOAD_AttachmentId__";
1821
- }
1822
- }
1823
- if (attachments[0]) {
1824
- attachments[0].appDraftId = "__PAYLOAD_DraftId__";
2482
+ // Still a bare literal in the emitted template (no ${} took its place).
2483
+ if (finalTemplate.includes(JSON.stringify(value))) {
2484
+ seen.add(key);
2485
+ unbound.push(key);
1825
2486
  }
1826
2487
  }
1827
- const jsonStr = JSON.stringify(content, null, 0);
1828
- const contentObj = JSON.parse(jsonStr);
1829
- const questionMapEntries = (questionMapping ?? []).map((m) => ` ${JSON.stringify(m.payloadField)}: { answers: ${JSON.stringify(m.answers)} as Record<string, number>, questionId: ${m.questionId} },`);
1830
- const questionMapConst2 = questionMapEntries.length > 0
1831
- ? `\nconst QUESTIONNAIRE_ANSWER_MAP = {\n${questionMapEntries.join("\n")}\n};\n`
1832
- : "";
1833
- const contentTemplate = JSON.stringify(contentObj, null, 2);
1834
- const parameterized = contentTemplate
1835
- .replace(/"__PAYLOAD_FirstName__"/g, "payload.FirstName")
1836
- .replace(/"__PAYLOAD_LastName__"/g, "payload.LastName")
1837
- .replace(/"__PAYLOAD_Email__"/g, "payload.Email")
1838
- .replace(/"__PAYLOAD_Phone__"/g, "payload.Phone")
1839
- .replace(/"__PAYLOAD_SignatureFullName__"/g, "payload.Answers.SignatureFullName")
1840
- // biome-ignore lint/suspicious/noTemplateCurlyInString: emitted as generated template-literal source
1841
- .replace(/"__PAYLOAD_DisplayName__"/g, "`${payload.FirstName} ${payload.LastName}`")
1842
- .replace(/"__PAYLOAD_AttachmentId__"/g, "attachmentId")
1843
- .replace(/"__PAYLOAD_DraftId__"/g, "draftId")
1844
- .replace(/-1(?=,\n\s*"questions")/g, "questionnaireId");
1845
- for (const m of questionMapping ?? []) {
1846
- parameterized.replace(`"__QMAP_${m.payloadField}__"`, `QUESTIONNAIRE_ANSWER_MAP[${JSON.stringify(m.payloadField)}].answers[payload.Answers.${m.payloadField}] ?? "draft-json-undefined"`);
1847
- }
1848
- let finalTemplate = parameterized.replace(/"__QMAP_[^"]*__"/g, '"draft-json-undefined"');
1849
- for (const m of questionMapping ?? []) {
1850
- finalTemplate = finalTemplate.replace(`"__QMAP_${m.payloadField}__"`, `(QUESTIONNAIRE_ANSWER_MAP[${JSON.stringify(m.payloadField)}].answers[payload.Answers.${m.payloadField}] ?? "draft-json-undefined")`);
1851
- }
1852
- return `${questionMapConst2}
1853
- /** Builds the ATS Content payload as a base64-encoded JSON string. */
1854
- function buildBase64Content(
1855
- payload: ${pascal}Payload,
1856
- questionnaireId: number,
1857
- draftId: number,
1858
- attachmentId: string
1859
- ): string {
1860
- const content = ${finalTemplate};
1861
- return Buffer.from(JSON.stringify(content)).toString("base64");
1862
- }
1863
- `;
2488
+ return unbound;
1864
2489
  }
1865
2490
  /** Builds the multi-step `executeHttp` body as a single template-literal string.
1866
2491
  *
@@ -1920,7 +2545,7 @@ function emitErrorSignalGuards(varName, urlPath, signals) {
1920
2545
  }
1921
2546
  /** Exported for unit testing — lets tests drive the multipart-upload code path directly
1922
2547
  * without going through the full emitContractTs pipeline. */
1923
- function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, base64PatchOverride = new Map(), formSchema = null) {
2548
+ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema = null, personaBindings = new Map(), entryUrlParams = new Map(), shieldedUuids = new Set(), selectResolutions = [], outStructuredKeys = new Map(), rawCodeFields = new Map()) {
1924
2549
  // Walk the first action's request body to map each leaf string value to its
1925
2550
  // `payload.<accessor>` expression. The emit's second interpolation pass uses
1926
2551
  // this to substitute literal occurrences (e.g. "Reginald") with their
@@ -1958,6 +2583,89 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
1958
2583
  payloadAccessorByValue.set(baseUrl, "payload.BaseUrl");
1959
2584
  outDiscoveredFields.add("BaseUrl");
1960
2585
  }
2586
+ // Persona identity bindings (from the flow's quoted literals + RECON_EMAIL,
2587
+ // paired to a payload field by the consumer vocabulary). Merged into the same
2588
+ // value→accessor map so `interpolateStateValues`' length-descending payload
2589
+ // pass substitutes them at ANY nesting depth — the fix for nested ATS bodies
2590
+ // like `formData.firstName` that the top-level-only key pass never reached.
2591
+ //
2592
+ // Collision guard (not a blunt length floor): that pass replaces by UNANCHORED
2593
+ // `String.split(value)`, so a persona value that appears INSIDE a longer token
2594
+ // would corrupt it — e.g. a `Select 'No' …` answer a vocabulary mapped to a
2595
+ // field would rewrite the "No" inside "Nursing"/"Not". A value binds only when
2596
+ // every occurrence across the action bodies sits at a token boundary (the
2597
+ // adjacent character is a non-alphanumeric JSON delimiter like `"`, space, or
2598
+ // punctuation), never flanked by alphanumerics. This keeps legitimately-short
2599
+ // identity values that don't collide (a 5-digit zip `06103`, a first name that
2600
+ // also appears space-delimited inside a signature) while dropping genuinely
2601
+ // dangerous substrings, which then surface via the unbound-literal TODO.
2602
+ // State-threaded produced values still win — they run in Pass 1, before this.
2603
+ const actionBodies = actions
2604
+ .map((a) => a.capture.requestPostData)
2605
+ .filter((b) => typeof b === "string" && b.length > 0);
2606
+ const isAlnum = (ch) => ch !== undefined && /[A-Za-z0-9]/.test(ch);
2607
+ const bindsWithoutCollision = (value) => {
2608
+ for (const body of actionBodies) {
2609
+ let from = 0;
2610
+ while (true) {
2611
+ const at = body.indexOf(value, from);
2612
+ if (at === -1)
2613
+ break;
2614
+ // Flanked by an alphanumeric on either side → it's a substring of a
2615
+ // longer token; binding it would mangle that token. Block the value.
2616
+ if (isAlnum(body[at - 1]) || isAlnum(body[at + value.length]))
2617
+ return false;
2618
+ from = at + value.length;
2619
+ }
2620
+ }
2621
+ return true;
2622
+ };
2623
+ for (const [value, accessor] of personaBindings) {
2624
+ if (value.length === 0)
2625
+ continue;
2626
+ if (!bindsWithoutCollision(value))
2627
+ continue;
2628
+ if (!payloadAccessorByValue.has(value))
2629
+ payloadAccessorByValue.set(value, accessor);
2630
+ const field = accessor.startsWith("payload.") ? accessor.slice("payload.".length) : null;
2631
+ if (field !== null && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field))
2632
+ outDiscoveredFields.add(field);
2633
+ }
2634
+ // Job coordinates from the recon entry URL's query string (e.g.
2635
+ // `?jobSeqNo=...`). Registered the same way as BaseUrl so every verbatim
2636
+ // occurrence — and, via length-descending order, embedded substrings like a
2637
+ // jobId inside a jobSeqNo — rewrites to the caller-supplied value.
2638
+ for (const [value, accessor] of entryUrlParams) {
2639
+ if (value.length === 0)
2640
+ continue;
2641
+ if (!payloadAccessorByValue.has(value))
2642
+ payloadAccessorByValue.set(value, accessor);
2643
+ const field = accessor.startsWith("payload.") ? accessor.slice("payload.".length) : null;
2644
+ if (field !== null && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field))
2645
+ outDiscoveredFields.add(field);
2646
+ }
2647
+ // Producer-boundary job coordinates: values a step's response produces (and
2648
+ // steps 2..N thread as `${var}`) that ALSO appear in that producing step's
2649
+ // own request body. The producer cannot thread its own not-yet-existent
2650
+ // response, so those slots would freeze the recon persona's coordinate; bind
2651
+ // them to the caller's payload instead. Registered here so the length-
2652
+ // descending payload pass rewrites short/embedded coordinates (a jobId inside
2653
+ // a jobSeqNo); the whole-value pass below binds composite coordinates a state
2654
+ // var would otherwise fragment. `bindsWithoutCollision`-guarded like personas.
2655
+ const producerBoundaryBindings = deriveProducerBoundaryBindings(actions, new Map(payloadAccessorByValue));
2656
+ for (const [value, { accessor, field }] of producerBoundaryBindings) {
2657
+ // Always declare: the whole-value pass binds this value's own JSON slot on
2658
+ // its producer step regardless of collision, so the schema MUST carry the
2659
+ // field or the emitted `${payload.<field>}` references an undeclared property.
2660
+ outDiscoveredFields.add(field);
2661
+ // The UNANCHORED length-descending registration stays collision-guarded: it
2662
+ // rewrites embedded substrings globally, so a value that also sits inside a
2663
+ // longer token (a jobId within a jobSeqNo) must not be registered here — the
2664
+ // whole-value pass already binds its standalone slot atomically.
2665
+ if (bindsWithoutCollision(value) && !payloadAccessorByValue.has(value)) {
2666
+ payloadAccessorByValue.set(value, accessor);
2667
+ }
2668
+ }
1961
2669
  // G2: register any tenant-subdomain header values as payload-supplied fields
1962
2670
  // (e.g. an `API-ShortName: "addus"` header becomes `payload.ApiShortName`).
1963
2671
  for (const [headerName, _value] of tenantSubdomainHeaders) {
@@ -1979,6 +2687,37 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
1979
2687
  // skip non-JSON bodies (e.g. multipart raw bytes)
1980
2688
  }
1981
2689
  }
2690
+ // Detect the flow's THREADED transaction id: a single UUID the site mints
2691
+ // once (on page load) and reuses across every submit body to correlate the
2692
+ // multi-step wizard — observed on real ATS flows where one such id spans
2693
+ // every step. A frozen capture UUID would collide across concurrent/real
2694
+ // submissions, so the plugin must mint ONE at call time and thread it — hence
2695
+ // it maps to a hoisted local, not a payload field. Identified generically:
2696
+ // the same non-shielded UUID present in ≥2 action bodies.
2697
+ const uuidBodyCounts = new Map();
2698
+ for (const { capture } of actions) {
2699
+ const seen = new Set();
2700
+ for (const v of jsonBodyLeafValues(capture.requestPostData) ?? []) {
2701
+ if (UUID_REGEX.test(v) && !shieldedUuids.has(v))
2702
+ seen.add(v);
2703
+ }
2704
+ for (const v of seen)
2705
+ uuidBodyCounts.set(v, (uuidBodyCounts.get(v) ?? 0) + 1);
2706
+ }
2707
+ const threadedTxnId = [...uuidBodyCounts.entries()].find(([, n]) => n >= 2)?.[0] ?? null;
2708
+ // The value→`${txnId}` binding rides the same substitution map as payload
2709
+ // accessors (Pass 2 of interpolateStateValues); the hoisted `const txnId`
2710
+ // declaration is emitted once above the step sequence below. `txnId` is a
2711
+ // generic local name — the wire key it fills is whatever the body used.
2712
+ if (threadedTxnId !== null && !payloadAccessorByValue.has(threadedTxnId)) {
2713
+ payloadAccessorByValue.set(threadedTxnId, "txnId");
2714
+ }
2715
+ // Values already substituted to a `${…}` reference — the volatile pass must
2716
+ // NOT regenerate these (an already-threaded txn id or a bound email is not
2717
+ // volatile). Keyed by the concrete captured value.
2718
+ const boundValues = new Set(payloadAccessorByValue.keys());
2719
+ // Captured literals that survived every pass — surfaced as a review TODO.
2720
+ const unboundLiteralKeys = new Set();
1982
2721
  // Pass 1: render every step's emitted strings; collect referenced var names.
1983
2722
  const rendered = [];
1984
2723
  for (let i = 0; i < actions.length; i++) {
@@ -1997,12 +2736,60 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
1997
2736
  const rawBodyWithFormSubs = cap.requestPostData && formSchema !== null
1998
2737
  ? applyRawOptionIdPayloadSubstitutions(applyFormSchemaOptionIdSubstitutions(applyFormSchemaSubstitutions(cap.requestPostData, fieldNameMap, outDiscoveredFields, formSchema), fieldOptionsMap, outDiscoveredOptionFields, formSchema), fieldNameMap, fieldOptionsMap, outDiscoveredRawOptionFields, formSchema)
1999
2738
  : (cap.requestPostData ?? "");
2000
- let bodyTemplate = rawBodyWithFormSubs
2001
- ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithFormSubs, prior, payloadAccessorByValue), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
2739
+ // Parsed once and shared by the structured (Mechanism B) and volatile passes
2740
+ // below both walk the same body JSON, so parsing twice would be redundant.
2741
+ // null for absent/non-JSON bodies (multipart raw bytes), which both passes skip.
2742
+ const parsedBody = (() => {
2743
+ if (!cap.requestPostData)
2744
+ return null;
2745
+ try {
2746
+ return JSON.parse(cap.requestPostData);
2747
+ }
2748
+ catch {
2749
+ return null;
2750
+ }
2751
+ })();
2752
+ // Mechanism B — parameterize whole nested caller structures
2753
+ // (experienceData/educationData history, opaque eventData) BEFORE value
2754
+ // substitution reaches inside them: swallowing the entire array/object first
2755
+ // keeps interpolateStateValues from binding a code buried in the history
2756
+ // sample (e.g. a work entry's state code) to an unrelated field.
2757
+ const rawBodyWithStructuredSubs = parsedBody !== null
2758
+ ? applyStructuredValuePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, outStructuredKeys)
2759
+ : rawBodyWithFormSubs;
2760
+ // Whole-value caller coordinates bind here — after structured subs, BEFORE
2761
+ // state threading — so a composite coordinate (a jobLocation or jobSeqNo
2762
+ // whose inner tokens a prior step produces as a state var) binds as one
2763
+ // atomic `${payload.X}` before Pass 1 can fragment it. Producer-boundary
2764
+ // coordinates fire on their producer only; entry-URL coordinates on every
2765
+ // step. No-op on steps without a match.
2766
+ const rawBodyWithProducerBoundary = parsedBody !== null
2767
+ ? applyWholeValuePayloadSubstitutions(rawBodyWithStructuredSubs, parsedBody, producerBoundaryBindings, entryUrlParams, i)
2768
+ : rawBodyWithStructuredSubs;
2769
+ const bodyAfterStateAndKv = rawBodyWithProducerBoundary
2770
+ ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithProducerBoundary, prior, payloadAccessorByValue), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
2002
2771
  : "";
2003
- const contentOverride = base64PatchOverride.get(step.varName);
2004
- if (contentOverride && bodyTemplate) {
2005
- bodyTemplate = bodyTemplate.replace(/"Content":"ey[A-Za-z0-9+/=]{100,}"/, contentOverride);
2772
+ // Mechanism A — generic (plain-JSON, wire-key-anchored) dropdown label→code
2773
+ // rewrite. Runs AFTER interpolateStateValues + the payload-KV pass, not
2774
+ // before: the emitted `${OPT_<Name>[]}` placeholder embeds the PascalCase
2775
+ // field name, and a wizard step-slug value (e.g. stepNum "Disability") that
2776
+ // becomes a global `.split` payload binding would otherwise rewrite the
2777
+ // matching substring INSIDE that placeholder and corrupt it. The closed-set
2778
+ // `"<key>":"<code>"` slot (numeric code, nested key) survives both earlier
2779
+ // passes untouched, so matching it here is still exact.
2780
+ let bodyTemplate = cap.requestPostData
2781
+ ? applyGenericRawCodeSubstitutions(applyGenericOptionCodeSubstitutions(bodyAfterStateAndKv, selectResolutions, fieldOptionsMap, outDiscoveredOptionFields), rawCodeFields)
2782
+ : bodyAfterStateAndKv;
2783
+ // Volatile pass: after persona/job/state/kv binding, regenerate any
2784
+ // remaining per-request UUID (fresh crypto.randomUUID()) and timestamp
2785
+ // (fresh new Date().toISOString()) so the plugin never replays the capture
2786
+ // instant. Recurses to any depth; skips schema anchors and already-bound
2787
+ // values (incl. the threaded txn id). Then flag whatever is STILL literal.
2788
+ if (bodyTemplate && parsedBody !== null) {
2789
+ bodyTemplate = applyVolatileFieldSubstitutions(bodyTemplate, parsedBody, shieldedUuids, boundValues);
2790
+ for (const key of collectUnboundLiterals(bodyTemplate, parsedBody, shieldedUuids)) {
2791
+ unboundLiteralKeys.add(key);
2792
+ }
2006
2793
  }
2007
2794
  const perCallHeaders = {};
2008
2795
  for (const [k, v] of Object.entries(cap.requestHeaders)) {
@@ -2067,24 +2854,24 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
2067
2854
  const returnAction = selectReturnAction(actions);
2068
2855
  if (returnAction)
2069
2856
  referencedNames.add(returnAction.varName);
2070
- // Base64 Content overrides reference variables inside function calls
2071
- // (e.g. buildBase64Content(payload, questionnaireId, ...)) that the
2072
- // ${name} regex above doesn't capture. Add them explicitly.
2073
- for (const [key, override] of base64PatchOverride.entries()) {
2074
- if (key === "__EXTRA_VARS__")
2075
- continue;
2076
- for (const m of override.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\b/g)) {
2077
- const name = m[1];
2078
- if (/^r\d+$/.test(name))
2079
- continue;
2080
- referencedNames.add(name);
2081
- }
2082
- }
2083
2857
  // Pass 2: emit. Skip response bindings that aren't referenced; skip
2084
2858
  // produces[] entries whose name isn't referenced. A step's response var
2085
2859
  // is still needed when at least one of its produces[] entries IS
2086
2860
  // referenced — the produces line dereferences it.
2087
2861
  const lines = [];
2862
+ // Mint the threaded transaction id ONCE and reuse across every step — the
2863
+ // `${txnId}` references emitted into the bodies above all resolve to this
2864
+ // single call-time UUID, matching how the site mints one per application.
2865
+ // Emitted only when actually referenced (Biome noUnusedVariables).
2866
+ if (threadedTxnId !== null && referencedNames.has("txnId")) {
2867
+ lines.push(` const txnId = crypto.randomUUID();`);
2868
+ lines.push("");
2869
+ }
2870
+ // Surface any captured literal that no pass could bind, so a reviewer knows
2871
+ // exactly which slots still carry recon data. Comment only — never blocks emit.
2872
+ if (unboundLiteralKeys.size > 0) {
2873
+ lines.push(` // TODO: unbound captured literal(s) — verify these carry caller data, not the recon capture's: ${[...unboundLiteralKeys].join(", ")}`);
2874
+ }
2088
2875
  const declaredNames = new Set();
2089
2876
  for (let i = 0; i < actions.length; i++) {
2090
2877
  const step = actions[i];
@@ -2113,10 +2900,6 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
2113
2900
  produceLines.push(` const ${p.name} = (${step.varName} as ${assertion})${pathToAccessor(p.path, { assertNonNull: false })};`);
2114
2901
  }
2115
2902
  const bindResponse = referencedNames.has(step.varName) || produceLines.length > 0;
2116
- if (base64PatchOverride.has(step.varName) && base64PatchOverride.has("__EXTRA_VARS__")) {
2117
- lines.push(base64PatchOverride.get("__EXTRA_VARS__"));
2118
- lines.push("");
2119
- }
2120
2903
  if (step.isCrossDomain) {
2121
2904
  lines.push(` // TODO: cross-domain redirect detected (${cap.url.split("/")[2]}) — likely needs browser fallback for this step.`);
2122
2905
  }
@@ -2243,7 +3026,7 @@ function bindOptionLiteral(headerBindings) {
2243
3026
  /** Generates a complete contract.ts source string for a plugin — exported so
2244
3027
  * unit tests can drive the emitter directly without spawning the CLI. */
2245
3028
  function emitContractTs(opts) {
2246
- const { siteId, pascal, baseUrl, baseHeaders, minTime, safeRps, responseBody, gql, gqlQuery, endpointPath, auxFiles, multiStepBody, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, payloadFieldNames, base64ContentHelper = "", headerBindings = [], } = opts;
3029
+ const { siteId, pascal, baseUrl, baseHeaders, minTime, safeRps, responseBody, gql, gqlQuery, endpointPath, auxFiles, multiStepBody, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, payloadFieldNames, headerBindings = [], } = opts;
2247
3030
  // This is the CLIENT-level schema — createHttpClient's default, and the
2248
3031
  // plugin's caller-facing contract (what executeHttp's return value promises
2249
3032
  // its own caller). It does NOT validate any individual call in a multi-step
@@ -2366,14 +3149,25 @@ function emitContractTs(opts) {
2366
3149
  })
2367
3150
  .join("\n")}\n})`
2368
3151
  : "";
3152
+ // Mechanism B: nested caller structures become payload fields carrying their
3153
+ // inferred schema. Emitted as an object body so multi-line z.array(z.object(
3154
+ // …)) expressions indent cleanly; a leading TSDoc flags eventData's opaque
3155
+ // passthrough so callers know its nested volatiles are theirs to mint.
3156
+ const sortedStructuredEntries = discoveredStructuredKeys
3157
+ ? [...discoveredStructuredKeys.entries()].sort(([a], [b]) => a.localeCompare(b))
3158
+ : [];
3159
+ const structuredKeysExtension = sortedStructuredEntries.length > 0
3160
+ ? `.extend({\n${sortedStructuredEntries
3161
+ .map(([name, schema]) => ` ${isValidJsIdentifier(name) ? name : JSON.stringify(name)}: ${schema},`)
3162
+ .join("\n")}\n})`
3163
+ : "";
2369
3164
  // optionSchemaExtension is appended LAST so option enums show up at the
2370
3165
  // end of the payload type — the section ordering (base, multipart fields,
2371
3166
  // form-schema fields, option enums, raw-option fields) mirrors the body
2372
3167
  // emit order and keeps the generated payload type readable.
2373
- const answersExtension = base64ContentHelper ? ".extend({ Answers: AnswersSchema })" : "";
2374
3168
  const payloadSchemaExpr = hasMultipartStep
2375
- ? `${basePayloadSchemaExpr}.extend({\n Resume: z.instanceof(Buffer),\n ResumeContentType: z.string(),\n ResumeFilename: z.string(),\n})${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${answersExtension}`
2376
- : `${basePayloadSchemaExpr}${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${answersExtension}`;
3169
+ ? `${basePayloadSchemaExpr}.extend({\n Resume: z.instanceof(Buffer),\n ResumeContentType: z.string(),\n ResumeFilename: z.string(),\n})${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${structuredKeysExtension}`
3170
+ : `${basePayloadSchemaExpr}${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${structuredKeysExtension}`;
2377
3171
  // When the payload schema uses multipartBoolean(), import the shared helper
2378
3172
  // so the generated file resolves the reference and doesn't re-inline the
2379
3173
  // preprocess expression per boolean field.
@@ -2480,7 +3274,7 @@ const ${pascal}ResponseSchema = ${responseSchemaExpr};
2480
3274
  export type ${pascal}Response = z.infer<typeof ${pascal}ResponseSchema>;
2481
3275
 
2482
3276
  export default ${pascal}ResponseSchema;
2483
- ${optionDecls}${base64ContentHelper}
3277
+ ${optionDecls}
2484
3278
  const ${pascal}PayloadSchema = ${payloadSchemaExpr};
2485
3279
 
2486
3280
  export type ${pascal}Payload = z.infer<typeof ${pascal}PayloadSchema>;
@@ -2990,8 +3784,8 @@ async function main() {
2990
3784
  body: submitBodyPattern,
2991
3785
  };
2992
3786
  // Resolved once and threaded down, never captured into a module const: a
2993
- // module-level const would freeze at import time, which is the bug that makes
2994
- // RECON_QUESTION_KEYWORDS silently inert for anyone setting it after load.
3787
+ // module-level const would freeze at import time, so an env var set after
3788
+ // module load would be silently inert for anyone reading it that way.
2995
3789
  const vocabulary = await resolveVocabulary(vocabularySpecifier);
2996
3790
  // Consumer-supplied wire keys for ATS form-schema recovery, or null. When
2997
3791
  // null the recovery functions no-op — the engine hardcodes no vendor format.
@@ -3032,6 +3826,13 @@ async function main() {
3032
3826
  // would be skipped by fieldNameMap; their field-ids still need shielding
3033
3827
  // because they appear as anchors in the T2-substituted body templates.
3034
3828
  const shieldedUuids = new Set(allSchemaUuids);
3829
+ // Persona identity bindings + entry-URL job coordinates — the value→payload
3830
+ // reconciliation the body emitter merges into its substitution map so nested
3831
+ // applicant fields and job context reach the caller's data instead of the
3832
+ // recon persona's. Both are site-agnostic: persona mapping comes from the
3833
+ // consumer vocabulary, job coordinates from the entry URL's own query keys.
3834
+ const personaBindings = harvestPersonaBindings(flowSteps, vocabulary, process.env);
3835
+ const entryUrlParams = extractEntryUrlParams(captures[0]?.url ?? "");
3035
3836
  // T4 — Phase B+C: detect a form-schema GET capture and insert it into the
3036
3837
  // action sequence at the position observed during recon, so the existing
3037
3838
  // state-threading machinery can produce its FormHistoryId / section UUIDs /
@@ -3098,6 +3899,20 @@ async function main() {
3098
3899
  // that get parameterized. Recorded with their value type so the contract
3099
3900
  // emitter can add them to the payload schema with appropriate Zod types.
3100
3901
  const discoveredAdditionalBodyKeys = new Map();
3902
+ // Mechanism A: reconcile flow SELECT steps to submitted option codes. The
3903
+ // resolutions drive a wire-key-anchored body rewrite (label→code dropdowns);
3904
+ // i18n-only dropdowns (labels all templated, e.g. gender) fall through to the
3905
+ // existing raw-option channel so their frozen code is still parameterized.
3906
+ const { resolutions: selectResolutions, rawCodeFields } = buildSelectOptionResolutions(flowSteps, captures, vocabulary, process.env);
3907
+ for (const [semanticName, { code }] of rawCodeFields) {
3908
+ const fieldName = `${semanticName}Code`;
3909
+ if (!discoveredRawOptionFields.has(fieldName))
3910
+ discoveredRawOptionFields.set(fieldName, code);
3911
+ }
3912
+ // Mechanism B: nested caller structures (experienceData/educationData
3913
+ // history, opaque eventData) discovered during the body emit, surfaced to the
3914
+ // contract's payload schema.
3915
+ const discoveredStructuredKeys = new Map();
3101
3916
  // G1+G2: partition baseHeaders into three buckets:
3102
3917
  // - static: values that don't reference baseUrl or tenant subdomain
3103
3918
  // - baseUrl-derived: values containing the recon's baseUrl as substring
@@ -3129,98 +3944,8 @@ async function main() {
3129
3944
  }
3130
3945
  }
3131
3946
  const multiStepBody = isSubmissionFlow
3132
- ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, new Map(), formSchema)
3947
+ ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields)
3133
3948
  : undefined;
3134
- let base64ContentHelper = "";
3135
- const base64PatchOverride = new Map();
3136
- if (isSubmissionFlow && actionSteps.length > 0) {
3137
- const lastPatchWithContent = [...actionSteps]
3138
- .reverse()
3139
- .find((s) => s.capture.method === "PATCH" &&
3140
- s.capture.requestPostData &&
3141
- /"Content":"ey[A-Za-z0-9+/=]{100,}"/.test(s.capture.requestPostData));
3142
- if (lastPatchWithContent) {
3143
- const b64Match = lastPatchWithContent.capture.requestPostData.match(/"Content":"(ey[A-Za-z0-9+/=]{100,})"/);
3144
- if (b64Match) {
3145
- const b64 = b64Match[1];
3146
- const qMapping = buildQuestionnaireMapping(captures);
3147
- const personaValues = new Map();
3148
- const firstPost = captures.find((c) => c.method === "POST" &&
3149
- c.url.includes("recruitingCEJobApplicationDrafts") &&
3150
- c.requestPostData);
3151
- if (firstPost?.requestPostData) {
3152
- try {
3153
- const pb = JSON.parse(firstPost.requestPostData);
3154
- if (typeof pb.EmailAddress === "string")
3155
- personaValues.set(pb.EmailAddress, "payload.Email");
3156
- }
3157
- catch {
3158
- /* skip */
3159
- }
3160
- }
3161
- base64ContentHelper = emitBuildBase64ContentFunction(b64, personaValues, qMapping, pascal);
3162
- base64PatchOverride.set(lastPatchWithContent.varName,
3163
- // biome-ignore lint/suspicious/noTemplateCurlyInString: emitted as generated template-literal source
3164
- '"Content":"${buildBase64Content(payload, questionnaireId, Number(draftId), String(attachmentId))}"');
3165
- const draftPostStep = actionSteps.find((s) => s.capture.method === "POST" &&
3166
- s.capture.url.includes("recruitingCEJobApplicationDrafts"));
3167
- if (draftPostStep && !draftPostStep.produces.some((p) => p.name === "draftId")) {
3168
- draftPostStep.produces.push({
3169
- kind: "body",
3170
- name: "draftId",
3171
- path: ["APPDraftId"],
3172
- });
3173
- }
3174
- const attachPostStep = actionSteps.find((s) => s.capture.method === "POST" && s.capture.url.includes("/attachments"));
3175
- if (attachPostStep && !attachPostStep.produces.some((p) => p.name === "attachmentId")) {
3176
- attachPostStep.produces.push({
3177
- kind: "body",
3178
- name: "attachmentId",
3179
- path: ["Id"],
3180
- });
3181
- }
3182
- const questionnaireCapture = captures.find((c) => c.method === "GET" &&
3183
- c.url.includes("recruitingCEQuestions") &&
3184
- c.url.includes("expand=answers"));
3185
- let sampleQid;
3186
- if (questionnaireCapture) {
3187
- const qResp = typeof questionnaireCapture.responseBody === "string"
3188
- ? JSON.parse(questionnaireCapture.responseBody)
3189
- : questionnaireCapture.responseBody;
3190
- sampleQid = qResp?.items?.[0]?.QuestionnaireId ?? undefined;
3191
- }
3192
- const overrideValue = base64PatchOverride.values().next().value;
3193
- if (overrideValue) {
3194
- const extraVarLines = [];
3195
- if (sampleQid) {
3196
- extraVarLines.push(` const questionnaireId = ${sampleQid};`);
3197
- }
3198
- if (!attachPostStep) {
3199
- extraVarLines.push(` const attachmentId = "";`);
3200
- }
3201
- if (extraVarLines.length > 0) {
3202
- base64PatchOverride.set("__EXTRA_VARS__", extraVarLines.join("\n"));
3203
- }
3204
- }
3205
- const answersFields = (qMapping ?? []).map((m) => m.payloadField);
3206
- answersFields.push("SignatureFullName");
3207
- const answersSchemaFields = answersFields.map((f) => ` ${f}: z.string(),`).join("\n");
3208
- base64ContentHelper = `\nconst AnswersSchema = z.object({\n${answersSchemaFields}\n});\n${base64ContentHelper}`;
3209
- const inputKeys = new Set();
3210
- if (inputBody && typeof inputBody === "object" && !Array.isArray(inputBody)) {
3211
- for (const k of Object.keys(inputBody))
3212
- inputKeys.add(k);
3213
- }
3214
- for (const fld of ["FirstName", "LastName", "Email", "Phone"]) {
3215
- if (!inputKeys.has(fld))
3216
- discoveredAdditionalBodyKeys.set(fld, "string");
3217
- }
3218
- }
3219
- }
3220
- }
3221
- const processedMultiStepBody = isSubmissionFlow
3222
- ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, base64PatchOverride, formSchema)
3223
- : multiStepBody;
3224
3949
  const hasMultipartStep = actionSteps.some((s) => s.isMultipart);
3225
3950
  const headerBindings = collectHeaderBindings(actionSteps);
3226
3951
  // Shape inference targets the SAME call executeHttp returns — see
@@ -3274,8 +3999,7 @@ async function main() {
3274
3999
  gqlQuery,
3275
4000
  endpointPath,
3276
4001
  auxFiles,
3277
- multiStepBody: processedMultiStepBody,
3278
- base64ContentHelper,
4002
+ multiStepBody,
3279
4003
  inputBody,
3280
4004
  hasMultipartStep,
3281
4005
  discoveredFormFields,
@@ -3283,6 +4007,7 @@ async function main() {
3283
4007
  discoveredOptionFields,
3284
4008
  discoveredRawOptionFields,
3285
4009
  discoveredAdditionalBodyKeys,
4010
+ discoveredStructuredKeys,
3286
4011
  payloadFieldNames: browserFlow.payloadFieldNames,
3287
4012
  headerBindings,
3288
4013
  }));