flowspec2 1.0.0__py3-none-any.whl

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 (92) hide show
  1. flowspec2/__init__.py +105 -0
  2. flowspec2/authoring/__init__.py +275 -0
  3. flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
  4. flowspec2/authoring/authoring-evidence.schema.json +455 -0
  5. flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
  6. flowspec2/authoring/benchmark.py +1409 -0
  7. flowspec2/authoring/corpus/await_correction.case.json +220 -0
  8. flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
  9. flowspec2/authoring/corpus/gated_derive.case.json +100 -0
  10. flowspec2/authoring/corpus/linear.case.json +55 -0
  11. flowspec2/authoring/corpus/manifest.json +31 -0
  12. flowspec2/authoring/corpus/subflow.case.json +66 -0
  13. flowspec2/authoring/corpus/terminal.case.json +94 -0
  14. flowspec2/authoring/corpus.py +307 -0
  15. flowspec2/authoring/ctk.py +836 -0
  16. flowspec2/authoring/detached_signature.py +120 -0
  17. flowspec2/authoring/evidence.py +311 -0
  18. flowspec2/authoring/evidence_signature.py +187 -0
  19. flowspec2/authoring/evidence_verification.py +229 -0
  20. flowspec2/authoring/gemini.py +215 -0
  21. flowspec2/authoring/operational-corpus.json +61 -0
  22. flowspec2/authoring/operational.py +956 -0
  23. flowspec2/authoring/operational_providers.py +167 -0
  24. flowspec2/authoring/presentation_review.py +1013 -0
  25. flowspec2/authoring/presentation_review_signature.py +305 -0
  26. flowspec2/authoring/projection.py +299 -0
  27. flowspec2/authoring/provider_prompt.py +83 -0
  28. flowspec2/backends/__init__.py +82 -0
  29. flowspec2/backends/http.py +189 -0
  30. flowspec2/checker.py +238 -0
  31. flowspec2/cli.py +789 -0
  32. flowspec2/cli_parser.py +345 -0
  33. flowspec2/clock.py +23 -0
  34. flowspec2/compat/__init__.py +47 -0
  35. flowspec2/compat/models.py +82 -0
  36. flowspec2/compat/open_workflow.py +616 -0
  37. flowspec2/compat/rasa.py +19 -0
  38. flowspec2/compat/rasa_export.py +876 -0
  39. flowspec2/compat/rasa_import.py +992 -0
  40. flowspec2/compat/rasa_shared.py +270 -0
  41. flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
  42. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
  43. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
  44. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
  45. flowspec2/compat/tool_profiles.py +149 -0
  46. flowspec2/compat/yaml.py +147 -0
  47. flowspec2/compiler.py +957 -0
  48. flowspec2/compiler_contracts.py +17 -0
  49. flowspec2/compiler_resume_contracts.py +594 -0
  50. flowspec2/compiler_schema_relations.py +1830 -0
  51. flowspec2/compiler_tool_contracts.py +245 -0
  52. flowspec2/compiler_value_contracts.py +447 -0
  53. flowspec2/derive.py +32 -0
  54. flowspec2/diagnostics.py +94 -0
  55. flowspec2/domains.py +489 -0
  56. flowspec2/experimental/__init__.py +57 -0
  57. flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
  58. flowspec2/experimental/v3-preview-loss-policy.json +161 -0
  59. flowspec2/experimental/v3_lowering.py +928 -0
  60. flowspec2/experimental/v3_preview.py +2460 -0
  61. flowspec2/flowspec-2.schema.json +635 -0
  62. flowspec2/interactive.py +300 -0
  63. flowspec2/ir.py +1459 -0
  64. flowspec2/json_codec.py +120 -0
  65. flowspec2/llm.py +366 -0
  66. flowspec2/models.py +121 -0
  67. flowspec2/nodes.py +1537 -0
  68. flowspec2/observability.py +151 -0
  69. flowspec2/predicates.py +89 -0
  70. flowspec2/profiles.py +118 -0
  71. flowspec2/py.typed +0 -0
  72. flowspec2/runtime.py +1059 -0
  73. flowspec2/schema.py +54 -0
  74. flowspec2/schema_contracts.py +203 -0
  75. flowspec2/semantic_derive_contracts.py +530 -0
  76. flowspec2/semantic_path_contracts.py +528 -0
  77. flowspec2/semantic_predicate_contracts.py +355 -0
  78. flowspec2/semantic_schema_contracts.py +137 -0
  79. flowspec2/semantic_source_contracts.py +21 -0
  80. flowspec2/semantic_state_contracts.py +450 -0
  81. flowspec2/semantic_support.py +40 -0
  82. flowspec2/semantics.py +410 -0
  83. flowspec2/state_migration.py +1034 -0
  84. flowspec2/subflows/__init__.py +1117 -0
  85. flowspec2/subflows/address.py +328 -0
  86. flowspec2/subflows/identification.py +1031 -0
  87. flowspec2/tools.py +1154 -0
  88. flowspec2-1.0.0.dist-info/METADATA +482 -0
  89. flowspec2-1.0.0.dist-info/RECORD +92 -0
  90. flowspec2-1.0.0.dist-info/WHEEL +4 -0
  91. flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
  92. flowspec2-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1031 @@
1
+ """identification@2 — method select → gov.br (await_external) | Brazilian tax ID → email → name.
2
+
3
+ Exposes the ``brazilian_tax_id``/``email``/``name`` slots. gov.br is modelled as an
4
+ out-of-band suspend/resume: the first visit emits the login affordance and
5
+ pauses; a later turn carrying a ``govbr_token`` resumes and populates the slots.
6
+ Refusal/abort (when identification is optional) routes to anonymous. Every
7
+ collection leg caps re-asks at ``max_attempts`` and dispatches the configured
8
+ ``on_exhaust`` policy.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import copy
14
+ import logging
15
+ from collections.abc import Mapping
16
+ from typing import Any, Final, Literal, Optional, cast
17
+
18
+ from langgraph.graph import END as LANGGRAPH_END
19
+
20
+ from ..domains import normalize_text
21
+ from ..models import CORRECTION_REQUESTED_INTERNAL_KEY, AgentResponse, ServiceState
22
+ from ..nodes import (
23
+ NEXT,
24
+ FlowContext,
25
+ NodeDesc,
26
+ clear_cascade,
27
+ inc_attempts,
28
+ make_await_external_node,
29
+ mark_flow_finished,
30
+ reset_attempts,
31
+ )
32
+ from ..observability import log_event
33
+ from . import SubflowBuild
34
+
35
+ _REFUSAL = (
36
+ "anonymous",
37
+ "without identification",
38
+ "do not identify",
39
+ "refuse",
40
+ "none",
41
+ "without brazilian_tax_id",
42
+ "skip",
43
+ )
44
+ END: Final[str] = LANGGRAPH_END
45
+ _EXHAUSTION_MODES = frozenset({"reask", "skip", "default", "handoff", "END"})
46
+ _EXHAUSTED_ERROR = "maximum attempts reached; let us try again"
47
+ _HANDOFF_DESCRIPTION = "I will transfer you to a support agent."
48
+ _END_DESCRIPTION = "I could not complete identification. Try again later."
49
+ _BRAZILIAN_TAX_ID_LOOKUP_DERIVED_MARKERS: Final[dict[str, str]] = {
50
+ "email": "_brazilian_tax_id_lookup_derived:email",
51
+ "name": "_brazilian_tax_id_lookup_derived:name",
52
+ }
53
+ _IdentificationStage = Literal["method", "brazilian_tax_id", "email", "name"]
54
+ _IdentificationMethod = Literal["brazilian_tax_id", "govbr", "anonymous"]
55
+ _ExhaustionMode = Literal["reask", "skip", "default", "handoff", "END"]
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+
60
+ def _is_refusal(text: str) -> bool:
61
+ norm = normalize_text(text)
62
+ return bool(norm) and any(tok in norm for tok in _REFUSAL)
63
+
64
+
65
+ # Exact-token skip for the optional contact slots (email/name/brazilian_tax_id). Exact match —
66
+ # not substring — so a real value like "skipper@x.com" is never read as a skip.
67
+ _SKIP_TOKENS = {"skip", "no", "none", "pass", "next", "later"}
68
+
69
+
70
+ def _is_skip(text: str) -> bool:
71
+ return normalize_text(text) in _SKIP_TOKENS
72
+
73
+
74
+ def _clear_brazilian_tax_id_lookup_derived_contacts(state: ServiceState, ctx: FlowContext) -> None:
75
+ for contact_slot, marker_key in _BRAZILIAN_TAX_ID_LOOKUP_DERIVED_MARKERS.items():
76
+ if state.internal.pop(marker_key, False):
77
+ clear_cascade(state, contact_slot, ctx)
78
+
79
+
80
+ def _mark_brazilian_tax_id_lookup_derived_contact(state: ServiceState, contact_slot: str) -> None:
81
+ state.internal[_BRAZILIAN_TAX_ID_LOOKUP_DERIVED_MARKERS[contact_slot]] = True
82
+
83
+
84
+ def _clear_brazilian_tax_id_lookup_provenance(state: ServiceState, contact_slot: str) -> None:
85
+ state.internal.pop(_BRAZILIAN_TAX_ID_LOOKUP_DERIVED_MARKERS[contact_slot], None)
86
+
87
+
88
+ def _govbr_validation_failure_response(
89
+ ctx: FlowContext,
90
+ state: ServiceState,
91
+ *,
92
+ tax_id_fallback: bool = True,
93
+ ) -> AgentResponse:
94
+ log_id = log_event(
95
+ logger,
96
+ logging.WARNING,
97
+ "gov.br authentication response could not be validated",
98
+ operation="authenticate_govbr",
99
+ log_id_generator=ctx.log_id_generator,
100
+ context={"flow": state.service_name},
101
+ )
102
+ return AgentResponse(
103
+ description=(
104
+ "I could not validate gov.br. Shall we try the Brazilian tax ID instead?"
105
+ if tax_id_fallback
106
+ else "I could not validate gov.br. Try again."
107
+ ),
108
+ log_id=log_id,
109
+ )
110
+
111
+
112
+ def _normalize_method(raw: str) -> Optional[_IdentificationMethod]:
113
+ norm = normalize_text(raw)
114
+ if "govbr" in norm or "gov.br" in norm or norm == "gov" or "gov br" in norm:
115
+ return "govbr"
116
+ if "brazilian_tax_id" in norm:
117
+ return "brazilian_tax_id"
118
+ if _is_refusal(norm) or norm == "anonymous":
119
+ return "anonymous"
120
+ return None
121
+
122
+
123
+ async def _call_optional_identification_tool(
124
+ ctx: FlowContext,
125
+ tool_name: str,
126
+ **tool_inputs: Any,
127
+ ) -> Optional[dict[str, Any]]:
128
+ """Call a subflow enrichment whose contract is explicitly best-effort."""
129
+ try:
130
+ tool_result = await ctx.tools.call(tool_name, **tool_inputs)
131
+ if not isinstance(tool_result, Mapping):
132
+ raise TypeError(f"tool {tool_name!r} returned a non-object result")
133
+ return dict(tool_result)
134
+ except Exception:
135
+ log_event(
136
+ logger,
137
+ logging.WARNING,
138
+ "Optional identification enrichment failed",
139
+ operation="identification",
140
+ log_id_generator=ctx.log_id_generator,
141
+ context={"tool": tool_name},
142
+ exc_info=True,
143
+ )
144
+ return None
145
+
146
+
147
+ class IdentificationSubflow:
148
+ name = "identification"
149
+ major = 2
150
+
151
+ def build(self, ctx: FlowContext, with_cfg: dict[str, Any]) -> SubflowBuild:
152
+ required = bool(with_cfg.get("required", ctx.config.get("identification_required", False)))
153
+ methods: tuple[_IdentificationMethod, ...] = tuple(
154
+ cast(
155
+ list[_IdentificationMethod],
156
+ with_cfg.get("methods", ["brazilian_tax_id", "govbr", "anonymous"]),
157
+ )
158
+ )
159
+ eligible_methods: tuple[_IdentificationMethod, ...] = tuple(
160
+ method for method in methods if method != "anonymous" or not required
161
+ )
162
+ if not eligible_methods:
163
+ raise ValueError(
164
+ "identification@2 requires an eligible method; required identification "
165
+ "must configure brazilian_tax_id or govbr"
166
+ )
167
+ eligible_method_set: frozenset[_IdentificationMethod] = frozenset(eligible_methods)
168
+ max_attempts = int(with_cfg.get("max_attempts", ctx.max_attempts))
169
+ configured_on_exhaust = str(with_cfg.get("on_exhaust", "reask"))
170
+ if max_attempts < 1:
171
+ raise ValueError("identification@2 max_attempts must be positive")
172
+ if configured_on_exhaust not in _EXHAUSTION_MODES:
173
+ raise ValueError(
174
+ f"identification@2 has unsupported on_exhaust mode: {configured_on_exhaust!r}"
175
+ )
176
+ on_exhaust = cast(_ExhaustionMode, configured_on_exhaust)
177
+
178
+ # register the slots this subflow contributes
179
+ ctx.domains.setdefault("BrazilianTaxId", {"type": "brazilian_tax_id"})
180
+ ctx.domains.setdefault("Email", {"type": "email"})
181
+ ctx.domains.setdefault("Name", {"type": "name"})
182
+ ctx.slots.setdefault("brazilian_tax_id", {"domain": "BrazilianTaxId"})
183
+ ctx.slots.setdefault("email", {"domain": "Email"})
184
+ ctx.slots.setdefault("name", {"domain": "Name"})
185
+ ctx.slot_aux["brazilian_tax_id"] = ["identity_verified", "identification_skipped"]
186
+ ctx.slot_aux["email"] = ["email_processed"]
187
+ ctx.slot_aux["name"] = ["name_processed"]
188
+ tax_id_model = ctx.model_for("brazilian_tax_id")
189
+ email_model = ctx.model_for("email")
190
+ name_model = ctx.model_for("name")
191
+
192
+ def set_identification_method(
193
+ state: ServiceState,
194
+ identification_method: _IdentificationMethod,
195
+ ) -> None:
196
+ if identification_method not in eligible_method_set:
197
+ raise ValueError(
198
+ "identification@2 attempted to select disabled method "
199
+ f"{identification_method!r}"
200
+ )
201
+ state.data["identification_method"] = identification_method
202
+ if identification_method == "anonymous":
203
+ state.data["identification_skipped"] = True
204
+ else:
205
+ state.data.pop("identification_skipped", None)
206
+
207
+ def alternative_method(
208
+ state: ServiceState,
209
+ *,
210
+ prefer_anonymous: bool,
211
+ ) -> _IdentificationMethod | None:
212
+ current_method = state.data.get("identification_method")
213
+ if prefer_anonymous and "anonymous" in eligible_method_set:
214
+ return "anonymous"
215
+ return next(
216
+ (method for method in eligible_methods if method != current_method),
217
+ None,
218
+ )
219
+
220
+ def validate_optional_lookup_contact(
221
+ state: ServiceState,
222
+ contact_slot: Literal["email", "name"],
223
+ raw_contact: Any,
224
+ ) -> str | None:
225
+ try:
226
+ validated_contact_model = ctx.model_for(contact_slot).model_validate(
227
+ {contact_slot: raw_contact}
228
+ )
229
+ except Exception: # noqa: BLE001
230
+ log_event(
231
+ logger,
232
+ logging.WARNING,
233
+ "Brazilian tax ID lookup contact failed slot validation",
234
+ operation="brazilian_tax_id_lookup",
235
+ log_id_generator=ctx.log_id_generator,
236
+ context={
237
+ "flow": state.service_name,
238
+ "slot": contact_slot,
239
+ },
240
+ exc_info=True,
241
+ )
242
+ return None
243
+ return cast(str, getattr(validated_contact_model, contact_slot))
244
+
245
+ def complete_without_value(
246
+ state: ServiceState,
247
+ identification_stage: _IdentificationStage,
248
+ *,
249
+ reask_response: AgentResponse,
250
+ ) -> ServiceState:
251
+ reset_attempts(state, identification_stage)
252
+ if identification_stage in {"email", "name"}:
253
+ _clear_brazilian_tax_id_lookup_provenance(state, identification_stage)
254
+ state.data.pop(identification_stage, None)
255
+ state.data[f"{identification_stage}_processed"] = True
256
+ state.agent_response = None
257
+ return state
258
+
259
+ if identification_stage == "brazilian_tax_id":
260
+ _clear_brazilian_tax_id_lookup_derived_contacts(state, ctx)
261
+ state.data.pop("brazilian_tax_id", None)
262
+ fallback_method = alternative_method(state, prefer_anonymous=True)
263
+ if fallback_method is None:
264
+ state.agent_response = reask_response
265
+ return state
266
+ set_identification_method(state, fallback_method)
267
+ state.agent_response = None
268
+ return state
269
+
270
+ def complete_with_default(
271
+ state: ServiceState,
272
+ identification_stage: _IdentificationStage,
273
+ *,
274
+ reask_response: AgentResponse,
275
+ ) -> ServiceState:
276
+ if identification_stage in {"email", "name"}:
277
+ return complete_without_value(
278
+ state,
279
+ identification_stage,
280
+ reask_response=reask_response,
281
+ )
282
+
283
+ reset_attempts(state, identification_stage)
284
+ current_method = state.data.get("identification_method")
285
+ default_method = (
286
+ "anonymous"
287
+ if identification_stage == "brazilian_tax_id" and "anonymous" in eligible_method_set
288
+ else eligible_methods[0]
289
+ )
290
+ if identification_stage == "brazilian_tax_id":
291
+ _clear_brazilian_tax_id_lookup_derived_contacts(state, ctx)
292
+ state.data.pop("brazilian_tax_id", None)
293
+ set_identification_method(state, default_method)
294
+ state.agent_response = reask_response if default_method == current_method else None
295
+ return state
296
+
297
+ def exhaust(
298
+ state: ServiceState,
299
+ *,
300
+ stage: _IdentificationStage,
301
+ reask_response: AgentResponse,
302
+ ) -> ServiceState:
303
+ if on_exhaust == "reask":
304
+ reset_attempts(state, stage)
305
+ state.agent_response = reask_response
306
+ elif on_exhaust == "skip":
307
+ complete_without_value(
308
+ state,
309
+ stage,
310
+ reask_response=reask_response,
311
+ )
312
+ elif on_exhaust == "default":
313
+ complete_with_default(
314
+ state,
315
+ stage,
316
+ reask_response=reask_response,
317
+ )
318
+ elif on_exhaust == "handoff":
319
+ state.agent_response = AgentResponse(description=_HANDOFF_DESCRIPTION)
320
+ else:
321
+ mark_flow_finished(state, reset_next=True)
322
+ state.status = "completed"
323
+ log_id = log_event(
324
+ logger,
325
+ logging.WARNING,
326
+ "Flow stopped after identification attempts were exhausted",
327
+ operation=f"identification:{stage}",
328
+ log_id_generator=ctx.log_id_generator,
329
+ context={"flow": state.service_name, "stage": stage},
330
+ )
331
+ state.agent_response = AgentResponse(
332
+ description=_END_DESCRIPTION,
333
+ log_id=log_id,
334
+ )
335
+ return state
336
+
337
+ def method_response(error_message: str | None = None) -> AgentResponse:
338
+ buttons = [
339
+ {
340
+ "id": method,
341
+ "title": {
342
+ "brazilian_tax_id": "Brazilian tax ID",
343
+ "govbr": "Gov.br",
344
+ "anonymous": "Continue anonymously",
345
+ }[method],
346
+ }
347
+ for method in eligible_methods
348
+ ]
349
+ return AgentResponse(
350
+ description="How would you like to identify yourself?",
351
+ error_message=error_message,
352
+ interactive={
353
+ "body": "How would you like to identify yourself?",
354
+ "field": "identification_method",
355
+ "buttons": buttons,
356
+ },
357
+ )
358
+
359
+ def recover_from_govbr_validation_failure(state: ServiceState) -> ServiceState:
360
+ state.data.pop("govbr_auth_sent", None)
361
+ if "brazilian_tax_id" in eligible_method_set:
362
+ set_identification_method(state, "brazilian_tax_id")
363
+ state.agent_response = _govbr_validation_failure_response(ctx, state)
364
+ return state
365
+
366
+ validation_failure_response = _govbr_validation_failure_response(
367
+ ctx,
368
+ state,
369
+ tax_id_fallback=False,
370
+ )
371
+ if inc_attempts(state, "method") >= max_attempts:
372
+ return exhaust(
373
+ state,
374
+ stage="method",
375
+ reask_response=validation_failure_response,
376
+ )
377
+ state.agent_response = validation_failure_response
378
+ return state
379
+
380
+ def reject_disabled_method(
381
+ state: ServiceState,
382
+ requested_method: _IdentificationMethod,
383
+ ) -> ServiceState:
384
+ state.data.pop("govbr_auth_sent", None)
385
+ unavailable_method_response = method_response(
386
+ f"the method {requested_method!r} is unavailable for this service session"
387
+ )
388
+ if inc_attempts(state, "method") >= max_attempts:
389
+ return exhaust(
390
+ state,
391
+ stage="method",
392
+ reask_response=unavailable_method_response,
393
+ )
394
+ state.agent_response = unavailable_method_response
395
+ return state
396
+
397
+ def reject_unavailable_anonymous_brazilian_tax_id_skip(state: ServiceState) -> ServiceState:
398
+ unavailable_skip_response = AgentResponse(
399
+ description="This service session requires an available identification method.",
400
+ payload_schema=tax_id_model.model_json_schema(),
401
+ error_message="anonymous continuation is not configured",
402
+ )
403
+ if inc_attempts(state, "brazilian_tax_id") >= max_attempts:
404
+ return exhaust(
405
+ state,
406
+ stage="brazilian_tax_id",
407
+ reask_response=unavailable_skip_response,
408
+ )
409
+ state.agent_response = unavailable_skip_response
410
+ return state
411
+
412
+ # ── select method ────────────────────────────────────────────────
413
+ async def select(state: ServiceState) -> ServiceState:
414
+ if (
415
+ state.data.get("identification_method")
416
+ or state.data.get("brazilian_tax_id")
417
+ or state.data.get("govbr_authenticated")
418
+ ):
419
+ state.agent_response = None
420
+ return state
421
+ pay = state.payload or {}
422
+ raw = (
423
+ str(pay.get("identification_method", "")) if "identification_method" in pay else ""
424
+ )
425
+ if not required and not pay and "anonymous" in eligible_method_set:
426
+ set_identification_method(state, "anonymous")
427
+ state.agent_response = None
428
+ return state
429
+ if "identification_method" in pay:
430
+ method = _normalize_method(raw)
431
+ if method in eligible_method_set:
432
+ set_identification_method(state, method)
433
+ reset_attempts(state, "method")
434
+ state.agent_response = None
435
+ return state
436
+ if inc_attempts(state, "method") >= max_attempts:
437
+ return exhaust(
438
+ state,
439
+ stage="method",
440
+ reask_response=method_response(_EXHAUSTED_ERROR),
441
+ )
442
+ state.agent_response = method_response()
443
+ return state
444
+
445
+ def select_router(state: ServiceState) -> str:
446
+ if state.agent_response is not None:
447
+ return END
448
+ method = state.data.get("identification_method")
449
+ if method == "govbr":
450
+ return "authenticate_govbr"
451
+ if method == "brazilian_tax_id":
452
+ return "collect_tax_id"
453
+ return "identification_done" # anonymous / pulada
454
+
455
+ # ── gov.br (await_external) ───────────────────────────────────────
456
+ async def authenticate_legacy(state: ServiceState) -> ServiceState:
457
+ if state.data.get("identification_method") != "govbr" or state.data.get(
458
+ "govbr_authenticated"
459
+ ):
460
+ state.agent_response = None
461
+ return state
462
+ pay = state.payload or {}
463
+ token = pay.get("govbr_token")
464
+ if "govbr_token" in pay:
465
+ try:
466
+ if not isinstance(token, Mapping):
467
+ raise TypeError("gov.br token must be an object")
468
+ if "brazilian_tax_id" not in token:
469
+ raise ValueError("gov.br token did not contain Brazilian tax ID")
470
+ validated_tax_id = cast(
471
+ str,
472
+ tax_id_model.model_validate(
473
+ {"brazilian_tax_id": token["brazilian_tax_id"]}
474
+ ).model_dump()["brazilian_tax_id"],
475
+ )
476
+ identity_writes: dict[str, Any] = {"brazilian_tax_id": validated_tax_id}
477
+ if "name" in token:
478
+ identity_writes["name"] = cast(
479
+ str,
480
+ name_model.model_validate({"name": token["name"]}).model_dump()["name"],
481
+ )
482
+ if "email" in token:
483
+ identity_writes["email"] = cast(
484
+ str,
485
+ email_model.model_validate({"email": token["email"]}).model_dump()[
486
+ "email"
487
+ ],
488
+ )
489
+ except Exception: # noqa: BLE001
490
+ return recover_from_govbr_validation_failure(state)
491
+
492
+ enrich = await _call_optional_identification_tool(
493
+ ctx,
494
+ "get_user_info",
495
+ brazilian_tax_id=validated_tax_id,
496
+ )
497
+ if enrich and enrich.get("phones"):
498
+ identity_writes["phone"] = str(enrich["phones"][0])
499
+ if "name" in identity_writes:
500
+ identity_writes["name_processed"] = True
501
+ if "email" in identity_writes:
502
+ identity_writes["email_processed"] = True
503
+ identity_writes["govbr_authenticated"] = True
504
+ identity_writes["identity_verified"] = True
505
+ state.data.update(identity_writes)
506
+ if "name" in identity_writes:
507
+ _clear_brazilian_tax_id_lookup_provenance(state, "name")
508
+ if "email" in identity_writes:
509
+ _clear_brazilian_tax_id_lookup_provenance(state, "email")
510
+ state.data.pop("govbr_auth_sent", None)
511
+ state.agent_response = None
512
+ return state
513
+ if state.data.get("govbr_auth_sent"):
514
+ text = normalize_text(
515
+ f"{pay.get('message', '')} {pay.get('identification_method', '')}"
516
+ )
517
+ if not required and _is_refusal(text):
518
+ if "anonymous" not in eligible_method_set:
519
+ return reject_disabled_method(state, "anonymous")
520
+ set_identification_method(state, "anonymous")
521
+ state.data.pop("govbr_auth_sent", None)
522
+ state.agent_response = None
523
+ return state
524
+ if "brazilian_tax_id" in text:
525
+ if "brazilian_tax_id" not in eligible_method_set:
526
+ return reject_disabled_method(state, "brazilian_tax_id")
527
+ set_identification_method(state, "brazilian_tax_id")
528
+ state.data.pop("govbr_auth_sent", None)
529
+ state.agent_response = None
530
+ return state
531
+ state.agent_response = AgentResponse(
532
+ description="I am waiting for you to complete the gov.br login. Let me know when you finish. 🙂"
533
+ )
534
+ return state
535
+ state.data["govbr_auth_sent"] = True
536
+ state.agent_response = AgentResponse(
537
+ description="To identify through gov.br, use the login button I sent. 🔐",
538
+ interactive={"out_of_band_sent": True, "next_step": "await_govbr_auth"},
539
+ )
540
+ return state
541
+
542
+ def authenticate_router(state: ServiceState) -> str:
543
+ if state.data.get("govbr_authenticated"):
544
+ if not state.data.get("email") and not state.data.get("email_processed"):
545
+ return "collect_email"
546
+ if not state.data.get("name") and not state.data.get("name_processed"):
547
+ return "collect_name"
548
+ return "identification_done"
549
+ if state.agent_response is not None:
550
+ return END
551
+ method = state.data.get("identification_method")
552
+ if method == "brazilian_tax_id":
553
+ return "collect_tax_id"
554
+ if method == "anonymous" or state.data.get("identification_skipped"):
555
+ return "identification_done"
556
+ return "identification_done"
557
+
558
+ # ── collect Brazilian tax ID ──────────────────────────────────────────────────
559
+ async def collect_tax_id(state: ServiceState) -> ServiceState:
560
+ if state.data.get("govbr_authenticated"):
561
+ state.agent_response = None
562
+ return state
563
+ if state.internal.get(CORRECTION_REQUESTED_INTERNAL_KEY) == "brazilian_tax_id":
564
+ _clear_brazilian_tax_id_lookup_derived_contacts(state, ctx)
565
+ clear_cascade(state, "brazilian_tax_id", ctx)
566
+ state.internal.pop(CORRECTION_REQUESTED_INTERNAL_KEY, None)
567
+ if "brazilian_tax_id" not in eligible_method_set:
568
+ return reject_disabled_method(state, "brazilian_tax_id")
569
+ # Persist the method so the NEXT turn's select node re-enters the
570
+ # subflow instead of short-circuiting to done (the citizen may have
571
+ # been "anonymous" before correcting). correction_requested is consumed
572
+ # this turn, so the flip must outlive it.
573
+ set_identification_method(state, "brazilian_tax_id")
574
+ if state.data.get("brazilian_tax_id") or state.data.get("identification_skipped"):
575
+ state.agent_response = None
576
+ return state
577
+ pay = state.payload or {}
578
+ if "brazilian_tax_id" in pay:
579
+ raw = pay["brazilian_tax_id"]
580
+ if (raw is None or str(raw).strip() == "" or _is_skip(str(raw))) and not required:
581
+ if "anonymous" not in eligible_method_set:
582
+ return reject_unavailable_anonymous_brazilian_tax_id_skip(state)
583
+ return complete_without_value(
584
+ state,
585
+ "brazilian_tax_id",
586
+ reask_response=AgentResponse(
587
+ description="What is your Brazilian tax ID?",
588
+ payload_schema=tax_id_model.model_json_schema(),
589
+ ),
590
+ )
591
+ try:
592
+ validated = tax_id_model.model_validate({"brazilian_tax_id": raw})
593
+ validated_tax_id = cast(str, validated.model_dump()["brazilian_tax_id"])
594
+ except Exception as exc:
595
+ if inc_attempts(state, "brazilian_tax_id") >= max_attempts:
596
+ return exhaust(
597
+ state,
598
+ stage="brazilian_tax_id",
599
+ reask_response=AgentResponse(
600
+ description="I could not validate your Brazilian tax ID. Try again.",
601
+ payload_schema=tax_id_model.model_json_schema(),
602
+ error_message=_EXHAUSTED_ERROR,
603
+ ),
604
+ )
605
+ state.agent_response = AgentResponse(
606
+ description="Invalid Brazilian tax ID. Check and resubmit all 11 digits.",
607
+ payload_schema=tax_id_model.model_json_schema(),
608
+ error_message=str(exc),
609
+ )
610
+ return state
611
+
612
+ state.data["brazilian_tax_id"] = validated_tax_id
613
+ reset_attempts(state, "brazilian_tax_id")
614
+ info = await _call_optional_identification_tool(
615
+ ctx, "brazilian_tax_id_lookup", brazilian_tax_id=validated_tax_id
616
+ )
617
+ if info and info.get("status") == "ok":
618
+ if info.get("email") and not state.data.get("email_processed"):
619
+ validated_email = validate_optional_lookup_contact(
620
+ state,
621
+ "email",
622
+ info["email"],
623
+ )
624
+ if validated_email is not None:
625
+ state.data["email"] = validated_email
626
+ state.data["email_processed"] = True
627
+ _mark_brazilian_tax_id_lookup_derived_contact(state, "email")
628
+ if info.get("name") and not state.data.get("name_processed"):
629
+ validated_name = validate_optional_lookup_contact(
630
+ state,
631
+ "name",
632
+ info["name"],
633
+ )
634
+ if validated_name is not None:
635
+ state.data["name"] = validated_name
636
+ state.data["name_processed"] = True
637
+ _mark_brazilian_tax_id_lookup_derived_contact(state, "name")
638
+ state.data["identity_verified"] = True
639
+ state.agent_response = None
640
+ return state
641
+ if not required and _is_refusal(str(pay.get("message", ""))):
642
+ if "anonymous" not in eligible_method_set:
643
+ return reject_unavailable_anonymous_brazilian_tax_id_skip(state)
644
+ return complete_without_value(
645
+ state,
646
+ "brazilian_tax_id",
647
+ reask_response=AgentResponse(
648
+ description="What is your Brazilian tax ID?",
649
+ payload_schema=tax_id_model.model_json_schema(),
650
+ ),
651
+ )
652
+ state.agent_response = AgentResponse(
653
+ description=(
654
+ "What is your Brazilian tax ID?"
655
+ if required
656
+ else "What is your Brazilian tax ID? (or say 'skip' to continue anonymously)"
657
+ ),
658
+ payload_schema=tax_id_model.model_json_schema(),
659
+ )
660
+ return state
661
+
662
+ def tax_id_router(state: ServiceState) -> str:
663
+ if state.agent_response is not None:
664
+ return END
665
+ if state.data.get("identification_method") == "govbr":
666
+ return "authenticate_govbr"
667
+ if state.data.get("identification_skipped"):
668
+ return "identification_done"
669
+ if not state.data.get("email") and not state.data.get("email_processed"):
670
+ return "collect_email"
671
+ if not state.data.get("name") and not state.data.get("name_processed"):
672
+ return "collect_name"
673
+ return "identification_done"
674
+
675
+ # ── collect email (optional) ─────────────────────────────────────
676
+ async def collect_email(state: ServiceState) -> ServiceState:
677
+ if state.internal.get(CORRECTION_REQUESTED_INTERNAL_KEY) == "email":
678
+ _clear_brazilian_tax_id_lookup_provenance(state, "email")
679
+ clear_cascade(state, "email", ctx)
680
+ state.internal.pop(CORRECTION_REQUESTED_INTERNAL_KEY, None)
681
+ if state.data.get("email_processed"):
682
+ state.agent_response = None
683
+ return state
684
+ pay = state.payload or {}
685
+ if "email" in pay:
686
+ raw = pay["email"]
687
+ if raw is None or str(raw).strip() == "" or _is_skip(str(raw)):
688
+ return complete_without_value(
689
+ state,
690
+ "email",
691
+ reask_response=AgentResponse(
692
+ description="What is your email address? (or say 'skip')",
693
+ payload_schema=email_model.model_json_schema(),
694
+ ),
695
+ )
696
+ try:
697
+ validated = email_model.model_validate({"email": raw})
698
+ state.data["email"] = cast(str, validated.model_dump()["email"])
699
+ state.data["email_processed"] = True
700
+ _clear_brazilian_tax_id_lookup_provenance(state, "email")
701
+ reset_attempts(state, "email")
702
+ state.agent_response = None
703
+ return state
704
+ except Exception as exc:
705
+ if inc_attempts(state, "email") >= max_attempts:
706
+ return exhaust(
707
+ state,
708
+ stage="email",
709
+ reask_response=AgentResponse(
710
+ description="Invalid email address. Resubmit it or say 'skip'.",
711
+ payload_schema=email_model.model_json_schema(),
712
+ error_message=_EXHAUSTED_ERROR,
713
+ ),
714
+ )
715
+ state.agent_response = AgentResponse(
716
+ description="Invalid email address. Resubmit it or say 'skip'.",
717
+ payload_schema=email_model.model_json_schema(),
718
+ error_message=str(exc),
719
+ )
720
+ return state
721
+ state.agent_response = AgentResponse(
722
+ description="What is your email address? (or say 'skip')",
723
+ payload_schema=email_model.model_json_schema(),
724
+ )
725
+ return state
726
+
727
+ def email_router(state: ServiceState) -> str:
728
+ if state.agent_response is not None:
729
+ return END
730
+ if not state.data.get("name") and not state.data.get("name_processed"):
731
+ return "collect_name"
732
+ return "identification_done"
733
+
734
+ # ── collect name (optional) ──────────────────────────────────────
735
+ async def collect_name(state: ServiceState) -> ServiceState:
736
+ if state.internal.get(CORRECTION_REQUESTED_INTERNAL_KEY) == "name":
737
+ _clear_brazilian_tax_id_lookup_provenance(state, "name")
738
+ clear_cascade(state, "name", ctx)
739
+ state.internal.pop(CORRECTION_REQUESTED_INTERNAL_KEY, None)
740
+ if state.data.get("name_processed"):
741
+ state.agent_response = None
742
+ return state
743
+ pay = state.payload or {}
744
+ if "name" in pay:
745
+ raw = pay["name"]
746
+ if raw is None or str(raw).strip() == "" or _is_skip(str(raw)):
747
+ return complete_without_value(
748
+ state,
749
+ "name",
750
+ reask_response=AgentResponse(
751
+ description="What is your full name? (or say 'skip')",
752
+ payload_schema=name_model.model_json_schema(),
753
+ ),
754
+ )
755
+ try:
756
+ validated = name_model.model_validate({"name": raw})
757
+ state.data["name"] = cast(str, validated.model_dump()["name"])
758
+ state.data["name_processed"] = True
759
+ _clear_brazilian_tax_id_lookup_provenance(state, "name")
760
+ reset_attempts(state, "name")
761
+ state.agent_response = None
762
+ return state
763
+ except Exception as exc:
764
+ if inc_attempts(state, "name") >= max_attempts:
765
+ return exhaust(
766
+ state,
767
+ stage="name",
768
+ reask_response=AgentResponse(
769
+ description="Invalid name. Resubmit it or say 'skip'.",
770
+ payload_schema=name_model.model_json_schema(),
771
+ error_message=_EXHAUSTED_ERROR,
772
+ ),
773
+ )
774
+ state.agent_response = AgentResponse(
775
+ description="Invalid name. Resubmit it or say 'skip'.",
776
+ payload_schema=name_model.model_json_schema(),
777
+ error_message=str(exc),
778
+ )
779
+ return state
780
+ state.agent_response = AgentResponse(
781
+ description="What is your full name? (or say 'skip')",
782
+ payload_schema=name_model.model_json_schema(),
783
+ )
784
+ return state
785
+
786
+ def name_router(state: ServiceState) -> str:
787
+ return END if state.agent_response is not None else "identification_done"
788
+
789
+ async def done(state: ServiceState) -> ServiceState:
790
+ state.agent_response = None
791
+ return state
792
+
793
+ authenticate_descriptor = NodeDesc(
794
+ "authenticate_govbr",
795
+ authenticate_legacy,
796
+ authenticate_router,
797
+ targets=[
798
+ "collect_tax_id",
799
+ "collect_email",
800
+ "collect_name",
801
+ "identification_done",
802
+ ],
803
+ )
804
+ await_external = ctx.await_external
805
+ if await_external and await_external.get("step") == "authenticate_govbr":
806
+ if "govbr" not in eligible_method_set:
807
+ raise ValueError("identification@2 await_external targets disabled method 'govbr'")
808
+ configured_capability = copy.deepcopy(await_external)
809
+ configured_transitions = {
810
+ "timeout": configured_capability.get("timeout"),
811
+ **(configured_capability.get("recovery") or {}),
812
+ }
813
+ transition_methods: dict[str, _IdentificationMethod | None] = {}
814
+ method_for_target: dict[str, _IdentificationMethod] = {
815
+ "collect_tax_id": "brazilian_tax_id",
816
+ "authenticate_govbr": "govbr",
817
+ "identification_done": "anonymous",
818
+ }
819
+ for transition_name, transition in configured_transitions.items():
820
+ if transition is None:
821
+ transition_methods[transition_name] = None
822
+ continue
823
+ transition_target = transition["goto"]
824
+ target_method = method_for_target.get(transition_target)
825
+ transition_set = transition.get("set") or {}
826
+ configured_method = transition_set.get("identification_method")
827
+ if configured_method is not None and configured_method not in {
828
+ "brazilian_tax_id",
829
+ "govbr",
830
+ "anonymous",
831
+ }:
832
+ raise ValueError(
833
+ "identification@2 await_external transition "
834
+ f"{transition_name!r} sets invalid identification method "
835
+ f"{configured_method!r}"
836
+ )
837
+ if (
838
+ target_method is not None
839
+ and configured_method is not None
840
+ and target_method != configured_method
841
+ ):
842
+ raise ValueError(
843
+ "identification@2 await_external transition "
844
+ f"{transition_name!r} has conflicting method and target"
845
+ )
846
+ implied_method = cast(
847
+ _IdentificationMethod | None,
848
+ configured_method or target_method,
849
+ )
850
+ if implied_method is not None and implied_method not in eligible_method_set:
851
+ raise ValueError(
852
+ "identification@2 await_external transition "
853
+ f"{transition_name!r} routes to disabled method {implied_method!r}"
854
+ )
855
+ transition_methods[transition_name] = implied_method
856
+ configured_on_resume = configured_capability.setdefault("on_resume", {})
857
+ if isinstance(configured_on_resume.get("enrich"), str):
858
+ configured_on_resume["enrich"] = {
859
+ "tool": configured_on_resume["enrich"],
860
+ "optional": True,
861
+ "input": {"brazilian_tax_id": "$token.brazilian_tax_id"},
862
+ "set": {"phone": "$result.phones.0"},
863
+ }
864
+ configured_step: dict[str, Any] = {"step": "authenticate_govbr"}
865
+ if not configured_capability.get("interactive"):
866
+ configured_step["interactive"] = {
867
+ "kind": "cta_url",
868
+ "field": configured_capability["resume_on"],
869
+ "out_of_band": True,
870
+ "next_step": "await_govbr_auth",
871
+ }
872
+ generic_descriptor = make_await_external_node(
873
+ ctx,
874
+ configured_capability,
875
+ configured_step,
876
+ default_router=authenticate_router,
877
+ additional_targets=[
878
+ "collect_tax_id",
879
+ "collect_email",
880
+ "collect_name",
881
+ "identification_done",
882
+ ],
883
+ legacy_sent_data_key="govbr_auth_sent",
884
+ waiting_description=(
885
+ "I am waiting for you to complete the gov.br login. "
886
+ "Let me know when you finish. 🙂"
887
+ ),
888
+ )
889
+ await_sent_internal_key = f"_await_external_sent:{generic_descriptor.id}"
890
+ await_completed_internal_key = f"_await_external_completed:{generic_descriptor.id}"
891
+
892
+ def clear_configured_await_markers(state: ServiceState) -> None:
893
+ state.data.pop("govbr_auth_sent", None)
894
+ state.internal.pop(await_sent_internal_key, None)
895
+ state.internal.pop(await_completed_internal_key, None)
896
+
897
+ async def authenticate_configured(state: ServiceState) -> ServiceState:
898
+ if state.data.get("identification_method") != "govbr" or state.data.get(
899
+ "govbr_authenticated"
900
+ ):
901
+ state.agent_response = None
902
+ return state
903
+
904
+ payload = state.payload or {}
905
+ resume_on = configured_capability["resume_on"]
906
+ recovery = configured_capability.get("recovery") or {}
907
+ if (
908
+ state.data.get("govbr_auth_sent")
909
+ and resume_on not in payload
910
+ and "_external_event" not in payload
911
+ ):
912
+ recovery_text = normalize_text(
913
+ f"{payload.get('message', '')} {payload.get('identification_method', '')}"
914
+ )
915
+ if not required and _is_refusal(recovery_text):
916
+ if "abort" in recovery:
917
+ payload["_external_event"] = "abort"
918
+ elif "anonymous" not in eligible_method_set:
919
+ clear_configured_await_markers(state)
920
+ return reject_disabled_method(state, "anonymous")
921
+ else:
922
+ set_identification_method(state, "anonymous")
923
+ clear_configured_await_markers(state)
924
+ state.status = "progress"
925
+ state.agent_response = None
926
+ return state
927
+ elif "brazilian_tax_id" in recovery_text:
928
+ if "brazilian_tax_id" not in eligible_method_set:
929
+ clear_configured_await_markers(state)
930
+ return reject_disabled_method(state, "brazilian_tax_id")
931
+ if "switch" in recovery:
932
+ payload["_external_event"] = "switch"
933
+ else:
934
+ set_identification_method(state, "brazilian_tax_id")
935
+ clear_configured_await_markers(state)
936
+ state.status = "progress"
937
+ state.agent_response = None
938
+ return state
939
+
940
+ resume_token = payload.get(resume_on)
941
+ if resume_on in payload and (
942
+ not isinstance(resume_token, Mapping)
943
+ or not resume_token.get("brazilian_tax_id")
944
+ ):
945
+ if "switch" in recovery:
946
+ payload.pop(resume_on, None)
947
+ payload["_external_event"] = "switch"
948
+ else:
949
+ clear_configured_await_markers(state)
950
+ state.status = "progress"
951
+ return recover_from_govbr_validation_failure(state)
952
+
953
+ external_event = payload.get("_external_event")
954
+ has_resume_token = resume_on in payload
955
+ updated_state = await generic_descriptor.fn(state)
956
+ if updated_state.agent_response is not None:
957
+ return updated_state
958
+
959
+ transition = configured_transitions.get(cast(str, external_event))
960
+ if external_event is not None and transition is not None:
961
+ if transition_method := transition_methods.get(cast(str, external_event)):
962
+ set_identification_method(updated_state, transition_method)
963
+ if transition_method == "govbr":
964
+ updated_state.internal.pop(await_completed_internal_key, None)
965
+ if transition["goto"] == "select_identification_method":
966
+ if transition_method is None:
967
+ updated_state.data.pop("identification_method", None)
968
+ updated_state.data.pop("identification_skipped", None)
969
+ updated_state.internal.pop(await_completed_internal_key, None)
970
+
971
+ if not has_resume_token:
972
+ return updated_state
973
+ if not updated_state.data.get("brazilian_tax_id"):
974
+ updated_state.data.pop("govbr_authenticated", None)
975
+ updated_state.data.pop("identity_verified", None)
976
+ clear_configured_await_markers(updated_state)
977
+ return recover_from_govbr_validation_failure(updated_state)
978
+ if updated_state.data.get("name"):
979
+ updated_state.data["name_processed"] = True
980
+ _clear_brazilian_tax_id_lookup_provenance(updated_state, "name")
981
+ if updated_state.data.get("email"):
982
+ updated_state.data["email_processed"] = True
983
+ _clear_brazilian_tax_id_lookup_provenance(updated_state, "email")
984
+ updated_state.data["govbr_authenticated"] = True
985
+ updated_state.data["identity_verified"] = True
986
+ return updated_state
987
+
988
+ authenticate_descriptor = NodeDesc(
989
+ generic_descriptor.id,
990
+ authenticate_configured,
991
+ generic_descriptor.router,
992
+ generic_descriptor.targets,
993
+ )
994
+
995
+ descriptors = [
996
+ NodeDesc(
997
+ "select_identification_method",
998
+ select,
999
+ select_router,
1000
+ targets=["authenticate_govbr", "collect_tax_id", "identification_done"],
1001
+ ),
1002
+ authenticate_descriptor,
1003
+ NodeDesc(
1004
+ "collect_tax_id",
1005
+ collect_tax_id,
1006
+ tax_id_router,
1007
+ targets=[
1008
+ "authenticate_govbr",
1009
+ "collect_email",
1010
+ "collect_name",
1011
+ "identification_done",
1012
+ ],
1013
+ ),
1014
+ NodeDesc(
1015
+ "collect_email",
1016
+ collect_email,
1017
+ email_router,
1018
+ targets=["collect_name", "identification_done"],
1019
+ ),
1020
+ NodeDesc("collect_name", collect_name, name_router, targets=["identification_done"]),
1021
+ NodeDesc("identification_done", done, lambda s: NEXT),
1022
+ ]
1023
+ return SubflowBuild(
1024
+ descriptors=descriptors,
1025
+ entry_id="select_identification_method",
1026
+ node_for_slot={
1027
+ "brazilian_tax_id": "collect_tax_id",
1028
+ "email": "collect_email",
1029
+ "name": "collect_name",
1030
+ },
1031
+ )