jsfe 1.15.9 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +1 -0
  2. package/dist/docs/flow-docs.d.ts.map +1 -1
  3. package/dist/docs/flow-docs.js +4 -2
  4. package/dist/forms/address-flows.js +1 -1
  5. package/dist/forms/continuation-compat.d.ts +5 -0
  6. package/dist/forms/continuation-compat.d.ts.map +1 -0
  7. package/dist/forms/continuation-compat.js +45 -0
  8. package/dist/forms/contracts.d.ts.map +1 -1
  9. package/dist/forms/contracts.js +6 -4
  10. package/dist/forms/declarations.d.ts +3 -0
  11. package/dist/forms/declarations.d.ts.map +1 -1
  12. package/dist/forms/declarations.js +26 -4
  13. package/dist/forms/flows.d.ts.map +1 -1
  14. package/dist/forms/flows.js +12 -6
  15. package/dist/forms/identity-flows.d.ts +3 -0
  16. package/dist/forms/identity-flows.d.ts.map +1 -1
  17. package/dist/forms/identity-flows.js +53 -7
  18. package/dist/forms/index.d.ts +1 -0
  19. package/dist/forms/index.js +1 -0
  20. package/dist/helpers/address.d.ts +1 -0
  21. package/dist/helpers/address.d.ts.map +1 -1
  22. package/dist/helpers/address.js +4 -1
  23. package/dist/helpers/form-actions.js +1 -1
  24. package/dist/helpers/index.d.ts +2 -1
  25. package/dist/helpers/index.d.ts.map +1 -1
  26. package/dist/helpers/index.js +2 -2
  27. package/dist/helpers/otp-check.d.ts +9 -3
  28. package/dist/helpers/otp-check.d.ts.map +1 -1
  29. package/dist/helpers/otp-check.js +20 -10
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +7 -1
  33. package/dist/runtime-identity.js +1 -1
  34. package/docs/architecture-guide.md +366 -0
  35. package/docs/copilot-reference.md +2 -4
  36. package/docs/development-release.md +7 -1
  37. package/package.json +2 -1
package/README.md CHANGED
@@ -12,6 +12,7 @@ npm i jsfe
12
12
  - **[JavaScript Flow Engine User Guide](user-guide.md)**
13
13
  - Comprehensive tutorials, examples, and best practices
14
14
  - **[README.md](README.md)** - Technical API reference (this document)
15
+ - **[Flows architecture guide](docs/architecture-guide.md)** - start here: the concepts, the building blocks, what the caller experiences, and how conversational AI and exact transactional flows work together (the reply ranker and DISPATCH)
15
16
  - **[Flow author reference](docs/copilot-reference.md)** - the authoritative engine and JSON authoring specification for flow authors and AI flow generators (shipped in the package; the Flow Designer's CoPilot reads it from the published version, so change it with the engine)
16
17
  - **[Development candidate acceptance](docs/development-release.md)** - reproducible package and cross-repository verification
17
18
  - **[System Flows](docs/flows/system/README.md)** and **[Shopify Flows](docs/flows/shopify/README.md)** - the bundled flow and tool libraries in `flows/`: a page and a chart per flow, generated from the JSON (`node scripts/flow-docs.mjs`), plus the conventions the system flows share
@@ -1 +1 @@
1
- {"version":3,"file":"flow-docs.d.ts","sourceRoot":"","sources":["../../src/docs/flow-docs.js"],"names":[],"mappings":"AAiBA;;;;;;;;;;;EAqRC"}
1
+ {"version":3,"file":"flow-docs.d.ts","sourceRoot":"","sources":["../../src/docs/flow-docs.js"],"names":[],"mappings":"AAkBA;;;;;;;;;;;EAsRC"}
@@ -14,8 +14,10 @@
14
14
  */
15
15
  import fs from 'fs';
16
16
  import path from 'path';
17
+ import { resolveSetDataReferences } from '../forms/declarations.js';
17
18
  export function generateFlowDocs({ sets, root, check = false, hostRun = {}, log = console.log }) {
18
- const SETS = sets;
19
+ // A catalog or value written as {"$data": path} is documented as compilation resolves it.
20
+ const SETS = sets.map((s) => ({ ...s, flows: resolveSetDataReferences(s.flows) }));
19
21
  // Which set holds a flow id; a page links to a flow in its own set, or in another set's folder.
20
22
  const setOf = new Map(), flowOf = new Map();
21
23
  for (const s of SETS) {
@@ -422,7 +424,7 @@ export function generateFlowDocs({ sets, root, check = false, hostRun = {}, log
422
424
  }
423
425
  else { /* not a compiled form */ }
424
426
  if (flow.performs) {
425
- out.push('', '### Performs', '', ...flow.performs.map((a) => `- \`${a.action}\` (${a.effect}): ${a.reconcile && a.reconcile.policy ? cut(a.reconcile.policy, 300) : ''}`), '', performsChart(flow));
427
+ out.push('', '### Performs', '', ...flow.performs.map((a) => `- \`${a.action}\` (${a.effect})`), '', performsChart(flow));
426
428
  }
427
429
  else { /* not a performer */ }
428
430
  if (flow.call) {
@@ -28,7 +28,7 @@ const street = C('address-street-validate', 'Validate a proposed street against
28
28
  W('address_street_malformed', "!/^\\d{1,6}\\s+[A-Za-z\\xC0-\\xFF]/.test(String(value))", S('address_street_invalid', 'noop', "(cargo.inputCheck={ok:false,message:'Please give the street number and street name.',message_es:'Indique el número y nombre de la calle.'},true)")), W('address_street_stop', "!/^\\d{1,6}\\s+[A-Za-z\\xC0-\\xFF]/.test(String(value))", { id: 'address_street_invalid_end', type: 'END' }),
29
29
  S('address_street_components', 'components', "'postal_code:'+values.zip+'|country:US'"), { id: 'address_street_tool', type: 'CALL-TOOL', tool: 'geocode-street', variable: 'result', args: { address: '{{value}}', components: '{{components}}' }, onFail: S('address_street_failed', 'result', 'null') },
30
30
  S('address_street_projection', 'location', E(streetLocation, 'result', 'value', 'values.zip')),
31
- S('address_street_result', 'noop', "(cargo.forms[form].data.street=location, cargo.inputCheck={ok:true,value:String(value)}, true)")
31
+ S('address_street_result', 'noop', "(cargo.forms[form].data.street=location, location.city&&cargo.forms[form].accepted&&cargo.forms[form].accepted.city&&cargo.forms[form].accepted.city.origin==='derived'&&cargo.forms[form].values.city!==location.city?(cargo.forms[form].values.city=location.city):null, cargo.inputCheck={ok:true,value:String(value)}, true)")
32
32
  ], { parameters: [P('form'), P('value'), P('values', 'object')], variables: V({ components: '', result: null, location: null }), contract: { tools: ['geocode-street'], result: { path: 'cargo.inputCheck', type: 'object' } } });
33
33
  const zipPrompt = C('address-zip-prompt', 'Render the ZIP retry wording from the owned address instance.', [S('address_zip_prompt', 'noop', "(cargo.forms[form].data.zip_question=(cargo.forms[form].data.retry ? \"Let's try again. \" : '')+'What is the 5-digit zip code of your new address?',cargo.forms[form].data.zip_question_es=(cargo.forms[form].data.retry?'Intentémoslo otra vez. ':'')+'¿Cuál es el código postal de 5 dígitos de su nueva dirección?',true)")], { parameters: [P('form')], contract: { result: { path: 'cargo.forms[form].data', type: 'object' } } });
34
34
  const streetPrompt = C('address-street-prompt', 'Include the ZIP-derived location when asking for the street.', [S('address_street_prompt', 'noop', "(cargo.forms[form].data.street_question=(cargo.forms[form].data.location?'Got it, '+(cargo.forms[form].values.city?cargo.forms[form].values.city+', '+cargo.forms[form].data.location.state:cargo.forms[form].data.location.line)+'. ':'')+'What is the street address?',cargo.forms[form].data.street_question_es=(cargo.forms[form].data.location?'Entendido, '+(cargo.forms[form].values.city?cargo.forms[form].values.city+', '+cargo.forms[form].data.location.state:cargo.forms[form].data.location.line)+'. ':'')+'¿Cuál es la dirección?',true)")], { parameters: [P('form')], contract: { result: { path: 'cargo.forms[form].data', type: 'object' } } });
@@ -0,0 +1,5 @@
1
+ export function continuationBreaks(previousFlows: any, nextFlows: any): {
2
+ id: any;
3
+ removed: {};
4
+ }[];
5
+ //# sourceMappingURL=continuation-compat.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"continuation-compat.d.ts","sourceRoot":"","sources":["../../src/forms/continuation-compat.js"],"names":[],"mappings":"AAgBA;;;IAgBC"}
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A deploy check: a change that can break a saved session must bump its declaration's major version. A saved session
3
+ * continues across a minor or patch change, so an entry argument or a form input it may still hold must not disappear
4
+ * without a major bump; neither may a shared data or function name without its entry's major bump. Removing a flow needs
5
+ * no bump: a session standing in a flow that no longer exists is retired anyway.
6
+ *
7
+ * continuationBreaks(previousFlows, nextFlows) → [{ id, removed: { parameters?, inputs?, data?, functions? } }]
8
+ * both arrays are the authored declarations (a set's .flows file); ordinary executable flows are not compared.
9
+ * It reads names only: a change of meaning under the same name is still the author's to declare with a major bump.
10
+ */
11
+ import { majorOf } from './declarations.js';
12
+ const parameterNames = parameters => Array.isArray(parameters) ? parameters.map(p => p?.name).filter(Boolean) : Object.keys(parameters || {});
13
+ const inputNames = inputs => (inputs || []).flatMap(input => [input?.name, ...inputNames(input?.any_of)]).filter(Boolean);
14
+ const missing = (before, after) => { const keep = new Set(after); return [...new Set(before)].filter(name => !keep.has(name)); };
15
+ export function continuationBreaks(previousFlows, nextFlows) {
16
+ const next = new Map((nextFlows || []).filter(flow => flow && flow.id).map(flow => [flow.id, flow]));
17
+ const breaks = [];
18
+ for (const before of previousFlows || []) {
19
+ const after = before && next.get(before.id);
20
+ if (!after || before.declarationVersion === undefined || majorOf(before.version) !== majorOf(after.version))
21
+ continue;
22
+ const removed = {};
23
+ if (before.library === 'data') {
24
+ const names = missing(Object.keys(before.data || {}), Object.keys(after.data || {}));
25
+ if (names.length)
26
+ removed.data = names;
27
+ }
28
+ else if (before.library === 'functions') {
29
+ const names = missing(Object.keys(before.functions || {}), Object.keys(after.functions || {}));
30
+ if (names.length)
31
+ removed.functions = names;
32
+ }
33
+ else {
34
+ const parameters = missing(parameterNames(before.parameters), parameterNames(after.parameters));
35
+ if (parameters.length)
36
+ removed.parameters = parameters;
37
+ const inputs = missing(inputNames(before.form?.inputs), inputNames(after.form?.inputs));
38
+ if (inputs.length)
39
+ removed.inputs = inputs;
40
+ }
41
+ if (Object.keys(removed).length)
42
+ breaks.push({ id: before.id, removed });
43
+ }
44
+ return breaks;
45
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"contracts.d.ts","sourceRoot":"","sources":["../../src/forms/contracts.js"],"names":[],"mappings":"AAgBA;;;IAGC;AACD;;;;;EAGC;AACD,qGAAqG;AACrG;;QAwCC;AAqBD;;;QAEC;AAgBD;;;;;;;;;QASC;AACD;;;;QAGC;AACD;;;;;;;;QA8BC;AAED,mHAAmH;AACnH,kDAgBC;AAED,+GAA+G;AAC/G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA2EC"}
1
+ {"version":3,"file":"contracts.d.ts","sourceRoot":"","sources":["../../src/forms/contracts.js"],"names":[],"mappings":"AAgBA;;;IAGC;AACD;;;;;EAGC;AACD,qGAAqG;AACrG;;QAwCC;AAqBD;;;QAEC;AAgBD;;;;;;;;;QASC;AACD;;;;QAGC;AACD;;;;;;;;QA8BC;AAED,mHAAmH;AACnH,kDAgBC;AAED,+GAA+G;AAC/G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA4EC"}
@@ -138,7 +138,7 @@ export function defineProvider({ provides, contract, parameters = [], variables
138
138
  }
139
139
  export function defineForm({ contract, provides, ...spec }) {
140
140
  const flow = formFlow({ ...spec, ...(provides ? { extraVars: { ...spec.extraVars, provides, questions: providerQuestions(provides) } } : {}) });
141
- return declared(flow, { role: 'collector', ...contract, form: { inputs: 'inputs' } });
141
+ return declared(flow, { role: 'collector', ...contract, form: { inputs: 'inputs' }, ...(provides ? { catalog: 'provides' } : {}) });
142
142
  }
143
143
  export function definePerformer({ performs, contract, prepare, present, project = [], provides, ...spec }) {
144
144
  validateActionProjection(project, spec.id);
@@ -170,7 +170,7 @@ export function definePerformer({ performs, contract, prepare, present, project
170
170
  { ...set('receipt_applied', 'noop', 'pf_receipt ? consumeActionReceipt(cargo,pf_receipt) : true'), requireTrue: true },
171
171
  call('present', present, { form: '{{form_instance}}', request: '{{pf_request}}', result: '{{pf_result}}' }),
172
172
  ...(spec.after || [])] });
173
- return declared(flow, { role: 'performer', ...contract, requires: ['guardedActions', ...normalizeHostRequirements(contract?.requires, spec.id)], actions: 'performs', form: { inputs: 'inputs' }, prepares: prepare, presents: present, projects: 'projection' });
173
+ return declared(flow, { role: 'performer', ...contract, requires: ['guardedActions', ...normalizeHostRequirements(contract?.requires, spec.id)], actions: 'performs', form: { inputs: 'inputs' }, prepares: prepare, presents: present, projects: 'projection', ...(provides ? { catalog: 'provides' } : {}) });
174
174
  }
175
175
  /** A registered tool gets one generated adapter with explicit args; no dynamic tool-name interpreter is needed. */
176
176
  export function operationWorkers(tools) {
@@ -278,6 +278,10 @@ export function validateFlowContracts(flows, tools = [], { externalFlows = [], r
278
278
  for (const reference of [provides.gate?.flow, provides.source?.flow])
279
279
  if (reference && !ids.has(reference))
280
280
  fail(flow.id, `unknown provider worker ${reference}`);
281
+ // a FLOW to a name that does not exist is skipped silently at run time, so a menu choice's target is checked here
282
+ for (const choice of provides.menu?.choices || [])
283
+ if (choice?.flow && !ids.has(choice.flow))
284
+ fail(flow.id, `unknown menu worker ${choice.flow}`);
281
285
  }
282
286
  for (const action of contract.performs || flow.variables?.[contract.actions]?.value || []) {
283
287
  if (action.retry?.unknown && !(action.effect === 'send' && action.retry.unknown === 'explicit'))
@@ -292,8 +296,6 @@ export function validateFlowContracts(flows, tools = [], { externalFlows = [], r
292
296
  fail(flow.id, `unknown action worker ${action.flow}`);
293
297
  if (!action.outcomes || !Object.keys(action.outcomes).length)
294
298
  fail(flow.id, `action ${action.action} needs outcomes`);
295
- if (['charge', 'write', 'send', 'cancel'].includes(action.effect) && !action.idempotency && !action.reconcile)
296
- fail(flow.id, `action ${action.action} needs idempotency or reconciliation`);
297
299
  if ((action.effect === 'charge' || action.effect === 'cancel' || action.reversible === false) && action.confirm?.exact !== true)
298
300
  fail(flow.id, `action ${action.action} needs exact confirmation`);
299
301
  }
@@ -1,6 +1,7 @@
1
1
  /** The set's shared data for expressions, read as $data.<name>: references resolved, deep-frozen (one engine serves many
2
2
  * sessions), never copied into session variables. Every $data.<name> an expression reads must exist, checked at load. */
3
3
  export function flowSetData(flows: any, compiledFlows?: any[]): any;
4
+ export function resolveSetDataReferences(flows: any): any;
4
5
  /** Compile only explicitly versioned declarations. Ordinary executable JSON is unchanged.
5
6
  * @param {any[]} flows
6
7
  * @param {{tools?: any[], externalFlows?: any[], validate?: boolean}} options
@@ -20,4 +21,6 @@ export function captureOriginalDeclaration(flow: any): {
20
21
  export function serializeFlowDeclaration(flow: any, { resolvedFlow }?: {
21
22
  resolvedFlow?: any;
22
23
  }): any;
24
+ /** The major of a declared version ("2.4.1" → "2"; absent or unreadable → "0"). */
25
+ export function majorOf(version: any): string;
23
26
  //# sourceMappingURL=declarations.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"declarations.d.ts","sourceRoot":"","sources":["../../src/forms/declarations.js"],"names":[],"mappings":"AAgDA;yHACyH;AACzH,oEAaC;AAmFD;;;;GAIG;AACH,+CAJW,GAAG,EAAE,uCACL;IAAC,KAAK,CAAC,EAAE,GAAG,EAAE,CAAC;IAAC,aAAa,CAAC,EAAE,GAAG,EAAE,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAC,GACxD,GAAG,EAAE,CAmEjB;AAED,sGAAsG;AACtG;;;SAAqK;AAuBrK,6FAA6F;AAC7F;;QAuCC"}
1
+ {"version":3,"file":"declarations.d.ts","sourceRoot":"","sources":["../../src/forms/declarations.js"],"names":[],"mappings":"AAyDA;yHACyH;AACzH,oEAaC;AAID,0DAIC;AAmFD;;;;GAIG;AACH,+CAJW,GAAG,EAAE,uCACL;IAAC,KAAK,CAAC,EAAE,GAAG,EAAE,CAAC;IAAC,aAAa,CAAC,EAAE,GAAG,EAAE,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAC,GACxD,GAAG,EAAE,CAuEjB;AAED,sGAAsG;AACtG;;;SAAqK;AAuBrK,6FAA6F;AAC7F;;QAuCC;AAnSD,mFAAmF;AACnF,8CAA4G"}
@@ -12,6 +12,15 @@ const fail = message => { throw new Error('Flow declaration: ' + message); };
12
12
  function sourceRevision(source) { const text = JSON.stringify(source); const hashes = [2166136261, 2246822507, 3266489909, 668265263]; for (let i = 0; i < text.length; i++)
13
13
  for (let lane = 0; lane < hashes.length; lane++)
14
14
  hashes[lane] = Math.imul(hashes[lane] ^ text.charCodeAt(i), 16777619 + lane * 2) >>> 0; return hashes.map(n => n.toString(16).padStart(8, '0')).join(''); }
15
+ /** The major of a declared version ("2.4.1" → "2"; absent or unreadable → "0"). */
16
+ const majorOf = version => { const m = /^\s*v?(\d+)/i.exec(String(version ?? '')); return m ? String(Number(m[1])) : '0'; };
17
+ /** A declaration's continuation revision: its major version, plus the major of each shared entry it uses ($data, fn.).
18
+ * A minor or patch change keeps the revision, so a saved session continues; a breaking change bumps the major. */
19
+ function majorRevision(declarationVersion, source, libraries) {
20
+ const text = JSON.stringify(source), uses = { data: /\$data\b/.test(text), functions: /(?<![\w$])fn\./.test(text) };
21
+ const shared = libraries.filter(library => uses[library.library]).map(library => library.library + '@' + library.id + ':' + majorOf(library.version)).sort();
22
+ return 'declaration-v' + declarationVersion + ':major:' + majorOf(source.version) + (shared.length ? '/' + shared.join('/') : '');
23
+ }
15
24
  const variableDeclaration = value => value && typeof value === 'object' && !Array.isArray(value) && ['string', 'number', 'integer', 'boolean', 'object', 'array'].includes(value.type) ? value : { type: Array.isArray(value) ? 'array' : value === null || typeof value === 'object' ? 'object' : typeof value, value };
16
25
  const DECLARATION_VERSIONS = new Set([1, 2]);
17
26
  /** A flow set's shared entries: {id, library:'functions', declarationVersion:2, functions} and
@@ -29,7 +38,7 @@ function sharedFunctions(libraries) {
29
38
  fail('a shared functions entry needs an id');
30
39
  if (library.declarationVersion !== 2)
31
40
  fail('shared functions ' + library.id + ' require declarationVersion 2');
32
- const extra = Object.keys(library).filter(key => !['id', 'name', 'library', 'declarationVersion', 'description', 'functions'].includes(key));
41
+ const extra = Object.keys(library).filter(key => !['id', 'name', 'library', 'declarationVersion', 'description', 'version', 'functions'].includes(key));
33
42
  if (extra.length)
34
43
  fail('shared functions ' + library.id + ' cannot declare ' + extra.join(', '));
35
44
  if (!library.functions || typeof library.functions !== 'object' || Array.isArray(library.functions) || !Object.keys(library.functions).length)
@@ -51,7 +60,7 @@ function sharedData(libraries) {
51
60
  fail('a shared data entry needs an id');
52
61
  if (library.declarationVersion !== 2)
53
62
  fail('shared data ' + library.id + ' requires declarationVersion 2');
54
- const extra = Object.keys(library).filter(key => !['id', 'name', 'library', 'declarationVersion', 'description', 'data'].includes(key));
63
+ const extra = Object.keys(library).filter(key => !['id', 'name', 'library', 'declarationVersion', 'description', 'version', 'data'].includes(key));
55
64
  if (extra.length)
56
65
  fail('shared data ' + library.id + ' cannot declare ' + extra.join(', '));
57
66
  if (!library.data || typeof library.data !== 'object' || Array.isArray(library.data) || !Object.keys(library.data).length)
@@ -88,6 +97,15 @@ export function flowSetData(flows, compiledFlows = []) {
88
97
  }
89
98
  return Object.keys(resolved).length ? resolved : undefined;
90
99
  }
100
+ /** A set's version 2 declarations with every {"$data":"path"} replaced by its value, as compilation sees them; the shared
101
+ * data entries are kept. For readers of the declarations (the flow docs); compilation resolves them itself. */
102
+ export { majorOf };
103
+ export function resolveSetDataReferences(flows) {
104
+ const data = sharedData((flows || []).filter(isLibrary));
105
+ if (!Object.keys(data).length)
106
+ return flows;
107
+ return flows.map(flow => flow?.declarationVersion === 2 && !isLibrary(flow) ? resolveDataReferences(flow, data, flow.id) : flow);
108
+ }
91
109
  const isDataReference = value => !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 1 && typeof value.$data === 'string';
92
110
  /** Replace every {"$data":"path"} with a copy of the shared value at that path; an unknown path fails compilation. */
93
111
  function resolveDataReferences(value, data, where, active = new Set()) {
@@ -266,7 +284,11 @@ export function compileFlowDeclarations(flows, { tools, externalFlows = [], vali
266
284
  return false; parameterNames.add(parameter.name); return true; }), ...(rest.parameterInstructions !== undefined ? { parameterInstructions: rest.parameterInstructions } : {}), ...(functions ? { functions } : {}), metadata: { ...flow.metadata, declaration: { version: declarationVersion, source: source.id } } };
267
285
  // Revisions are engine bookkeeping; a stale authored token cannot override source change detection.
268
286
  const { safetyRevision: ignoredRevision, ...revisionSource } = source;
269
- return withSafetyRevision(flow, 'declaration-v' + declarationVersion + ':' + sourceRevision(declarationVersion === 2 && Object.keys(shared).length ? { ...revisionSource, sharedFunctions: shared } : revisionSource));
287
+ const revised = withSafetyRevision(flow, majorRevision(declarationVersion, original, libraries));
288
+ // The earlier revision, a fingerprint of the whole declaration: a session saved under it continues while the declaration
289
+ // is unchanged (and takes the major revision), so moving to major revisions retires no session.
290
+ revised.safetyRevisionCompat = 'declaration-v' + declarationVersion + ':' + sourceRevision(declarationVersion === 2 && Object.keys(shared).length ? { ...revisionSource, sharedFunctions: shared } : revisionSource);
291
+ return revised;
270
292
  });
271
293
  const externalObjects = externalFlows.filter(flow => flow && typeof flow === 'object');
272
294
  const formIds = new Set(flows.filter(flow => flow.declarationVersion !== undefined && flow.form).map(flow => flow.id));
@@ -472,7 +494,7 @@ export function serializeFlowDeclaration(flow, { resolvedFlow = flow } = {}) {
472
494
  else if (spec.variables)
473
495
  result.variables = spec.variables;
474
496
  for (const [key, value] of Object.entries(spec))
475
- if (!sharedKeys.includes(key) && !['extraVars', 'variables', 'helpers', 'safetyRevision'].includes(key) && !(isForm && formKeys.has(key)))
497
+ if (!sharedKeys.includes(key) && !['extraVars', 'variables', 'helpers', 'safetyRevision', 'safetyRevisionCompat'].includes(key) && !(isForm && formKeys.has(key)))
476
498
  result[key] = value;
477
499
  const out = rewrite(result);
478
500
  // Helpers and bodies can reference other explicitly authored expressions as well.
@@ -1 +1 @@
1
- {"version":3,"file":"flows.d.ts","sourceRoot":"","sources":["../../src/forms/flows.js"],"names":[],"mappings":"AAqIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA6DC;AACD;;;GAGG;AACH;;;;;;EAGC;AAgfD,wHAAwH;AACxH,wEAaC;AACD,4FAA4F;AAC5F;;;EAYC;AACD,sGAAsG;AACtG;;;EAOC;AArrBM,6CAAuG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA82B9G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAOG"}
1
+ {"version":3,"file":"flows.d.ts","sourceRoot":"","sources":["../../src/forms/flows.js"],"names":[],"mappings":"AAqIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA6DC;AACD;;;GAGG;AACH;;;;;;EAGC;AAsfD,wHAAwH;AACxH,wEAaC;AACD,4FAA4F;AAC5F;;;EAYC;AACD,sGAAsG;AACtG;;;EAOC;AA3rBM,6CAAuG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAo3B9G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAOG"}
@@ -459,20 +459,26 @@ const readFormChoice = {
459
459
  // form's other choices (a correction) and a person, never YES. Result: cargo.confirmRanked = 'no' | 'changed' | 'none'
460
460
  // (ConfirmForm reads it); `abort` ends every flow; `new_request` goes to intent detection.
461
461
  const readConfirmReply = {
462
- id: 'read-confirm-reply', name: 'ReadConfirmReply', version: '1.2.0', scope: 'isolated', description: 'Rank an unreadable answer to a confirmation: it declines (NO), picks another of the form\'s choices (a correction, read as that input\'s answer), asks for a person, or is asked again; a withdrawal or a new request leaves with saved form progress. Never confirms.',
463
- parameters: [{ name: 'form', type: 'string' }, { name: 'question', type: 'string' }, { name: 'question_es', type: 'string' }],
464
- variables: vars({ question: '', question_es: '', asked: '', ranked: [], reply: null, chain: null, others: [], picked: null, reading: null }),
462
+ id: 'read-confirm-reply', name: 'ReadConfirmReply', version: '1.3.0', scope: 'isolated', description: 'Rank an unreadable answer to a confirmation: it declines (NO), picks another of the form\'s choices (a correction, read as that input\'s answer), says a named field read back is wrong (confirm.named_fields: that input is asked again), asks for a person, or is asked again; a withdrawal or a new request leaves with saved form progress. Never confirms.',
463
+ parameters: [{ name: 'form', type: 'string' }, { name: 'question', type: 'string' }, { name: 'question_es', type: 'string' }, { name: 'named_fields', type: 'boolean' }],
464
+ variables: vars({ question: '', question_es: '', asked: '', ranked: [], reply: null, chain: null, others: [], fields: [], field: null, picked: null, reading: null, named_fields: false }),
465
465
  steps: [SET('confirm_asked', 'asked', "language === 'es' && question_es ? question_es : question"),
466
466
  SET('confirm_chain', 'chain', 'formInputChain(cargo.forms, form, cargo)'),
467
467
  SET('confirm_others', 'others', otherChoicesJS("''", '[]')),
468
- rankedChoices('confirm_ranked_choices', "[{key:'no',label:'no: declines or refuses what the question asks to confirm'}].concat(others.map(function(c){return {key:c.key,label:c.label};}))"),
468
+ // confirm.named_fields: a field read back may be corrected in the caller's own words ('es Long Beach' at an address
469
+ // read back with the ZIP code's city): each named input of this form that holds a value is offered, and a pick asks
470
+ // that input again, so the caller's value replaces the one read back
471
+ SET('confirm_fields', 'fields', "named_fields === true || named_fields === 'true' ? (chain || []).filter(function(i){var v=cargo.forms[form] && cargo.forms[form].values;return i.form===form && !i.confirmation && Array.isArray(i.names) && i.names.length && v && v[i.name]!==undefined && v[i.name]!==null && v[i.name]!=='';}).map(function(i){var l=String(i.label || i.names[0]);return {key:'field:'+i.name,label:'says the '+l+' read back is wrong, or gives a different '+l,owner:{form:i.form,name:i.name}};}) : []"),
472
+ rankedChoices('confirm_ranked_choices', "[{key:'no',label:'no: declines or refuses what the question asks to confirm'}].concat(others.map(function(c){return {key:c.key,label:c.label};})).concat(fields.map(function(c){return {key:c.key,label:c.label};}))"),
469
473
  rankedDispatch('confirm_dispatch', 'confirmation_not_matched'),
470
474
  ...escapeRoutes('confirm_escape'),
471
475
  // a correction picked by the ranker is read as its input's answer: the form changes and is confirmed again, never confirmed
472
476
  SET('confirm_picked', 'picked', "others.filter(function(c){return reply && c.key===reply.key;})[0] || null"),
473
477
  SET('confirm_read', 'reading', "picked ? enrichFormMatches(readFormAnswer(picked.key, chain, picked.owner, picked.key, []), chain, form, [picked.owner.name], picked.owner.name) : null"),
474
478
  WHEN('confirm_change', 'reading && reading.hits.length', FLOW('confirm_change_accept', 'collect-input-accept', 'call', { hits: '{{reading.hits}}' })),
475
- SIDE('confirm_ranked', "(cargo.confirmRanked=reading && reading.hits.length ? 'changed' : reply && reply.key==='no' ? 'no' : 'none',true)")]
479
+ SET('confirm_field', 'field', "fields.filter(function(c){return reply && c.key===reply.key;})[0] || null"),
480
+ WHEN('confirm_field_reset', 'field', SIDE('confirm_field_ask', '(resetFormInputs(cargo, field.owner.form, [field.owner.name]), true)')),
481
+ SIDE('confirm_ranked', "(cargo.confirmRanked=reading && reading.hits.length || field ? 'changed' : reply && reply.key==='no' ? 'no' : 'none',true)")]
476
482
  };
477
483
  const collectContextualAnswer = formFlow({ id: 'collect-contextual-answer', name: 'CollectContextualAnswer', description: 'Collect one open-ended answer using contextual DISPATCH and preserve the caller wording.',
478
484
  parameters: [...['question', 'question_es', 'description', 'context', 'context_es'].map(name => ({ name, type: 'string' })), { name: 'choices', type: 'array' }, { name: 'digits', type: 'object' }],
@@ -597,7 +603,7 @@ const confirmForm = {
597
603
  'condition: cf_named': SET('cf_named_reset', 'cf_result', "(resetFormInputs(cargo, cf_named.form, [cf_named.name]), 'changed')"),
598
604
  "condition: cargo.choice === 'no'": SET('cf_declined', 'cf_result', "'no'"),
599
605
  "condition: Array.isArray(clarifications) && pickChoice(String(cargo.choiceText || '').replace(/[?¿]/g,''),[{key:'clarify',phrases:clarifications,exact:true}],false)==='clarify'": SET('cf_clarification', 'cf_result', "'clarification'"),
600
- 'condition: !cf_must && cf_words > 0': FLOW('cf_elsewhere', 'read-confirm-reply', 'call', { form: '{{form}}', question: '{{cf_prompt}}', question_es: '{{cf_prompt_es}}' }),
606
+ 'condition: !cf_must && cf_words > 0': FLOW('cf_elsewhere', 'read-confirm-reply', 'call', { form: '{{form}}', question: '{{cf_prompt}}', question_es: '{{cf_prompt_es}}', named_fields: '{{named_fields}}' }),
601
607
  default: SET('cf_unclear', 'cf_result', "'unclear'")
602
608
  } },
603
609
  // the ranker's reading of an unread answer: a decline is the NO; an answer that declines nothing is asked again
@@ -5,7 +5,10 @@ export function serviceDigits(text: any): string;
5
5
  export function otpDigits(text: any): string;
6
6
  export function contactPrompt(cargo: any, options: any, callerAllowed: any, partial?: boolean, es?: boolean): string;
7
7
  export function contactError(kind: any, options: any, es?: boolean): string;
8
+ export function otpWhere(cargo: any, es?: boolean): string;
8
9
  export function otpPrompt(cargo: any, es?: boolean): string;
10
+ /** What the caller hears when a second code goes to the account's email: the address masked to its first letter and domain. */
11
+ export function otpAlsoSent(cargo: any, es?: boolean): string;
9
12
  export function emailReadback(address: any, voice: any, es?: boolean): string;
10
13
  /** Reenter the existing owning FORM or its gate without making the owner its own parent. */
11
14
  export function identityResumeContext(forms: any, owner: any, target: any, identifiers?: {}): {
@@ -1 +1 @@
1
- {"version":3,"file":"identity-flows.d.ts","sourceRoot":"","sources":["../../src/forms/identity-flows.js"],"names":[],"mappings":"AA2BA,+GAA+G;AAC/G,oFAOC;AAQD,wFAAwF;AACxF,iDAGC;AACD,6CAMC;AACD,qHAQC;AAMD,4EAKC;AAoFD,4DAKC;AAmHD,8EAGC;AA4BD,4FAA4F;AAC5F;;;;EAOC;AAQD,0BAAmI"}
1
+ {"version":3,"file":"identity-flows.d.ts","sourceRoot":"","sources":["../../src/forms/identity-flows.js"],"names":[],"mappings":"AA2BA,+GAA+G;AAC/G,oFAOC;AAQD,wFAAwF;AACxF,iDAGC;AACD,6CAMC;AACD,qHAQC;AAMD,4EAKC;AAwFD,2DAOC;AACD,4DAOC;AACD,+HAA+H;AAC/H,8DAGC;AA0ID,8EAGC;AA4BD,4FAA4F;AAC5F;;;;EAOC;AAQD,0BAA4I"}
@@ -95,6 +95,9 @@ const contact = worker('get-cell-or-email', 'GetCellOrEmail', 'Identify a suppli
95
95
  when('identity_contact_partial_reset', '!contact_partial', effect('identity_contact_partial_forget', "(cargo.gcePartial='',true)")),
96
96
  set('identity_contact_allowed', 'contact_allowed', "!!cargo.callerId && !callerIsInternational(cargo) && !cargo.callerDeclined && contact_options.caller_on_file !== 'no'"),
97
97
  set('identity_contact_prefill', 'contact_prefill', "String(acct_number || cell_number || email || (!contact_partial && contact_allowed && contact_options.caller_on_file === 'yes' ? cargo.callerId : ''))"),
98
+ // a question answered from what is already known (the caller ID, a contact given) is never spoken: its intro (why the
99
+ // account is needed) leads the verification code question instead (otpPrompt). A spoken question said it (or had none).
100
+ effect('identity_contact_intro_unsaid', "(!contact_prefill ? (cargo.otpIntro='',cargo.otpIntro_es='') : contact_options.intro && !cargo.contactIntroSkip && !contact_options.fromMenu ? (cargo.otpIntro=String(contact_options.intro),cargo.otpIntro_es=String(contact_options.intro_es || contact_options.intro)) : true,true)"),
98
101
  effect('identity_contact_preselected', "(acct_number || cell_number || email ? true : (cargo.callerPreselected=!!(contact_prefill && contact_options.caller_on_file === 'yes')),true)"),
99
102
  set('identity_contact_profiles', 'contact_profiles', "contact_profiles_base.map(function (field) {return field.name === 'account' ? Object.assign({},field,{profile:{kind:'identifier',pattern:contact_options.account_pattern || '(?!)'}}) : field;}).concat(contact_allowed ? [{name:'caller_decision',label:'caller ID choice',label_es:'opción del identificador de llamadas',profile:{kind:'choice',choices:[{key:'yes',phrases:['1','yes','si','sí'],exact:true},{key:'no',phrases:['no','nope','nah'],exact:true}]}}] : [])"),
100
103
  set('identity_contact_keypad', 'contact_keypad', "(function () {var shapes=[{first:'23456789',lengths:[10]},{first:'1',lengths:[11]}].concat(contact_options.account_shape || []);if(contact_partial){var left=[];(contact_options.account_shape || []).forEach(function(s){(s.lengths || []).forEach(function(n){var rest=n-String(cargo.gcePartial).length;if(rest>0 && left.indexOf(rest)<0)left.push(rest);});});return {min:1,max:12,autoSubmitMs:3000,complete:[{first:'0123456789',lengths:left.sort(function(a,b){return a-b;})}]};}return {min:7,max:12,autoSubmitMs:3000,autoSubmitChars:contact_allowed?['1']:[],complete:shapes};})()"),
@@ -148,7 +151,7 @@ const authKeys = ['retry_flow', 'cancel_flow', 'email_validator', 'cell_validato
148
151
  const auth = worker('authenticate-user', 'AuthenticateUser', 'Authenticate a contact using shared FORM collection and one-time-code services.', { parameters: Object.keys(AUTH_DEFAULTS).filter(k => !['contact_valid', 'auth_result', 'auth_contact_error', 'auth_contact_error_es', 'auth_previous_options', 'auth_restart', 'auth_join', 'otp_expiry_ms'].includes(k)).map(k => param(k, k.replace(/_/g, ' '), ['retry_returns', 'retrying', 'declined_caller'].includes(k) ? 'boolean' : 'string')).concat([param('caller_on_file', 'Caller-ID eligibility inherited from the invoking service')]), variables: vars(AUTH_DEFAULTS), contract: { role: 'gate', summary: 'Generic one-time-code identity gate', result: { paths: ['cargo.otpVerified', 'cargo.authRetry'], type: 'object' } }, steps: [
149
152
  set('auth_restart', 'auth_restart', "retrying === true || retrying === 'true'"),
150
153
  set('auth_previous_options', 'auth_previous_options', 'cargo.authOptions ? Object.assign({},cargo.authOptions) : null'),
151
- when('auth_declined_caller', "declined_caller === true || declined_caller === 'true'", effect('auth_decline_state', "(cargo.callerDeclined=true,cargo.callerPreselected=false,cargo.otpHash=null,cargo.contactIntroSkip=true,cargo.callerNamed ? (cargo.displayName='',cargo.displayNameSetBy='flow',cargo.callerNamed=false) : true,true)")),
154
+ when('auth_declined_caller', "declined_caller === true || declined_caller === 'true'", effect('auth_decline_state', "(cargo.callerDeclined=true,cargo.callerPreselected=false,cargo.otpHash=null,cargo.otpAltHash=null,cargo.otpAltTimestamp=null,cargo.contactIntroSkip=true,cargo.callerNamed ? (cargo.displayName='',cargo.displayNameSetBy='flow',cargo.callerNamed=false) : true,true)")),
152
155
  when('auth_already_verified', 'cargo.otpVerified === true', end('auth_verified_end')),
153
156
  ...authKeys.map(key => set('auth_option_' + key, key, `auth_restart && cargo.authOptions ? cargo.authOptions.${key} || ${key === 'retry_returns' ? 'false' : "''"} : ${key}`)),
154
157
  set('auth_default_retry', 'retry_flow', "retry_flow || 'authenticate-user'"), set('auth_default_cancel', 'cancel_flow', "cancel_flow || 'contact-support'"),
@@ -165,22 +168,53 @@ const auth = worker('authenticate-user', 'AuthenticateUser', 'Authenticate a con
165
168
  when('auth_join_challenge', 'auth_join', call('auth_join_collect', 'get-and-validate-otp-code')),
166
169
  when('auth_join_release', 'auth_join', call('auth_join_gate_end', 'task-gate', { form: '{{parent_form}}', gate: 'authenticate-user', active: false })),
167
170
  when('auth_join_return', 'auth_join', end('auth_join_complete')),
168
- effect('auth_challenge_init', "(cargo.otpLateCode='',cargo.otpResends=0,cargo.otpMismatches=0,cargo.otpFormatMisses=0,cargo.otpWordMisses=0,cargo.otp_cell_number=cell_number || '',cargo.otp_email=email || '',true)"),
171
+ effect('auth_challenge_init', "(cargo.otpLateCode='',cargo.otpResends=0,cargo.otpMismatches=0,cargo.otpFormatMisses=0,cargo.otpWordMisses=0,cargo.otp_cell_number=cell_number || '',cargo.otp_email=email || '',cargo.otpAltSent=false,cargo.otpAltHash=null,cargo.otpAltTimestamp=null,true)"),
169
172
  call('auth_send', 'send-otp-code'), when('auth_send_failed', 'cargo.otpSendFailure', call('auth_send_failure', 'otp-send-failed', undefined, 'replace')),
170
173
  call('auth_code', 'get-and-validate-otp-code'),
171
174
  cases('auth_complete', { 'condition: cargo.otpVerified === true': set('auth_ok', 'auth_result', 'true'), 'condition: cargo.authRetry': end('auth_return_retry'), default: otpMenu('auth_retry_code', "I couldn't verify your account. You can try again or use another contact.", 'No pude verificar su cuenta. Puede intentarlo de nuevo o usar otro contacto.', true) }),
172
175
  call('auth_gate_end', 'task-gate', { form: '{{parent_form}}', gate: 'authenticate-user', active: false })
173
176
  ] });
177
+ // Where the code went, for a caller who asks ('¿dónde está?'): the channel and the masked destination, never the code.
178
+ export function otpWhere(cargo, es = false) {
179
+ const number = String(cargo.otp_cell_number || '').replace(/\D/g, '').slice(-4), tail = cargo.voice ? number.split('').join(', ') : number;
180
+ if (cargo.otpSentOn === 'whatsapp')
181
+ return es ? 'Se lo acabo de enviar aquí en este chat.' : 'I just sent it to you here in this chat.';
182
+ const mail = String(cargo.otpAltEmail || ''), at = mail.indexOf('@'), masked = cargo.voice ? (es ? 'que empieza con ' : 'starting with ') + mail.charAt(0).toUpperCase() + (es ? ', arroba ' : ', at ') + mail.slice(at + 1) : mail.charAt(0) + '…@' + mail.slice(at + 1);
183
+ if (cargo.otp_cell_number && cargo.otpAltSent === true)
184
+ return es ? 'Se lo envié por mensaje de texto al celular que termina con ' + tail + ', y también le envié un código a su correo ' + masked + '. Revise sus mensajes de texto y su correo.' : 'I sent it by text message to the cell ending with ' + tail + ', and I also sent a code to your email ' + masked + '. Check your text messages and your email.';
185
+ if (cargo.otp_cell_number)
186
+ return es ? 'Se lo envié por mensaje de texto al celular que termina con ' + tail + '. Revise sus mensajes de texto.' : 'I sent it by text message to the cell ending with ' + tail + '. Check your text messages.';
187
+ return es ? 'Se lo envié por correo electrónico a ' + String(cargo.otp_email || '') + '. Revise su correo.' : 'I sent it by email to ' + String(cargo.otp_email || '') + '. Check your email.';
188
+ }
174
189
  export function otpPrompt(cargo, es = false) {
175
190
  const verb = es ? cargo.verb_es : cargo.verb, number = String(cargo.otp_cell_number || '').replace(/\D/g, '').slice(-4), tail = cargo.voice ? number.split('').join(', ') : number;
176
191
  const where = cargo.otpSentOn === 'whatsapp' ? (es ? 'que le acabo de enviar aquí en este chat' : 'I just sent you here in this chat') : (es ? 'que recibió en ' : 'you received at ') + (cargo.otp_cell_number ? (es ? 'celular que termina con ' : 'cell ending with ') + tail : (es ? 'correo electrónico: ' : 'email: ') + String(cargo.otp_email || ''));
192
+ const mail = String(cargo.otpAltEmail || ''), at = mail.indexOf('@'), also = cargo.otp_cell_number && cargo.otpAltSent === true ? (es ? ' o en su correo ' : ' or at your email ') + (cargo.voice ? (es ? 'que empieza con ' : 'starting with ') + mail.charAt(0).toUpperCase() + (es ? ', arroba ' : ', at ') + mail.slice(at + 1) : mail.charAt(0) + '…@' + mail.slice(at + 1)) : '';
177
193
  const name = cargo.callerPreselected && cargo.callerNamed && cargo.displayName ? cargo.displayName : '';
178
- return (name ? name + (es ? ', por favor' : ', please') : es ? 'Por favor' : 'Please') + ' ' + verb + ' ' + (cargo.voice ? es ? 'o ingrese ' : 'or enter ' : '') + (es ? 'el código de verificación de 6 dígitos ' : 'the 6-digit verification code ') + where + '. ' + (es ? `También puede ${cargo.voice ? 'presionar * o decir' : 'escribir'} SALIR para cancelar.` : `You can also ${cargo.voice ? 'press * or ' : ''}${verb} EXIT to cancel.`) + (cargo.callerPreselected ? (es ? ` Para usar otra cuenta, ${verb} OTRA CUENTA.` : ` To use another account, ${verb} ANOTHER ACCOUNT.`) : '');
194
+ const lead = String((es ? cargo.otpIntro_es : cargo.otpIntro) || '');
195
+ return (lead ? lead + ' ' : '') + (name ? name + (es ? ', por favor' : ', please') : es ? 'Por favor' : 'Please') + ' ' + verb + ' ' + (cargo.voice ? es ? 'o ingrese ' : 'or enter ' : '') + (es ? 'el código de verificación de 6 dígitos ' : 'the 6-digit verification code ') + where + also + '. ' + (es ? `También puede ${cargo.voice ? 'presionar * o decir' : 'escribir'} SALIR para cancelar.` : `You can also ${cargo.voice ? 'press * or ' : ''}${verb} EXIT to cancel.`) + (cargo.callerPreselected ? (es ? ` Para usar otra cuenta, ${verb} OTRA CUENTA.` : ` To use another account, ${verb} ANOTHER ACCOUNT.`) : '');
196
+ }
197
+ /** What the caller hears when a second code goes to the account's email: the address masked to its first letter and domain. */
198
+ export function otpAlsoSent(cargo, es = false) {
199
+ const mail = String(cargo.otpAltEmail || ''), at = mail.indexOf('@'), masked = cargo.voice ? (es ? 'que empieza con ' : 'starting with ') + mail.charAt(0).toUpperCase() + (es ? ', arroba ' : ', at ') + mail.slice(at + 1) : mail.charAt(0) + '…@' + mail.slice(at + 1);
200
+ return es ? 'Muy bien, también le envié un código a su correo ' + masked + '.' : 'Okay, I also sent a code to your email ' + masked + '.';
179
201
  }
202
+ // The caller cannot find the texted code (or asks for another way, or for email): a second code goes to the account's email
203
+ // (cargo.otpAltEmail, set by the tenant's contact step when the code goes to a cell), once per challenge, without a question.
204
+ // The texted code stays valid beside it (send-email-otp additional: otpAltHash). A refused address says where the code went.
205
+ const emailToo = worker('send-otp-email-too', 'SendOtpEmailToo', "Email a second one-time code to the account's email beside the texted one, and say so.", { variables: vars({ alt_hash: '', also_sent: '', also_sent_es: '', otp_where: '', otp_where_es: '' }), contract: { role: 'worker', summary: "Second one-time code to the account's email", tools: ['send-email-otp'] }, steps: [
206
+ { id: 'otp_email_too_send', type: 'CALL-TOOL', tool: 'send-email-otp', variable: 'alt_hash', args: { to: '{{cargo.otpAltEmail}}', container: '{{cargo}}', language: '{{language}}', additional: true }, onFail: set('otp_email_too_failed', 'alt_hash', "''") },
207
+ effect('otp_email_too_state', "(cargo.otpAltSent=/^[0-9a-f]{64}$/.test(String(alt_hash || ''))?true:'failed',true)"),
208
+ set('otp_email_too_said', 'also_sent', expression(otpAlsoSent, 'cargo')), set('otp_email_too_said_es', 'also_sent_es', expression(otpAlsoSent, 'cargo', 'true')),
209
+ set('otp_email_too_where', 'otp_where', expression(otpWhere, 'cargo')), set('otp_email_too_where_es', 'otp_where_es', expression(otpWhere, 'cargo', 'true')),
210
+ cases('otp_email_too_say', { 'condition: cargo.otpAltSent === true': say('otp_email_too_sent', '{{also_sent}}', '{{also_sent_es}}'), default: say('otp_email_too_refused', '{{otp_where}}', '{{otp_where_es}}') })
211
+ ] });
180
212
  const OTP_PROFILES = [{ name: 'code', label: 'verification code', label_es: 'código de verificación', profile: { kind: 'digits', min: 6, max: 6 } }, raw];
181
213
  const code = worker('get-and-validate-otp-code', 'GetAndValidateOtpCode', 'Collect a one-time-code answer through the owning FORM ancestry, preserving protocol commands for validation.', { variables: vars({ otp_code: '', user_choice: '', normalized_otp_code: '', otp_question: '', otp_question_es: '', otp_profiles: OTP_PROFILES }), contract: { role: 'collector', summary: 'One-time-code answer collector using shared FORM matching' }, steps: [
182
214
  when('otp_already_verified', 'cargo.otpVerified === true', end('otp_existing_verification')),
183
215
  set('otp_question', 'otp_question', expression(otpPrompt, 'cargo')), set('otp_question_es', 'otp_question_es', expression(otpPrompt, 'cargo', 'true')),
216
+ // a contact question's unsaid intro (identity_contact_intro_unsaid) leads the first code question only
217
+ effect('otp_intro_said', "(cargo.otpIntro='',cargo.otpIntro_es='',true)"),
184
218
  call('otp_collect', 'collect-service-answer', { profiles: '{{otp_profiles}}', question: '{{otp_question}}', question_es: '{{otp_question_es}}', keypad: { min: 6, max: 6 }, parent_form: '{{cargo.authOptions.parent_form}}', stop_path: 'cargo.otpVerified', context: 'account verification', context_es: 'verificación de cuenta' }),
185
219
  when('otp_verified_by_join', 'cargo.otpVerified === true', end('otp_join_finished')),
186
220
  set('otp_answer', 'otp_code', "String(cargo.serviceAnswer.values.code || cargo.serviceAnswer.raw || '')"), set('otp_choice', 'user_choice', 'otp_code.trim().toLowerCase()'), set('otp_digits', 'normalized_otp_code', expression(otpDigits, 'otp_code')),
@@ -193,6 +227,9 @@ const OTP_WORDS = {
193
227
  other_alone: ['otra', 'otro', 'another', 'other'],
194
228
  email: ['email', 'e-mail', 'e mail', 'emails', 'correo', 'correos', 'correo electrónico', 'correo electronico', 'imeil'],
195
229
  resend: ['resend', 'send again', 'send it again', 'not received', 'never received', 'did not receive', 'didn t receive', 'didn t get', 'haven t received', 'nothing came', 'no code', 'no text', 'no llegó', 'no llego', 'no llega', 'no ha llegado', 'no me llegó', 'no me llego', 'no le llegó', 'no le llego', 'no me ha llegado', 'no recibí', 'no recibi', 'no lo recibí', 'no lo recibi', 'no tengo el código', 'no tengo el codigo', 'no lo tengo', 'no alcancé', 'no alcance', 'mándame el código', 'mandame el codigo', 'envíame el código', 'enviame el codigo', 'reenviar', 'reenvíe', 'reenvie'],
230
+ // the caller cannot find the texted code, or asks for another way to get it or to verify
231
+ cannot_find: ['don t have it', 'i don t have it', 'don t have the code', 'i don t have the code', 'can t find', 'cannot find', 'can not find', 'couldn t find', 'could not find', 'can t find it', 'cannot find it', 'i can t find', 'i cannot find', 'not there', 'isn t there', 'it s not there', 'there s no code', 'no encuentro', 'no lo encuentro', 'no la encuentro', 'no encuentro el código', 'no encuentro el codigo', 'no aparece', 'no hay código', 'no hay codigo'],
232
+ another_way: ['another way', 'different way', 'other way', 'some other way', 'a different way', 'another option', 'other option', 'otra forma', 'otra manera', 'de otra forma', 'de otra manera', 'otra opción', 'otra opcion', 'hay otra forma', 'hay otra manera'],
196
233
  later: ['driving', 'i am driving', 'i m busy', 'im busy', 'not now', 'later', 'call me later', 'call back', 'cant right now', 'can t right now', 'can t now', 'cannot right now', 'manejando', 'conduciendo', 'estoy manejando', 'voy manejando', 'ahora no', 'ahora no puedo', 'no puedo ahora', 'más tarde', 'mas tarde', 'estoy ocupado', 'estoy ocupada'],
197
234
  noPhone: ['don t have that phone', 'don t have that number', 'don t have that phone anymore', 'don t have the phone', 'not my number anymore', 'not my phone anymore', 'that s an old number', 'old number', 'changed my number', 'changed my phone', 'lost my phone', 'no tengo ese teléfono', 'no tengo ese telefono', 'no tengo ese número', 'no tengo ese numero', 'ya no tengo ese', 'ya no tengo el teléfono', 'ya no tengo el telefono', 'ya no es mi número', 'ya no es mi numero', 'número viejo', 'numero viejo', 'cambié de número', 'cambie de numero', 'cambié mi número', 'perdí mi teléfono', 'perdi mi telefono', 'don t have a phone', 'don t have phone', 'don t have my phone', 'don t have a cell', 'don t have the cell', 'don t have my cell', 'don t have a cell phone', 'don t have access to that phone', 'don t have access to the phone', 'no tengo teléfono', 'no tengo telefono', 'no tengo celular', 'no tengo el celular', 'no tengo mi teléfono', 'no tengo mi telefono', 'no tengo mi celular', 'no tengo un teléfono', 'no tengo un telefono', 'no tengo acceso a ese teléfono', 'no tengo acceso a ese telefono']
198
235
  };
@@ -206,24 +243,29 @@ function otpPhoneUnavailable(value) {
206
243
  // What the reply ranker may take at the code question beside the code: the step's offers. Email and another account are
207
244
  // offered only where the step offers them (a code sent by text; a caller-ID preselection).
208
245
  const OTP_REPLY_CHOICES = [{ key: 'email', label: 'use the email on the account instead of the phone' }, { key: 'another_account', label: 'use another account' },
209
- { key: 'resend', label: 'the code did not arrive; send it again' }, { key: 'no_phone', label: 'no longer has that phone or number' }, { key: '__agent', label: 'speak with a person (a live agent)' }];
210
- const validateCode = worker('validate-otp-code', 'ValidateOtpCode', 'Validate a challenge response with explicit bounded retries, alternate-contact handling and tenant callbacks.', { variables: vars({ otp_phrases: OTP_WORDS, no_code_given: false, wants_other_account: false, other_account_given: false, otp_validation_result: false, whatsapp_invite_sent: false, invite_text: '', otp_cancel: 'contact-support', answer_key: '', answer_value: '', otp_reply_choices: OTP_REPLY_CHOICES, otp_offers: [], otp_reply: {} }), contract: { role: 'gate', summary: 'Validate the active one-time challenge', tools: ['validate-otp', 'send-whatsapp-invite'], result: { path: 'cargo.otpVerified', type: 'boolean' } }, steps: [
246
+ { key: 'where', label: 'asks where the code was sent' }, { key: 'cannot_find', label: 'cannot find the code, or it is not there' }, { key: 'another_way', label: 'asks for another way to get the code or to verify' }, { key: 'resend', label: 'the code did not arrive; send it again' }, { key: 'no_phone', label: 'no longer has that phone or number' }, { key: '__agent', label: 'speak with a person (a live agent)' }];
247
+ const validateCode = worker('validate-otp-code', 'ValidateOtpCode', 'Validate a challenge response with explicit bounded retries, alternate-contact handling and tenant callbacks.', { variables: vars({ otp_phrases: OTP_WORDS, email_too: false, no_code_given: false, wants_other_account: false, other_account_given: false, otp_validation_result: false, whatsapp_invite_sent: false, invite_text: '', otp_cancel: 'contact-support', answer_key: '', answer_value: '', otp_reply_choices: OTP_REPLY_CHOICES, otp_offers: [], otp_reply: {}, otp_where: '', otp_where_es: '' }), contract: { role: 'gate', summary: 'Validate the active one-time challenge', tools: ['validate-otp', 'send-whatsapp-invite'], result: { path: 'cargo.otpVerified', type: 'boolean' } }, steps: [
211
248
  set('otp_cancel', 'otp_cancel', "cargo.authCancelFlow || 'contact-support'"),
249
+ set('otp_email_too_ready', 'email_too', "!!(cargo.otp_cell_number && cargo.otpAltSent !== true && cargo.otpAltSent !== 'failed' && validateEmail(String(cargo.otpAltEmail || '')))"),
212
250
  set('otp_reset_no_code', 'no_code_given', 'false'), set('otp_reset_invite', 'whatsapp_invite_sent', 'false'), set('otp_reset_callback', 'answer_key', "''"),
213
251
  when('otp_tenant_code', 'cargo.authOptions && cargo.authOptions.code_validator && normalized_otp_code.length !== 6 && /[0-9]/.test(user_choice)', call('otp_tenant_code_call', '{{cargo.authOptions.code_validator}}', { answer: '{{user_choice}}' })),
214
252
  set('otp_other_given', 'other_account_given', "answer_key === 'another_account' ? (cargo.authOtherAccount=String(answer_value || ''),true) : false"),
215
253
  set('otp_other_requested', 'wants_other_account', 'other_account_given || !!(cargo.callerPreselected && normalized_otp_code.length !== 6 && (matchesChoice(user_choice,otp_phrases.other) || otp_phrases.other_alone.includes(user_choice.replace(/[.,!?¡¿]/g,\'\').trim())))'),
216
254
  when('otp_tenant_answer', 'cargo.authOptions && cargo.authOptions.answer_handler && normalized_otp_code.length !== 6 && !wants_other_account', call('otp_tenant_answer_call', '{{cargo.authOptions.answer_handler}}', { answer: '{{user_choice}}' })),
255
+ set('otp_where_early', 'otp_where', expression(otpWhere, 'cargo')), set('otp_where_early_es', 'otp_where_es', expression(otpWhere, 'cargo', 'true')),
217
256
  cases('otp_response', {
218
257
  'condition: normalized_otp_code.length === 6': { id: 'otp_check_tool', type: 'CALL-TOOL', tool: 'validate-otp', variable: 'otp_validation_result', args: { otp: '{{normalized_otp_code}}', container: '{{cargo}}' }, onFail: call('otp_check_failed', 'get-and-validate-otp-code', undefined, 'replace') },
219
258
  'condition: wants_other_account': call('otp_change_account', 'authenticate-user', { cell_number: '', email: '', retrying: true, declined_caller: true }, 'replace'),
220
259
  // Email asked for at a code sent by text: authentication is an input step, so the contact question is asked again
221
260
  // (without the caller-ID offer) and the caller gives the email; a code sent by email keeps "no me llegó el correo" a resend.
261
+ ['condition: email_too && normalized_otp_code.length !== 6 && (' + 'matchesChoice(user_choice,otp_phrases.resend) || matchesChoice(user_choice,otp_phrases.cannot_find) || matchesChoice(user_choice,otp_phrases.another_way) || matchesChoice(user_choice,otp_phrases.email)' + ')']: call('otp_email_too', 'send-otp-email-too'),
222
262
  'condition: !!cargo.otp_cell_number && normalized_otp_code.length !== 6 && matchesChoice(user_choice,otp_phrases.email)': call('otp_use_email', 'authenticate-user', { cell_number: '', email: '', retrying: true, declined_caller: true }, 'replace'),
223
263
  "condition: isExitRequest(user_choice)": call('otp_exit', '{{otp_cancel}}', undefined, 'reboot'),
224
264
  "condition: isAnswer(user_choice,'agent')": call('otp_agent', 'live-agent-requested', undefined, 'reboot'),
225
265
  "condition: isAnswer(user_choice,'text')": call('otp_text', 'switch-to-text', undefined, 'reboot'),
226
266
  'condition: matchesChoice(user_choice,otp_phrases.resend)': call('otp_resend', 'resend-otp-code', { resend_reason: 'requested' }),
267
+ 'condition: matchesChoice(user_choice,otp_phrases.cannot_find)': say('otp_cannot_find', '{{otp_where}}', '{{otp_where_es}}'),
268
+ 'condition: matchesChoice(user_choice,otp_phrases.another_way)': say('otp_no_other_way', '{{otp_where}} To reach a person, {{cargo.verb}} AGENT.', '{{otp_where_es}} Para hablar con una persona, {{cargo.verb_es}} AGENTE.'),
227
269
  'condition: matchesChoice(user_choice,otp_phrases.later)': say('otp_later', "No problem. Say TEXT and I'll continue with you by message so you can do this when you are free, or {{cargo.verb}} the code whenever you are ready.", 'No hay problema. Diga TEXTO y sigo con usted por mensaje para que lo haga cuando esté libre, o {{cargo.verb_es}} el código cuando pueda.'),
228
270
  'condition: normalized_otp_code.length > 0 && Number(cargo.otpFormatMisses || 0)<2': say('otp_bad_format', "I was expecting a 6-digit code. Let's try again. You can also {{cargo.voice ? 'press * or ' : ''}}{{cargo.verb}} EXIT to cancel.", "Esperaba un código de 6 dígitos. Intentemos de nuevo. También puede {{cargo.voice ? 'presionar * o decir' : 'escribir'}} SALIR para cancelar."),
229
271
  'condition: normalized_otp_code.length > 0': effect('otp_format_cap_next', 'true'),
@@ -243,10 +285,14 @@ const validateCode = worker('validate-otp-code', 'ValidateOtpCode', 'Validate a
243
285
  + ".concat(((typeof $callers !== 'undefined' && $callers && $callers['authenticate-user']) || []).map(function(c){return {key:'usecase:'+c.id,label:'states the reason for the call: '+c.label};}))"
244
286
  + ".concat(((typeof $gateExits !== 'undefined' && $gateExits && $gateExits['authenticate-user']) || []).map(function(a){return {key:'exit:'+a.key,label:a.label,route:'new_request'};}))"),
245
287
  when('otp_off_topic', 'no_code_given', { id: 'otp_reply_rank', type: 'DISPATCH', onResponse: 'continue', responseContext: 'Please say the 6-digit verification code I sent you. (Por favor diga el código de verificación de 6 dígitos que le envié.)', responseChoices: 'otp_offers', responseVariable: 'otp_reply', onNoIntent: 'stay' }),
288
+ set('otp_where', 'otp_where', expression(otpWhere, 'cargo')), set('otp_where_es', 'otp_where_es', expression(otpWhere, 'cargo', 'true')),
246
289
  set('otp_reply_picked', 'no_code_given', "no_code_given && !(otp_reply && otp_reply.key && String(otp_reply.key).indexOf('usecase:') !== 0)"),
247
290
  cases('otp_reply_route', {
291
+ "condition: !no_code_given && email_too && otp_reply && ['email','resend','cannot_find','another_way'].includes(otp_reply.key)": call('otp_ranked_email_too', 'send-otp-email-too'),
248
292
  "condition: !no_code_given && otp_reply && otp_reply.key === 'email'": call('otp_ranked_email', 'authenticate-user', { cell_number: '', email: '', retrying: true, declined_caller: true }, 'replace'),
249
293
  "condition: !no_code_given && otp_reply && otp_reply.key === 'another_account'": call('otp_ranked_account', 'authenticate-user', { cell_number: '', email: '', retrying: true, declined_caller: true }, 'replace'),
294
+ "condition: !no_code_given && otp_reply && (otp_reply.key === 'where' || otp_reply.key === 'cannot_find')": say('otp_ranked_where', '{{otp_where}}', '{{otp_where_es}}'),
295
+ "condition: !no_code_given && otp_reply && otp_reply.key === 'another_way'": say('otp_ranked_no_other_way', '{{otp_where}} To reach a person, {{cargo.verb}} AGENT.', '{{otp_where_es}} Para hablar con una persona, {{cargo.verb_es}} AGENTE.'),
250
296
  "condition: !no_code_given && otp_reply && otp_reply.key === 'resend'": call('otp_ranked_resend', 'resend-otp-code', { resend_reason: 'requested' }),
251
297
  "condition: !no_code_given && otp_reply && otp_reply.key === 'no_phone'": otpMenu('otp_ranked_no_phone', "No problem. Then the code can't reach you there. You can use the email on your account instead, or {{cargo.verb}} AGENT to reach a person.", 'No hay problema. Entonces el código no le puede llegar ahí. Puede usar el correo electrónico de su cuenta, o {{cargo.verb_es}} AGENTE para hablar con una persona.', false),
252
298
  "condition: !no_code_given && otp_reply && otp_reply.key === '__agent'": call('otp_ranked_agent', 'live-agent-requested', undefined, 'reboot'),
@@ -261,7 +307,7 @@ const validateCode = worker('validate-otp-code', 'ValidateOtpCode', 'Validate a
261
307
  when('otp_format_cap', 'normalized_otp_code.length !== 6 && cargo.otpFormatMisses >= 3', otpMenu('otp_format_menu', "I still don't have a 6-digit code. To reach a person, {{cargo.verb}} AGENT.", 'Todavía no tengo un código de 6 dígitos. Para hablar con una persona, {{cargo.verb_es}} AGENTE.', true)),
262
308
  when('otp_format_retry', 'normalized_otp_code.length !== 6', call('otp_collect_again', 'get-and-validate-otp-code', undefined, 'replace')),
263
309
  effect('otp_mismatch_count', "(cargo.otpMismatches=Number(cargo.otpMismatches || 0)+(!cargo.otpVerified && cargo.otpFailure === 'mismatch'?1:0),true)"),
264
- when('otp_mismatch_cap', "!cargo.otpVerified && cargo.otpFailure === 'mismatch' && cargo.otpMismatches >= 3", effect('otp_withdraw', '(cargo.otpHash=null,true)')),
310
+ when('otp_mismatch_cap', "!cargo.otpVerified && cargo.otpFailure === 'mismatch' && cargo.otpMismatches >= 3", effect('otp_withdraw', '(cargo.otpHash=null,cargo.otpAltHash=null,cargo.otpAltTimestamp=null,true)')),
265
311
  cases('otp_verified_result', {
266
312
  'condition: cargo.otpVerified === true': effect('otp_verified_record', '(cargo.otpLateCode=String(normalized_otp_code),true)'),
267
313
  "condition: cargo.otpFailure === 'expired' || cargo.otpFailure === 'missing'": call('otp_refresh', 'resend-otp-code', { resend_reason: '{{cargo.otpFailure}}' }),
@@ -349,4 +395,4 @@ const resume = worker('resume-identity-caller', 'ResumeIdentityCaller', 'Resume
349
395
  steps: [set('identity_resume_context', 'resume_context', expression(identityResumeContext, 'cargo.forms', 'parent_form', 'retry_flow', '{acct_number:acct_number,cell_number:cell_number,email:email}')),
350
396
  call('identity_resume_caller', '{{retry_flow}}', { form_instance: '{{resume_context.form_instance}}', parent_form: '{{resume_context.parent_form}}', result_input: '{{resume_context.result_input}}', gate_options: '{{gate_options}}', cell_number: '{{cell_number}}', email: '{{email}}', acct_number: '{{acct_number}}' }, 'replace')]
351
397
  });
352
- export const FLOWS = [taskGate, resume, service, rawTransport, contact, declined, splitFailed, rejected, auth, code, validateCode, retry, email];
398
+ export const FLOWS = [taskGate, resume, service, rawTransport, contact, declined, splitFailed, rejected, auth, code, validateCode, emailToo, retry, email];
@@ -2,6 +2,7 @@ export const FLOWS: any[];
2
2
  export { FLOWS as IDENTITY_FLOWS } from "./identity-flows.js";
3
3
  export { compileFormPrompts } from "./prompt-compiler.js";
4
4
  export { explainSession } from "./inspection.js";
5
+ export { continuationBreaks } from "./continuation-compat.js";
5
6
  export { inheritFlowDeclaration } from "./declaration-capture.js";
6
7
  export { withSafetyRevision, CONTINUATION_SAFETY_REQUIREMENT } from "./continuation-safety.js";
7
8
  export { FIELD_PRESETS, formFlow, declinedFlow, textValue } from "./flows.js";
@@ -13,4 +13,5 @@ export { addressDraft, zipLocation, streetLocation, resolvedAddress } from '../h
13
13
  export { explainSession } from './inspection.js';
14
14
  export { Form, Provider, Performer, Worker, compileExpression } from './authoring.js';
15
15
  export { compileFlowDeclarations, serializeFlowDeclaration, captureOriginalDeclaration } from './declarations.js';
16
+ export { continuationBreaks } from './continuation-compat.js';
16
17
  export { inheritFlowDeclaration } from './declaration-capture.js';
@@ -16,6 +16,7 @@ export function streetLocation(result: any, said: any, zip: any): {
16
16
  street: string;
17
17
  said: string;
18
18
  verified: boolean;
19
+ city: any;
19
20
  };
20
21
  export function resolvedAddress(values: any, data: any): {
21
22
  street: any;
@@ -1 +1 @@
1
- {"version":3,"file":"address.d.ts","sourceRoot":"","sources":["../../src/helpers/address.js"],"names":[],"mappings":"AAAA,uFAAuF;AACvF;;;;;EAQC;AACD;;;;;SASC;AACD,gDAEC;AACD;;;;EAMC;AACD;;;;;;;;EAIC"}
1
+ {"version":3,"file":"address.d.ts","sourceRoot":"","sources":["../../src/helpers/address.js"],"names":[],"mappings":"AAAA,uFAAuF;AACvF;;;;;EAQC;AACD;;;;;SASC;AACD,gDAEC;AACD;;;;;EASC;AACD;;;;;;;;EAIC"}
@@ -27,7 +27,10 @@ export function streetLocation(result, said, zip) {
27
27
  const part = kind => components.find(c => (c.types || []).includes(kind))?.long_name || '';
28
28
  const types = row.types || [], locationType = row.location_type || row.geometry?.location_type || '';
29
29
  const exact = result?.status === 'OK' && row.partial_match !== true && types.includes('street_address') && locationType === 'ROOFTOP' && part('street_number') && part('route') && (!part('postal_code') || part('postal_code') === zip);
30
- return { street: exact ? part('street_number') + ' ' + part('route') : String(said).trim(), said: String(said).trim(), verified: !!exact };
30
+ // An exact street match names the place its mail carries; it corrects a city read from the ZIP code alone (90805 is
31
+ // Lakewood to the ZIP lookup, while its streets are Long Beach and Lakewood)
32
+ const city = exact ? part('locality') || part('postal_town') || part('sublocality') : '';
33
+ return { street: exact ? part('street_number') + ' ' + part('route') : String(said).trim(), said: String(said).trim(), verified: !!exact, city };
31
34
  }
32
35
  export function resolvedAddress(values, data) {
33
36
  const unit = /^(none|no|ninguno|ninguna|sin|no hay)$/i.test(String(values.unit || '')) ? '' : String(values.unit || '').replace(/^(apt\.?|unit|#)\s*/i, '');
@@ -1,4 +1,4 @@
1
- /** Session-owned action state. Backend idempotency and reconciliation remain explicit parts of the action contract. */
1
+ /** Session-owned action state. */
2
2
  import { nextFormInput, formPath, formInputApplies } from './form-state.js';
3
3
  export function formSignature(value) {
4
4
  return JSON.stringify(value, (_key, v) => v && typeof v === 'object' && !Array.isArray(v)