jsfe 1.37.0 → 1.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -387,7 +387,7 @@ means a reply that picks none of them. A picked choice sets `responseVariable` t
387
387
  `answers` sets `{ key: '' }` unless `responseParameters` are declared too. The request carries the options it offers
388
388
  (`request.options`); a host validates a reply against those, never a fixed list. The form readers use it: an unread
389
389
  answer with any words goes to the ranker with the question's choices (`exact` ones too, since `exact` is how words match
390
- a choice; never the YES of a firm confirmation; a NO always) and the escape every question supports, `__agent` (a person);
390
+ a choice; never the YES of a firm confirmation, which a form's confirmation is when an action it authorizes is `confirm.exact`; a NO always) and the escape every question supports, `__agent` (a person);
391
391
  text is never offered. **Arguments in an unread answer** (jsfe 1.36): the asked input's `rank_parameters` (`[{ name, description, type?, reads? }]`) are extracted when the ranker says the reply answers the question, and a choice's own `parameters` when it is picked (`responseParameters` may name the array variable that holds them). Each value is read by the form's reader as if said alone: `reads` names the input it answers, else it answers the asked question, so that question's rules decide a number's field. Two readings of one input that differ take nothing, and nothing read asks again, as before. A form's `confirm.rank_parameters` do the same at its confirmation (no `reads`: read as a correction there); a value read changes the form, which is confirmed again. A charge or a cancellation is guarded by the action's `confirm: {exact: true}`, not by the choice's
392
392
  `exact`. A confirmation's exhausted attempts go to the `give-up` worker.
393
393
 
@@ -1 +1 @@
1
- {"version":3,"file":"flow-docs.d.ts","sourceRoot":"","sources":["../../src/docs/flow-docs.js"],"names":[],"mappings":"AAkBA;;;;;;;;;;;EA+RC"}
1
+ {"version":3,"file":"flow-docs.d.ts","sourceRoot":"","sources":["../../src/docs/flow-docs.js"],"names":[],"mappings":"AAmBA;;;;;;;;;;;EA+RC"}
@@ -15,6 +15,7 @@
15
15
  import fs from 'fs';
16
16
  import path from 'path';
17
17
  import { resolveSetDataReferences } from '../forms/declarations.js';
18
+ import { confirmationFirmness } from '../forms/flows.js';
18
19
  export function generateFlowDocs({ sets, root, check = false, hostRun = {}, log = console.log }) {
19
20
  // A catalog or value written as {"$data": path} is documented as compilation resolves it.
20
21
  const SETS = sets.map((s) => ({ ...s, flows: resolveSetDataReferences(s.flows) }));
@@ -354,7 +355,7 @@ export function generateFlowDocs({ sets, root, check = false, hostRun = {}, log
354
355
  else { /* no menu */ }
355
356
  return c.done();
356
357
  }
357
- function formChart(fm) {
358
+ function formChart(fm, performs) {
358
359
  const c = builder();
359
360
  if (fm.before && fm.before.length) {
360
361
  c.steps(fm.before);
@@ -365,7 +366,7 @@ export function generateFlowDocs({ sets, root, check = false, hostRun = {}, log
365
366
  c.then({ label: `${i.name}${i.label ? ' (' + i.label + ')' : ''}${extra ? ': ' + extra : ''}`, kind: 'input' });
366
367
  }
367
368
  if (fm.confirm) {
368
- c.then({ label: 'confirm', kind: 'decision' });
369
+ c.then({ label: `confirm (${['firm', 'soft'].includes(fm.confirm?.confirmation) ? fm.confirm.confirmation : confirmationFirmness(performs)})`, kind: 'decision' });
369
370
  }
370
371
  else { /* no confirmation */ }
371
372
  if (fm.after && fm.after.length) {
@@ -414,7 +415,7 @@ export function generateFlowDocs({ sets, root, check = false, hostRun = {}, log
414
415
  }
415
416
  else { /* not a provider */ }
416
417
  if (flow.form) {
417
- out.push('', '### Form', '', formChart(flow.form));
418
+ out.push('', '### Form', '', formChart(flow.form, flow.performs));
418
419
  }
419
420
  else { /* not a form */ }
420
421
  // A form the jsfe SDK compiled (the system set) carries its inputs as a variable; its steps are the engine's run-form loop.
@@ -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;;;;;;;;QA+BC;AAED,mHAAmH;AACnH,kDAgBC;AAED,+GAA+G;AAC/G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA4EC"}
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"}
@@ -5,7 +5,7 @@ import { INPUT_PROPOSALS_REQUIREMENT, hasInputProposals, proposalRuntimeSteps }
5
5
  import { ACTION_TOOLS } from './action-tools.js';
6
6
  import { normalizeHostRequirements } from '../host/capabilities.js';
7
7
  import { validateActionProjection, actionProjectionExpression } from './action-projection.js';
8
- import { formFlow, FIELD_PRESETS } from './flows.js';
8
+ import { formFlow, FIELD_PRESETS, confirmationFirmness } from './flows.js';
9
9
  import { resolveFormInputs } from '../helpers/form-state.js';
10
10
  const ROLES = new Set(['provider', 'performer', 'collector', 'gate', 'router', 'worker']);
11
11
  const EFFECTS = new Set(['read', 'send', 'write', 'charge', 'cancel', 'handoff']);
@@ -137,7 +137,7 @@ export function defineProvider({ provides, contract, parameters = [], variables
137
137
  { id: `${flow.id}_provide`, type: 'FLOW', value: 'provide-data', callType: 'call', parameters: { provides: '{{provides}}', asked: '{{asked}}', reply: '{{userInput}}', ...(gateParameters !== undefined ? { gate_params: '{{provider_gate_arguments}}' } : {}) } }] }, { role: 'provider', ...contract, catalog: 'provides' });
138
138
  }
139
139
  export function defineForm({ contract, provides, ...spec }) {
140
- const flow = formFlow({ ...spec, confirm_soft: false, ...(provides ? { extraVars: { ...spec.extraVars, provides, questions: providerQuestions(provides) } } : {}) });
140
+ const flow = formFlow({ ...spec, confirm_soft: confirmationFirmness([]) === 'soft', ...(provides ? { extraVars: { ...spec.extraVars, provides, questions: providerQuestions(provides) } } : {}) });
141
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 }) {
@@ -158,8 +158,7 @@ export function definePerformer({ performs, contract, prepare, present, project
158
158
  when('bad_request', "!(pf_action === null || pf_action && typeof pf_action === 'object' && pf_action.action) || pf_action !== null && (!pf_request || typeof pf_request !== 'object')", { id: `${spec.id}_prepare_failed`, type: 'FLOW', value: 'give-up', callType: 'reboot', recovery: { version: 1, mode: 'none', reason: 'performer_request_invalid' }, parameters: { context: '{{exit_context}}', context_es: '{{exit_context_es}}' } }),
159
159
  set('proposal', 'pf_record', "pf_action ? prepareFormAction(cargo, form_instance, pf_action, pf_request) : null"),
160
160
  ];
161
- // its confirmation is as firm as what it authorizes: firm when an action is exact (charge, cancel, irreversible), else soft
162
- const flow = formFlow({ ...spec, confirm, confirm_soft: !performs.some(action => action.confirm?.exact), confirmation_condition: "pf_action !== null && pf_action && typeof pf_action === 'object'",
161
+ const flow = formFlow({ ...spec, confirm, confirm_soft: confirmationFirmness(performs) === 'soft', confirmation_condition: "pf_action !== null && pf_action && typeof pf_action === 'object'",
163
162
  extraVars: { ...spec.extraVars, ...(provides ? { provides, questions: providerQuestions(provides) } : {}), performs, projection: project, pf_action: null, pf_request: null, pf_record: null, pf_result: null, pf_receipt: null },
164
163
  before_confirm: confirm ? [...prepareSteps, ...(spec.before_confirm || [])] : spec.before_confirm || [],
165
164
  after: [...(confirm ? [] : prepareSteps),
@@ -2,7 +2,7 @@
2
2
  * The form shell: declare inputs, run the loop, hand the result on. `after` = the form's own steps once complete.
3
3
  * `confirm` ({ question, question_es } — SAY-style templates that may read cargo.forms[<id>].values; build them in
4
4
  * `before_confirm` SET steps when they need computing): the confirmation, asked every time after the loop and
5
- * never an input. It is firm unless `confirm_soft` (definePerformer: no action it authorizes declares `confirm.exact`): a soft
5
+ * never an input. It is soft unless `confirm_soft` is false (confirmationFirmness: firm only for a payment): a soft
6
6
  * confirmation's YES is read as a soft input confirmation's and is offered to the reply ranker; a firm one's is neither. YES runs `after`; NO runs `declined`; a correction given there goes through the form's own matching
7
7
  * and the form runs again (so whatever the correction made missing is asked).
8
8
  */
@@ -13,6 +13,10 @@
13
13
  */
14
14
  /** rank_parameters and a choice's parameters: [{ name, description, type?, reads? }] (reads: the input the value answers). */
15
15
  export function validateRankParameters(id: any, name: any, what: any, list: any): void;
16
+ /** How firm a form's confirmation is: firm ONLY when an action it authorizes is a payment (effect 'charge'); every other
17
+ * confirmation is soft, a plain form's (no action) included (owner 2026-11-09). confirm.exact is separate: it binds the
18
+ * action to the confirmed values, whatever the confirmation's firmness. */
19
+ export function confirmationFirmness(performs: any): "firm" | "soft";
16
20
  export function formFlow({ id, name, version, description, inputs, after, extraVars, parameters, confirm, confirm_soft, before_confirm, declined, stop, close_on_stop, on_no, resume_on_parent_change, before, done, primary, scope, validator, confirmation_condition, prompt, prompt_es, incomplete_outcome, on_exhausted, must_complete, first_task, first_task_es, acknowledge }: {
17
21
  id: any;
18
22
  name: any;
@@ -1 +1 @@
1
- {"version":3,"file":"flows.d.ts","sourceRoot":"","sources":["../../src/forms/flows.js"],"names":[],"mappings":"AA6GA;;;;;;;GAOG;AACH;;;;GAIG;AACH,8HAA8H;AAC9H,uFAIC;AAiBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA+DC;AACD;;;GAGG;AACH;;;;;;EAGC;AAolBD,wHAAwH;AACxH,wEAaC;AACD,4FAA4F;AAC5F;;;EAYC;AACD,sGAAsG;AACtG;;;EAOC;AAtyBM,6CAAuG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+/B9G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAOG"}
1
+ {"version":3,"file":"flows.d.ts","sourceRoot":"","sources":["../../src/forms/flows.js"],"names":[],"mappings":"AA6GA;;;;;;;GAOG;AACH;;;;GAIG;AACH,8HAA8H;AAC9H,uFAIC;AAiBD;;2EAE2E;AAC3E,qEAEC;AACD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAqEC;AACD;;;GAGG;AACH;;;;;;EAGC;AAslBD,wHAAwH;AACxH,wEAaC;AACD,4FAA4F;AAC5F;;;EAYC;AACD,sGAAsG;AACtG;;;EAOC;AApzBM,6CAAuG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6gC9G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAOG"}
@@ -105,7 +105,7 @@ const FIELD_COLLECTORS = { 'collect-phone': 'phone', 'collect-email': 'email', '
105
105
  * The form shell: declare inputs, run the loop, hand the result on. `after` = the form's own steps once complete.
106
106
  * `confirm` ({ question, question_es } — SAY-style templates that may read cargo.forms[<id>].values; build them in
107
107
  * `before_confirm` SET steps when they need computing): the confirmation, asked every time after the loop and
108
- * never an input. It is firm unless `confirm_soft` (definePerformer: no action it authorizes declares `confirm.exact`): a soft
108
+ * never an input. It is soft unless `confirm_soft` is false (confirmationFirmness: firm only for a payment): a soft
109
109
  * confirmation's YES is read as a soft input confirmation's and is offered to the reply ranker; a firm one's is neither. YES runs `after`; NO runs `declined`; a correction given there goes through the form's own matching
110
110
  * and the form runs again (so whatever the correction made missing is asked).
111
111
  */
@@ -145,7 +145,13 @@ function validateAcceptanceDeclarations(id, inputs) {
145
145
  else { /* no invites, or a list of names */ }
146
146
  }
147
147
  }
148
- export function formFlow({ id, name, version, description, inputs, after = [], extraVars = {}, parameters = [], confirm = null, confirm_soft = false, before_confirm = [], declined = [], stop = '', close_on_stop = false, on_no = '', resume_on_parent_change = false, before = [], done = true, primary = false, scope = 'isolated', validator = '', confirmation_condition = 'true', prompt = undefined, prompt_es = undefined, incomplete_outcome = null, on_exhausted = null, must_complete = false, first_task = '', first_task_es = '', acknowledge = 'all' }) {
148
+ /** How firm a form's confirmation is: firm ONLY when an action it authorizes is a payment (effect 'charge'); every other
149
+ * confirmation is soft, a plain form's (no action) included (owner 2026-11-09). confirm.exact is separate: it binds the
150
+ * action to the confirmed values, whatever the confirmation's firmness. */
151
+ export function confirmationFirmness(performs) {
152
+ return Array.isArray(performs) && performs.some((action) => action?.effect === 'charge') ? 'firm' : 'soft';
153
+ }
154
+ export function formFlow({ id, name, version, description, inputs, after = [], extraVars = {}, parameters = [], confirm = null, confirm_soft = confirmationFirmness([]) === 'soft', before_confirm = [], declined = [], stop = '', close_on_stop = false, on_no = '', resume_on_parent_change = false, before = [], done = true, primary = false, scope = 'isolated', validator = '', confirmation_condition = 'true', prompt = undefined, prompt_es = undefined, incomplete_outcome = null, on_exhausted = null, must_complete = false, first_task = '', first_task_es = '', acknowledge = 'all' }) {
149
155
  validateFormExhaustion(on_exhausted);
150
156
  if (acknowledge !== 'all' && acknowledge !== 'data')
151
157
  throw new Error(`FORM ${id}: acknowledge must be 'all' or 'data'`);
@@ -157,6 +163,12 @@ export function formFlow({ id, name, version, description, inputs, after = [], e
157
163
  if (confirm && typeof confirm === 'object')
158
164
  validateRankParameters(id, 'confirm', 'rank_parameters', confirm.rank_parameters);
159
165
  else { /* no confirmation */ }
166
+ // confirm.confirmation ('firm' | 'soft', the attribute inputs declare) is definite when declared; a template ('{{…}}')
167
+ // is resolved at run time (an inferred value's descriptor); absent, confirm_soft (confirmationFirmness) decides.
168
+ if (confirm && typeof confirm === 'object' && confirm.confirmation !== undefined && !['firm', 'soft'].includes(confirm.confirmation) && !String(confirm.confirmation).includes('{{'))
169
+ throw new Error(`FORM ${id}: confirm.confirmation must be "firm" or "soft"`);
170
+ else { /* declared, or derived below */ }
171
+ const confirmFirmness = confirm && typeof confirm === 'object' && confirm.confirmation !== undefined ? confirm.confirmation : (confirm_soft === true ? 'soft' : 'firm');
160
172
  const incompleteFrom = incomplete_outcome && Object.hasOwn(incomplete_outcome, 'from');
161
173
  if (incompleteFrom) {
162
174
  if (Object.keys(incomplete_outcome).length !== 1 || typeof incomplete_outcome.from !== 'string' || !/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(incomplete_outcome.from) || !parameters.some(p => p.name === incomplete_outcome.from.split('.')[0] && p.type === 'object'))
@@ -169,7 +181,8 @@ export function formFlow({ id, name, version, description, inputs, after = [], e
169
181
  // FLOW parameters are paths only: the question is built here, as a SAY-style template, then passed by name
170
182
  SET(`${id}_confirm_q`, 'form_confirm_q', textValue(confirm.question)),
171
183
  SET(`${id}_confirm_q_es`, 'form_confirm_q_es', textValue(confirm.question_es || confirm.question)),
172
- FLOW(`${id}_confirm`, 'confirm-form', 'call', { form: '{{form_instance}}', confirmation: confirm_soft === true ? 'soft' : 'firm', question: '{{form_confirm_q}}', question_es: '{{form_confirm_q_es}}', phrases: '{{form_confirmation.phrases}}', words: '{{form_confirmation.words}}', clarifications: '{{form_confirmation.clarifications}}', no_keys: '{{form_confirmation.no_keys}}', interrupts: '{{form_confirmation.interrupts}}', invites: '{{form_confirmation.invites}}', named_fields: '{{form_confirmation.named_fields}}', reply_context: '{{form_confirmation.reply_context}}', rank_parameters: '{{form_confirmation.rank_parameters}}', exit_context: '{{exit_context}}', exit_context_es: '{{exit_context_es}}' }),
184
+ SET(`${id}_confirm_firmness`, 'form_confirm_firmness', textValue(confirmFirmness)),
185
+ FLOW(`${id}_confirm`, 'confirm-form', 'call', { form: '{{form_instance}}', confirmation: '{{form_confirm_firmness}}', question: '{{form_confirm_q}}', question_es: '{{form_confirm_q_es}}', phrases: '{{form_confirmation.phrases}}', words: '{{form_confirmation.words}}', clarifications: '{{form_confirmation.clarifications}}', no_keys: '{{form_confirmation.no_keys}}', interrupts: '{{form_confirmation.interrupts}}', invites: '{{form_confirmation.invites}}', named_fields: '{{form_confirmation.named_fields}}', reply_context: '{{form_confirmation.reply_context}}', rank_parameters: '{{form_confirmation.rank_parameters}}', exit_context: '{{exit_context}}', exit_context_es: '{{exit_context_es}}' }),
173
186
  { id: `${id}_confirmed`, type: 'CASE', branches: {
174
187
  [`condition: (cargo.formConfirm || {})[form_instance] === 'changed'`]: FLOW(`${id}_again`, id, 'replace', { parent_form: '{{parent_form}}', result_input: '{{result_input}}', form_instance: '{{form_instance}}' }),
175
188
  // NO: the declined flow takes the form's place, so the action below never runs
@@ -179,7 +192,7 @@ export function formFlow({ id, name, version, description, inputs, after = [], e
179
192
  ] : [];
180
193
  return {
181
194
  id, name, version, description, scope, metadata: { formProtocol: 2 }, parameters: [...parameters, { name: 'form_instance', type: 'string', internal: true, description: 'Internal instance to resume' }], ...(primary ? { primary: true } : {}), ...(prompt ? { prompt, prompt_es } : {}),
182
- variables: vars({ form_on_exhausted: on_exhausted, form_on_no: on_no, form_must_complete: must_complete, form_incomplete_outcome: incompleteFrom ? null : incomplete_outcome, form_instance: '', form_internal_resume: false, form_resume_owner: '', form_arguments: {}, form_exhaustion: null, form_values: {}, form_validation: '', initial_readings: [], inputs: resolveFormInputs(inputs, FIELD_PRESETS), parent_form: '', result_input: '', questions: [], provides: null, exit_context: '', exit_context_es: '', form_confirm_q: '', form_confirm_q_es: '', form_confirmation: confirm || {}, form_should_confirm: false, ...extraVars }),
195
+ variables: vars({ form_on_exhausted: on_exhausted, form_on_no: on_no, form_must_complete: must_complete, form_incomplete_outcome: incompleteFrom ? null : incomplete_outcome, form_instance: '', form_internal_resume: false, form_resume_owner: '', form_arguments: {}, form_exhaustion: null, form_values: {}, form_validation: '', initial_readings: [], inputs: resolveFormInputs(inputs, FIELD_PRESETS), parent_form: '', result_input: '', questions: [], provides: null, exit_context: '', exit_context_es: '', form_confirm_q: '', form_confirm_q_es: '', form_confirm_firmness: '', form_confirmation: confirm || {}, form_should_confirm: false, ...extraVars }),
183
196
  steps: [
184
197
  ...contextualResponseRuntimeSteps(id, [], hasFormResponse(inputs) ? [CONTEXTUAL_RESPONSES_REQUIREMENT] : []),
185
198
  ...(hasFormResponse(inputs) ? [{ ...SET(`${id}_response_reader_required`, 'noop', "typeof validateFormResponse === 'function'"), requireTrue: true }] : []),
@@ -679,12 +692,14 @@ const disambiguate = {
679
692
  // ── confirm-form: the exact confirmation of a finished form; a correction is matched against the form chain ───────
680
693
  const confirmForm = {
681
694
  id: 'confirm-form', name: 'ConfirmForm', version: '1.0.0', scope: 'isolated',
682
- description: "Worker: ask a form's confirmation (YES / NO, keys 1 and 2; firm when an action it authorizes is exact; otherwise soft: YES is read as a soft confirmation's and offered to the reply ranker). A correction instead ('no, make it $40') is read against the form chain and stored, and the result is 'changed' so the form runs again; an answer with words it cannot read goes to the reply ranker (read-confirm-reply); an unclear one is asked again, and after two the give-up worker takes over. Result: cargo.formConfirm[form] = 'yes' | 'no' | 'changed'. Params: form, question(_es), exit_context(_es).",
695
+ description: "Worker: ask a form's confirmation (YES / NO, keys 1 and 2; firm only when an action it authorizes is a payment; otherwise soft: YES is read as a soft confirmation's and offered to the reply ranker). A correction instead ('no, make it $40') is read against the form chain and stored, and the result is 'changed' so the form runs again; an answer with words it cannot read goes to the reply ranker (read-confirm-reply); an unclear one is asked again, and after two the give-up worker takes over. Result: cargo.formConfirm[form] = 'yes' | 'no' | 'changed'. Params: form, question(_es), exit_context(_es).",
683
696
  parameters: ['form', 'confirmation', 'question', 'question_es', 'exit_context', 'exit_context_es', 'phrases', 'words', 'interrupts', 'no_keys', 'clarifications', 'invites', 'rank_parameters'].map((n) => ({ name: n, type: ['phrases', 'words', 'interrupts', 'no_keys', 'clarifications', 'invites', 'rank_parameters'].includes(n) ? 'array' : 'string', description: n })).concat([{ name: 'named_fields', type: 'boolean', description: "true: an answer that names one of the form's inputs (its names) without a value re-asks only that input" }]),
684
697
  variables: vars({ confirmation: '', cf_clarify: null, named_fields: false, reply_context: '', rank_parameters: [], cf_must: false, cf_presets: FIELD_PRESETS, cf_chain: [], cf_m: null, cf_current: null, cf_result: '', cf_prompt: '', cf_prompt_es: '', cf_words: 0, cf_tries: 0, phrases: null, words: [], clarifications: [], no_keys: ['2'], interrupts: [], invites: [], cf_choices: [], cf_event: '', cf_amount: false, cf_questions: [], cf_provides: null, cf_skip_gate: '' }),
685
698
  steps: [
686
699
  SIDE('cf_reset', "(cargo.formConfirm = cargo.formConfirm || {}, cargo.formConfirm[form] = '', cargo.confirmRanked = '', true)"),
687
- SET('cf_choices', 'cf_choices', `[{key:'yes',phrases:['1'].concat(Array.isArray(phrases)?phrases:Array.isArray(words)?words:[]).concat(confirmation==='soft'?${JSON.stringify(ANSWER_WORDS.yes)}:[]),exact:confirmation!=='soft'},{key:'no',phrases:(Array.isArray(no_keys)?no_keys:['2']).concat(${JSON.stringify(ANSWER_WORDS.no)}),exact:true}].concat((Array.isArray(interrupts)?interrupts:[]).map(function(event){return {key:'event:'+event.key,phrases:event.phrases ?? event.words,exact:event.exact!==false};}))`),
700
+ // YES matches only as the whole answer, firm or soft (as an input confirmation's): soft widens the words and offers the
701
+ // ranker YES; the flow's own YES phrases inside a longer sentence ("quiero cancelar el club, no la cuenta") never confirm.
702
+ SET('cf_choices', 'cf_choices', `[{key:'yes',phrases:['1'].concat(Array.isArray(phrases)?phrases:Array.isArray(words)?words:[]).concat(confirmation==='soft'?${JSON.stringify(ANSWER_WORDS.yes)}:[]),exact:true},{key:'no',phrases:(Array.isArray(no_keys)?no_keys:['2']).concat(${JSON.stringify(ANSWER_WORDS.no)}),exact:true}].concat((Array.isArray(interrupts)?interrupts:[]).map(function(event){return {key:'event:'+event.key,phrases:event.phrases ?? event.words,exact:event.exact!==false};}))`),
688
703
  SET('cf_initial_chain', 'cf_chain', CHAIN_JS),
689
704
  SET('cf_amount_context', 'cf_amount', "cf_chain.some(function(input){return input.profile && input.profile.kind==='amount';})"),
690
705
  SET('cf_prompt', 'cf_prompt', "formInputPrompt(cargo,form,'__confirm',{question:question,question_es:question_es},{},'en')"),
@@ -747,7 +762,7 @@ const confirmInputProposal = formFlow({ id: 'confirm-input-proposal', name: 'Con
747
762
  { ...SIDE('proposal_bind', '(bindFormProposal(cargo,parent_form,input,form_instance),true)'), requireTrue: true },
748
763
  SET('proposal_confirmation', 'form_confirmation', 'Object.assign({},proposal.descriptor)'),
749
764
  ],
750
- confirm: { question: '{{proposal.descriptor.question}}', question_es: '{{proposal.descriptor.question_es || proposal.descriptor.question}}' },
765
+ confirm: { question: '{{proposal.descriptor.question}}', question_es: '{{proposal.descriptor.question_es || proposal.descriptor.question}}', confirmation: "{{proposal.descriptor.confirmation || 'soft'}}" },
751
766
  after: [{ ...SIDE('proposal_confirmed', "decideFormProposal(cargo,parent_form,input,form_instance,'confirmed')"), requireTrue: true }],
752
767
  declined: [NOOP('proposal_declined_marker')],
753
768
  });
@@ -5,7 +5,7 @@ export { explainSession } from "./inspection.js";
5
5
  export { continuationBreaks } from "./continuation-compat.js";
6
6
  export { inheritFlowDeclaration } from "./declaration-capture.js";
7
7
  export { withSafetyRevision, CONTINUATION_SAFETY_REQUIREMENT } from "./continuation-safety.js";
8
- export { FIELD_PRESETS, formFlow, declinedFlow, textValue } from "./flows.js";
8
+ export { FIELD_PRESETS, formFlow, declinedFlow, textValue, confirmationFirmness } from "./flows.js";
9
9
  export { linkFormSchemas, defineWorker, defineProvider, defineForm, definePerformer, validateFlowContracts, operationWorkers, providerQuestions, providerParameter } from "./contracts.js";
10
10
  export { addressDraft, zipLocation, streetLocation, resolvedAddress } from "../helpers/address.js";
11
11
  export { Form, Provider, Performer, Worker, compileExpression } from "./authoring.js";
@@ -5,7 +5,7 @@ import { FLOWS as FORM_FLOWS } from './flows.js';
5
5
  import { FLOWS as CHOICE_FLOWS } from './choice-flow.js';
6
6
  import { FLOWS as IDENTITY } from './identity-flows.js';
7
7
  export const FLOWS = [...FORM_FLOWS, ...ADDRESS_FLOWS, ...CHOICE_FLOWS, ...IDENTITY].map(flow => withSafetyRevision(flow, 'dispatch-intake-2026-10-22'));
8
- export { FIELD_PRESETS, formFlow, declinedFlow, textValue } from './flows.js';
8
+ export { FIELD_PRESETS, formFlow, declinedFlow, textValue, confirmationFirmness } from './flows.js';
9
9
  export { FLOWS as IDENTITY_FLOWS } from './identity-flows.js';
10
10
  export { linkFormSchemas, defineWorker, defineProvider, defineForm, definePerformer, validateFlowContracts, operationWorkers, providerQuestions, providerParameter } from './contracts.js';
11
11
  export { compileFormPrompts } from './prompt-compiler.js';
@@ -1 +1 @@
1
- {"version":3,"file":"proposal-contract.d.ts","sourceRoot":"","sources":["../../src/helpers/proposal-contract.js"],"names":[],"mappings":"AAMA,uEAcC;AAUD,4DAWC;AACD,gEAIC;AACD,iGAAiG;AACjG,+EASC;AAzDM,+CAAoC"}
1
+ {"version":3,"file":"proposal-contract.d.ts","sourceRoot":"","sources":["../../src/helpers/proposal-contract.js"],"names":[],"mappings":"AAMA,uEAcC;AAWD,4DAWC;AACD,gEAIC;AACD,iGAAiG;AACjG,+EASC;AA1DM,+CAAoC"}
@@ -26,7 +26,7 @@ export function validateProposalData(value, seen = new Set()) {
26
26
  seen.delete(value);
27
27
  return value;
28
28
  }
29
- const confirmationKeys = ['question', 'question_es', 'binding', 'phrases', 'words', 'interrupts', 'no_keys', 'clarifications', 'on_declined'];
29
+ const confirmationKeys = ['question', 'question_es', 'confirmation', 'binding', 'phrases', 'words', 'interrupts', 'no_keys', 'clarifications', 'on_declined'];
30
30
  function confirmation(value, required) {
31
31
  if (required && !string(value.question))
32
32
  fail('needs a nonempty question');
@@ -35,6 +35,8 @@ function confirmation(value, required) {
35
35
  fail(key + ' must be nonempty text');
36
36
  if (value.on_declined !== undefined && !safeName(value.on_declined))
37
37
  fail('on_declined needs a static worker id');
38
+ if (value.confirmation !== undefined && !['firm', 'soft'].includes(value.confirmation))
39
+ fail('confirmation must be "firm" or "soft"');
38
40
  for (const key of ['phrases', 'words', 'no_keys', 'clarifications'])
39
41
  if (value[key] !== undefined && (!Array.isArray(value[key]) || !value[key].every(string)))
40
42
  fail(key + ' must be an array of nonempty phrases');
@@ -1 +1 @@
1
- export const JSFE_RUNTIME_FINGERPRINT = "sha256:f97519aef9f9c5c878c5ef549068d5aa78e092c5ba2a85b99e864841aebe31e8";
1
+ export const JSFE_RUNTIME_FINGERPRINT = "sha256:d01edb5b556d1bc3c8825df569b295adf6b359da2293ce9ce009bab01ea7e2b5";
@@ -339,9 +339,10 @@ it.
339
339
  Some options are **never offered**, so the ranker cannot pick them: the YES of a firm confirmation (an input's, or a form's
340
340
  confirmation of an exact action), a `current_only` choice while another question is asked, and text. `exact` on a choice says how words
341
341
  match it, not what it does, so exact choices are offered. A charge or a cancellation is guarded by `confirm: {exact: true}`
342
- on its action, whose form confirmation offers the ranker NO, corrections and a person: a ranker can understand "no thanks,
343
- maybe later" as NO; it can never produce a YES to a payment. A form whose actions are none of them exact (filing a request)
344
- confirms softly: its YES is read as a soft confirmation's and is offered to the ranker too. Options that cost more when wrong need a higher score (a NO that would cancel what the
342
+ on its action. A form confirmation is firm only when it authorizes a payment (effect `charge`), unless the form declares
343
+ `confirm.confirmation`: a firm one offers the ranker NO, corrections and a person (a ranker can understand "no thanks,
344
+ maybe later" as NO; it can never produce a YES to a payment). Every other confirmation is soft: its YES is read as a
345
+ soft confirmation's and is offered to the ranker too. Options that cost more when wrong need a higher score (a NO that would cancel what the
345
346
  caller was confirming, or a new request that leaves the question unanswered). A reply that clears no option is
346
347
  `answers`, and the question is asked again if nothing usable was in it.
347
348
 
@@ -410,8 +411,9 @@ Cancellation and Concurrency" explains each rule and the production incident beh
410
411
 
411
412
  - **Choices.** A choice has a key, phrases in each language and, on a call, keypad keys. A key matches only as the
412
413
  whole answer. Sensitive choices are `exact`.
413
- - **Confirmations.** `confirmation: "firm"` for consent (a charge, a cancellation, storing a card):
414
- only confirmation words. `"soft"` for an offer ("Would you like a link?"): the wider YES vocabulary.
414
+ - **Confirmations.** One attribute, `confirmation: "firm" | "soft"`, on all three confirmations: an input's, a form's
415
+ `confirm`, and a proposal's policy. Declared, it is definite. Absent: firm only for a payment, soft otherwise (a
416
+ proposal is soft). Firm: only confirmation words. Soft: the wider YES vocabulary, and the reply ranker may pick YES.
415
417
  - **Proposals.** When the flow infers a value ("Is this the card ending in 4242?"), a proposal asks the caller to
416
418
  approve that one value. It never authorizes the final action.
417
419
  - **Questions in the middle of a task.** "When is it due?" asked while paying is answered from the provider's catalog,
@@ -214,7 +214,7 @@ Declarations:
214
214
 
215
215
  | Declaration | Meaning |
216
216
  | --- | --- |
217
- | input `confirmation:"firm"` / `"soft"` | A yes/no decision. Firm (consent to a charge, a cancellation, storing a card): YES only by `CONFIRM_WORDS` or the option's own exact phrases. Soft (an offer): the wider YES vocabulary. NO by the NO vocabulary or the option's own phrases. |
217
+ | `confirmation:"firm"` / `"soft"` (an input, a form's `confirm`, a proposal policy) | A yes/no decision; declared, it is definite; absent, firm only for a payment. Firm: YES only by `CONFIRM_WORDS` or the option's own exact phrases. Soft (an offer): the wider YES vocabulary. NO by the NO vocabulary or the option's own phrases. |
218
218
  | input `invites:[names]`, `form.confirm.invites` | The other inputs this question (or the read-back) also asks for ("NEW CARD, LINK, or a different amount" invites `amount`), in its form or an enclosing one; read as in order while it is asked. |
219
219
  | profile `out_of_order:{…}` | A narrower signature for out-of-order reading: the payment card takes a bare last four when asked, but out of order only a card word or "ending in 4242". |
220
220
  | `authenticate-user` `other_whole_answer:true` | At the code question "another account" switches only as the whole answer; inside a sentence the reply ranker decides, offered `another_account` and `no_other_account` ("I understand. We still need to secure access to your account.", then the question). Default off. |
@@ -347,7 +347,7 @@ A Performer requires `form`, nonempty `performs`, `prepare` worker and `present`
347
347
  | `subject` | Ownership path such as `form.data.account` or `form.data.subject`; bind account/service identity, not an unrelated session flag. |
348
348
  | `gate:{flow?,verified:[paths]}` | Execution-time verification; rerun the declared flow before each fresh effect. |
349
349
  | `flow`, `tool` | Installed executor worker and/or registered tool identity. |
350
- | `confirm:{exact:true}` | Required for charge, cancel or `reversible:false`. This requires a real form confirmation question. |
350
+ | `confirm:{exact:true}` | Required for charge, cancel or `reversible:false`. This requires a real form confirmation question, and makes it firm (YES only by `CONFIRM_WORDS` or its own exact phrases, never offered to the reply ranker). A performer none of whose actions is exact (filing a request) confirms softly: the wider YES vocabulary, and YES is offered to the ranker. There is no separate attribute for this. |
351
351
  | `outcomes` | Map declared executor statuses to business outcomes, including unknown results. |
352
352
  | `retry` | Explicit policies: `after:[knownStatuses]`; unknown retry only for SEND with `unknown:"explicit"` and a new accepted recovery choice. |
353
353
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsfe",
3
- "version": "1.37.0",
3
+ "version": "1.38.0",
4
4
  "description": "TypeScript workflow engine for conversational/automation apps: multi-flow orchestration, interrupt/resume, stack-based control, robust errors, and flexible tool/REST integration.",
5
5
  "license": "MIT",
6
6
  "author": "Ron Pinkas",