mcp-eregistrations-bpa 0.8.5__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.

Potentially problematic release.


This version of mcp-eregistrations-bpa might be problematic. Click here for more details.

Files changed (66) hide show
  1. mcp_eregistrations_bpa/__init__.py +121 -0
  2. mcp_eregistrations_bpa/__main__.py +6 -0
  3. mcp_eregistrations_bpa/arazzo/__init__.py +21 -0
  4. mcp_eregistrations_bpa/arazzo/expression.py +379 -0
  5. mcp_eregistrations_bpa/audit/__init__.py +56 -0
  6. mcp_eregistrations_bpa/audit/context.py +66 -0
  7. mcp_eregistrations_bpa/audit/logger.py +236 -0
  8. mcp_eregistrations_bpa/audit/models.py +131 -0
  9. mcp_eregistrations_bpa/auth/__init__.py +64 -0
  10. mcp_eregistrations_bpa/auth/callback.py +391 -0
  11. mcp_eregistrations_bpa/auth/cas.py +409 -0
  12. mcp_eregistrations_bpa/auth/oidc.py +252 -0
  13. mcp_eregistrations_bpa/auth/permissions.py +162 -0
  14. mcp_eregistrations_bpa/auth/token_manager.py +348 -0
  15. mcp_eregistrations_bpa/bpa_client/__init__.py +84 -0
  16. mcp_eregistrations_bpa/bpa_client/client.py +740 -0
  17. mcp_eregistrations_bpa/bpa_client/endpoints.py +193 -0
  18. mcp_eregistrations_bpa/bpa_client/errors.py +276 -0
  19. mcp_eregistrations_bpa/bpa_client/models.py +203 -0
  20. mcp_eregistrations_bpa/config.py +349 -0
  21. mcp_eregistrations_bpa/db/__init__.py +21 -0
  22. mcp_eregistrations_bpa/db/connection.py +64 -0
  23. mcp_eregistrations_bpa/db/migrations.py +168 -0
  24. mcp_eregistrations_bpa/exceptions.py +39 -0
  25. mcp_eregistrations_bpa/py.typed +0 -0
  26. mcp_eregistrations_bpa/rollback/__init__.py +19 -0
  27. mcp_eregistrations_bpa/rollback/manager.py +616 -0
  28. mcp_eregistrations_bpa/server.py +152 -0
  29. mcp_eregistrations_bpa/tools/__init__.py +372 -0
  30. mcp_eregistrations_bpa/tools/actions.py +155 -0
  31. mcp_eregistrations_bpa/tools/analysis.py +352 -0
  32. mcp_eregistrations_bpa/tools/audit.py +399 -0
  33. mcp_eregistrations_bpa/tools/behaviours.py +1042 -0
  34. mcp_eregistrations_bpa/tools/bots.py +627 -0
  35. mcp_eregistrations_bpa/tools/classifications.py +575 -0
  36. mcp_eregistrations_bpa/tools/costs.py +765 -0
  37. mcp_eregistrations_bpa/tools/debug_strategies.py +351 -0
  38. mcp_eregistrations_bpa/tools/debugger.py +1230 -0
  39. mcp_eregistrations_bpa/tools/determinants.py +2235 -0
  40. mcp_eregistrations_bpa/tools/document_requirements.py +670 -0
  41. mcp_eregistrations_bpa/tools/export.py +899 -0
  42. mcp_eregistrations_bpa/tools/fields.py +162 -0
  43. mcp_eregistrations_bpa/tools/form_errors.py +36 -0
  44. mcp_eregistrations_bpa/tools/formio_helpers.py +971 -0
  45. mcp_eregistrations_bpa/tools/forms.py +1269 -0
  46. mcp_eregistrations_bpa/tools/jsonlogic_builder.py +466 -0
  47. mcp_eregistrations_bpa/tools/large_response.py +163 -0
  48. mcp_eregistrations_bpa/tools/messages.py +523 -0
  49. mcp_eregistrations_bpa/tools/notifications.py +241 -0
  50. mcp_eregistrations_bpa/tools/registration_institutions.py +680 -0
  51. mcp_eregistrations_bpa/tools/registrations.py +897 -0
  52. mcp_eregistrations_bpa/tools/role_status.py +447 -0
  53. mcp_eregistrations_bpa/tools/role_units.py +400 -0
  54. mcp_eregistrations_bpa/tools/roles.py +1236 -0
  55. mcp_eregistrations_bpa/tools/rollback.py +335 -0
  56. mcp_eregistrations_bpa/tools/services.py +674 -0
  57. mcp_eregistrations_bpa/tools/workflows.py +2487 -0
  58. mcp_eregistrations_bpa/tools/yaml_transformer.py +991 -0
  59. mcp_eregistrations_bpa/workflows/__init__.py +28 -0
  60. mcp_eregistrations_bpa/workflows/loader.py +440 -0
  61. mcp_eregistrations_bpa/workflows/models.py +336 -0
  62. mcp_eregistrations_bpa-0.8.5.dist-info/METADATA +965 -0
  63. mcp_eregistrations_bpa-0.8.5.dist-info/RECORD +66 -0
  64. mcp_eregistrations_bpa-0.8.5.dist-info/WHEEL +4 -0
  65. mcp_eregistrations_bpa-0.8.5.dist-info/entry_points.txt +2 -0
  66. mcp_eregistrations_bpa-0.8.5.dist-info/licenses/LICENSE +86 -0
@@ -0,0 +1,2235 @@
1
+ """MCP tools for BPA determinant operations.
2
+
3
+ This module provides tools for listing, retrieving, creating, and updating
4
+ BPA determinants. Determinants are accessed through service endpoints
5
+ (service-centric API design).
6
+
7
+ Write operations follow the audit-before-write pattern:
8
+ 1. Validate parameters (pre-flight, no audit record if validation fails)
9
+ 2. Create PENDING audit record
10
+ 3. Execute BPA API call
11
+ 4. Update audit record to SUCCESS or FAILED
12
+
13
+ API Endpoints used:
14
+ - GET /service/{service_id}/determinant - List determinants for service
15
+ - GET /determinant/{id} - Get determinant by ID
16
+ - POST /service/{service_id}/textdeterminant - Create text determinant
17
+ - PUT /service/{service_id}/textdeterminant - Update text determinant
18
+ - POST /service/{service_id}/selectdeterminant - Create select determinant
19
+ - POST /service/{service_id}/numericdeterminant - Create numeric determinant
20
+ - POST /service/{service_id}/booleandeterminant - Create boolean determinant
21
+ - POST /service/{service_id}/datedeterminant - Create date determinant
22
+ - POST /service/{service_id}/classificationdeterminant - Create classification det.
23
+ - DELETE /service/{service_id}/determinant/{determinant_id} - Delete determinant
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Any
29
+
30
+ from mcp.server.fastmcp.exceptions import ToolError
31
+
32
+ from mcp_eregistrations_bpa.audit.context import (
33
+ NotAuthenticatedError,
34
+ get_current_user_email,
35
+ )
36
+ from mcp_eregistrations_bpa.audit.logger import AuditLogger
37
+ from mcp_eregistrations_bpa.bpa_client import BPAClient
38
+ from mcp_eregistrations_bpa.bpa_client.errors import (
39
+ BPAClientError,
40
+ BPANotFoundError,
41
+ translate_error,
42
+ )
43
+ from mcp_eregistrations_bpa.tools.large_response import large_response_handler
44
+
45
+ # Operator normalization mapping (Story 10-4)
46
+ # Maps common operator variants to canonical BPA API format
47
+ _OPERATOR_ALIASES: dict[str, str] = {
48
+ # Equals variants
49
+ "equals": "EQUAL",
50
+ "eq": "EQUAL",
51
+ "==": "EQUAL",
52
+ # Not equals variants
53
+ "notequals": "NOT_EQUAL",
54
+ "noteq": "NOT_EQUAL",
55
+ "ne": "NOT_EQUAL",
56
+ "!=": "NOT_EQUAL",
57
+ "not_equals": "NOT_EQUAL",
58
+ # Contains variants
59
+ "contains": "CONTAINS",
60
+ # Starts with variants
61
+ "startswith": "STARTS_WITH",
62
+ "starts_with": "STARTS_WITH",
63
+ # Ends with variants
64
+ "endswith": "ENDS_WITH",
65
+ "ends_with": "ENDS_WITH",
66
+ # Greater than variants
67
+ "greaterthan": "GREATER_THAN",
68
+ "greater_than": "GREATER_THAN",
69
+ "gt": "GREATER_THAN",
70
+ ">": "GREATER_THAN",
71
+ # Less than variants
72
+ "lessthan": "LESS_THAN",
73
+ "less_than": "LESS_THAN",
74
+ "lt": "LESS_THAN",
75
+ "<": "LESS_THAN",
76
+ # Greater than or equal variants
77
+ "greaterthanorequal": "GREATER_THAN_OR_EQUAL",
78
+ "greater_than_or_equal": "GREATER_THAN_OR_EQUAL",
79
+ "gte": "GREATER_THAN_OR_EQUAL",
80
+ "ge": "GREATER_THAN_OR_EQUAL",
81
+ ">=": "GREATER_THAN_OR_EQUAL",
82
+ # Less than or equal variants
83
+ "lessthanorequal": "LESS_THAN_OR_EQUAL",
84
+ "less_than_or_equal": "LESS_THAN_OR_EQUAL",
85
+ "lte": "LESS_THAN_OR_EQUAL",
86
+ "le": "LESS_THAN_OR_EQUAL",
87
+ "<=": "LESS_THAN_OR_EQUAL",
88
+ }
89
+
90
+
91
+ def _normalize_operator(operator: str) -> str:
92
+ """Normalize operator to canonical BPA API format.
93
+
94
+ Accepts various formats (camelCase, lowercase, symbols) and converts
95
+ to the uppercase underscore format expected by BPA API.
96
+
97
+ Args:
98
+ operator: Operator in any supported format.
99
+
100
+ Returns:
101
+ Normalized operator string (e.g., "EQUAL", "NOT_EQUAL", "GREATER_THAN").
102
+
103
+ Examples:
104
+ >>> _normalize_operator("equals")
105
+ "EQUAL"
106
+ >>> _normalize_operator("notEquals")
107
+ "NOT_EQUAL"
108
+ >>> _normalize_operator(">=")
109
+ "GREATER_THAN_OR_EQUAL"
110
+ """
111
+ cleaned = operator.strip().lower()
112
+
113
+ # Check alias mapping first
114
+ if cleaned in _OPERATOR_ALIASES:
115
+ return _OPERATOR_ALIASES[cleaned]
116
+
117
+ # If already in canonical format, just uppercase
118
+ return operator.strip().upper()
119
+
120
+
121
+ __all__ = [
122
+ "determinant_list",
123
+ "determinant_get",
124
+ "determinant_search",
125
+ "determinant_delete",
126
+ "textdeterminant_create",
127
+ "textdeterminant_update",
128
+ "selectdeterminant_create",
129
+ "numericdeterminant_create",
130
+ "booleandeterminant_create",
131
+ "datedeterminant_create",
132
+ "classificationdeterminant_create",
133
+ "griddeterminant_create",
134
+ "register_determinant_tools",
135
+ ]
136
+
137
+
138
+ @large_response_handler(
139
+ threshold_bytes=50 * 1024, # 50KB threshold for list tools
140
+ navigation={
141
+ "list_all": "jq '.determinants'",
142
+ "by_type": "jq '.determinants[] | select(.type == \"text\")'",
143
+ "by_field": "jq '.determinants[] | select(.target_form_field_key==\"x\")'",
144
+ "by_name": "jq '.determinants[] | select(.name | contains(\"x\"))'",
145
+ },
146
+ )
147
+ async def determinant_list(service_id: str | int) -> dict[str, Any]:
148
+ """List determinants for a service.
149
+
150
+ Large responses (>50KB) are saved to file with navigation hints.
151
+
152
+ Args:
153
+ service_id: Service ID to list determinants for.
154
+
155
+ Returns:
156
+ dict with determinants, total, service_id.
157
+ """
158
+ try:
159
+ async with BPAClient() as client:
160
+ determinants_data = await client.get_list(
161
+ "/service/{service_id}/determinant",
162
+ path_params={"service_id": service_id},
163
+ resource_type="determinant",
164
+ )
165
+ except BPAClientError as e:
166
+ raise translate_error(e, resource_type="determinant")
167
+
168
+ # Transform to consistent output format with snake_case keys
169
+ determinants = []
170
+ for det in determinants_data:
171
+ determinant_item: dict[str, Any] = {
172
+ "id": det.get("id"),
173
+ "name": det.get("name"),
174
+ "type": det.get("type"),
175
+ "operator": det.get("operator"),
176
+ "target_form_field_key": det.get("targetFormFieldKey"),
177
+ "condition_summary": det.get("conditionSummary"),
178
+ "json_condition": det.get("jsonCondition"),
179
+ }
180
+ # Include type-specific value fields if present
181
+ if det.get("textValue") is not None:
182
+ determinant_item["text_value"] = det.get("textValue")
183
+ if det.get("selectValue") is not None:
184
+ determinant_item["select_value"] = det.get("selectValue")
185
+ if det.get("numericValue") is not None:
186
+ determinant_item["numeric_value"] = det.get("numericValue")
187
+ if det.get("booleanValue") is not None:
188
+ determinant_item["boolean_value"] = det.get("booleanValue")
189
+ if det.get("dateValue") is not None:
190
+ determinant_item["date_value"] = det.get("dateValue")
191
+ if det.get("isCurrentDate") is not None:
192
+ determinant_item["is_current_date"] = det.get("isCurrentDate")
193
+ determinants.append(determinant_item)
194
+
195
+ return {
196
+ "determinants": determinants,
197
+ "total": len(determinants),
198
+ "service_id": service_id,
199
+ }
200
+
201
+
202
+ async def determinant_get(determinant_id: str | int) -> dict[str, Any]:
203
+ """Get determinant details by ID.
204
+
205
+ Args:
206
+ determinant_id: Determinant ID.
207
+
208
+ Returns:
209
+ dict with id, name, type, service_id (if available), condition_logic,
210
+ json_condition.
211
+ """
212
+ try:
213
+ async with BPAClient() as client:
214
+ try:
215
+ determinant_data = await client.get(
216
+ "/determinant/{id}",
217
+ path_params={"id": determinant_id},
218
+ resource_type="determinant",
219
+ resource_id=determinant_id,
220
+ )
221
+ except BPANotFoundError:
222
+ raise ToolError(
223
+ f"Determinant '{determinant_id}' not found. "
224
+ "Use 'determinant_list' with service_id to see determinants."
225
+ )
226
+ except ToolError:
227
+ raise
228
+ except BPAClientError as e:
229
+ raise translate_error(
230
+ e, resource_type="determinant", resource_id=determinant_id
231
+ )
232
+
233
+ # Build response with all condition-related fields
234
+ result: dict[str, Any] = {
235
+ "id": determinant_data.get("id"),
236
+ "name": determinant_data.get("name"),
237
+ "type": determinant_data.get("type"),
238
+ "operator": determinant_data.get("operator"),
239
+ "target_form_field_key": determinant_data.get("targetFormFieldKey"),
240
+ "condition_logic": determinant_data.get("conditionLogic"),
241
+ "json_condition": determinant_data.get("jsonCondition"),
242
+ "condition_summary": determinant_data.get("conditionSummary"),
243
+ }
244
+
245
+ # Include service_id if present in API response (Story 10-7: NFR4 complete context)
246
+ if determinant_data.get("serviceId") is not None:
247
+ result["service_id"] = determinant_data.get("serviceId")
248
+
249
+ # Include type-specific value fields if present
250
+ if determinant_data.get("textValue") is not None:
251
+ result["text_value"] = determinant_data.get("textValue")
252
+ if determinant_data.get("selectValue") is not None:
253
+ result["select_value"] = determinant_data.get("selectValue")
254
+ if determinant_data.get("numericValue") is not None:
255
+ result["numeric_value"] = determinant_data.get("numericValue")
256
+ if determinant_data.get("booleanValue") is not None:
257
+ result["boolean_value"] = determinant_data.get("booleanValue")
258
+ if determinant_data.get("dateValue") is not None:
259
+ result["date_value"] = determinant_data.get("dateValue")
260
+ if determinant_data.get("isCurrentDate") is not None:
261
+ result["is_current_date"] = determinant_data.get("isCurrentDate")
262
+
263
+ return result
264
+
265
+
266
+ def _validate_textdeterminant_create_params(
267
+ service_id: str | int,
268
+ name: str,
269
+ operator: str,
270
+ target_form_field_key: str,
271
+ text_value: str = "",
272
+ ) -> dict[str, Any]:
273
+ """Validate textdeterminant_create parameters (pre-flight).
274
+
275
+ Returns validated params dict or raises ToolError if invalid.
276
+ No audit record is created for validation failures.
277
+
278
+ Args:
279
+ service_id: Parent service ID (required).
280
+ name: Determinant name (required).
281
+ operator: Comparison operator (required). Valid values: equals, notEquals,
282
+ contains, notContains, startsWith, endsWith, isEmpty, isNotEmpty.
283
+ target_form_field_key: The form field key this determinant targets (required).
284
+ text_value: The text value to compare against (default: "" for isEmpty checks).
285
+
286
+ Returns:
287
+ dict: Validated parameters ready for API call.
288
+
289
+ Raises:
290
+ ToolError: If validation fails.
291
+ """
292
+ errors = []
293
+
294
+ if not service_id:
295
+ errors.append("'service_id' is required")
296
+
297
+ if not name or not name.strip():
298
+ errors.append("'name' is required and cannot be empty")
299
+
300
+ if name and len(name.strip()) > 255:
301
+ errors.append("'name' must be 255 characters or less")
302
+
303
+ if not operator or not operator.strip():
304
+ errors.append("'operator' is required")
305
+
306
+ valid_operators = [
307
+ "EQUAL",
308
+ "NOT_EQUAL",
309
+ "CONTAINS",
310
+ "STARTS_WITH",
311
+ "ENDS_WITH",
312
+ ]
313
+ # Normalize operator to canonical format (Story 10-4)
314
+ normalized_operator = _normalize_operator(operator) if operator else ""
315
+ if operator and normalized_operator not in valid_operators:
316
+ errors.append(f"'operator' must be one of: {', '.join(valid_operators)}")
317
+
318
+ if not target_form_field_key or not target_form_field_key.strip():
319
+ errors.append("'target_form_field_key' is required")
320
+
321
+ if errors:
322
+ error_msg = "; ".join(errors)
323
+ raise ToolError(
324
+ f"Cannot create text determinant: {error_msg}. "
325
+ "Provide valid 'service_id', 'name', 'operator', and "
326
+ "'target_form_field_key' parameters."
327
+ )
328
+
329
+ return {
330
+ "name": name.strip(),
331
+ "operator": normalized_operator,
332
+ "targetFormFieldKey": target_form_field_key.strip(),
333
+ "determinantType": "FORMFIELD",
334
+ "type": "text",
335
+ "textValue": text_value.strip() if text_value else "",
336
+ "determinantInsideGrid": False,
337
+ }
338
+
339
+
340
+ def _validate_textdeterminant_update_params(
341
+ service_id: str | int,
342
+ determinant_id: str | int,
343
+ name: str | None,
344
+ operator: str | None,
345
+ target_form_field_key: str | None,
346
+ condition_logic: str | None,
347
+ json_condition: str | None,
348
+ ) -> dict[str, Any]:
349
+ """Validate textdeterminant_update parameters (pre-flight).
350
+
351
+ Returns validated params dict or raises ToolError if invalid.
352
+
353
+ Args:
354
+ service_id: Parent service ID (required).
355
+ determinant_id: Determinant ID to update (required).
356
+ name: New name (optional).
357
+ operator: Comparison operator (optional). Valid values: equals, notEquals,
358
+ contains, notContains, startsWith, endsWith, isEmpty, isNotEmpty.
359
+ target_form_field_key: The form field key this determinant targets (optional).
360
+ condition_logic: New condition logic (optional).
361
+ json_condition: New JSON condition (optional).
362
+
363
+ Returns:
364
+ dict: Validated parameters ready for API call.
365
+
366
+ Raises:
367
+ ToolError: If validation fails.
368
+ """
369
+ errors = []
370
+
371
+ if not service_id:
372
+ errors.append("'service_id' is required")
373
+
374
+ if not determinant_id:
375
+ errors.append("'determinant_id' is required")
376
+
377
+ if name is not None and not name.strip():
378
+ errors.append("'name' cannot be empty when provided")
379
+
380
+ if name and len(name.strip()) > 255:
381
+ errors.append("'name' must be 255 characters or less")
382
+
383
+ valid_operators = [
384
+ "EQUAL",
385
+ "NOT_EQUAL",
386
+ "CONTAINS",
387
+ "STARTS_WITH",
388
+ "ENDS_WITH",
389
+ ]
390
+ # Normalize operator to canonical format (Story 10-4)
391
+ normalized_operator = _normalize_operator(operator) if operator else None
392
+ if operator is not None:
393
+ if not operator.strip():
394
+ errors.append("'operator' cannot be empty when provided")
395
+ elif normalized_operator not in valid_operators:
396
+ errors.append(f"'operator' must be one of: {', '.join(valid_operators)}")
397
+
398
+ if target_form_field_key is not None and not target_form_field_key.strip():
399
+ errors.append("'target_form_field_key' cannot be empty when provided")
400
+
401
+ # At least one field must be provided for update
402
+ if all(
403
+ v is None
404
+ for v in [
405
+ name,
406
+ operator,
407
+ target_form_field_key,
408
+ condition_logic,
409
+ json_condition,
410
+ ]
411
+ ):
412
+ errors.append(
413
+ "At least one field (name, operator, target_form_field_key, "
414
+ "condition_logic, json_condition) required"
415
+ )
416
+
417
+ if errors:
418
+ error_msg = "; ".join(errors)
419
+ raise ToolError(
420
+ f"Cannot update text determinant: {error_msg}. Check required fields."
421
+ )
422
+
423
+ params: dict[str, Any] = {"id": determinant_id}
424
+ if name is not None:
425
+ params["name"] = name.strip()
426
+ if normalized_operator is not None:
427
+ params["operator"] = normalized_operator
428
+ if target_form_field_key is not None:
429
+ params["targetFormFieldKey"] = target_form_field_key.strip()
430
+ if condition_logic is not None:
431
+ params["conditionLogic"] = condition_logic
432
+ if json_condition is not None:
433
+ params["jsonCondition"] = json_condition
434
+
435
+ return params
436
+
437
+
438
+ def _validate_selectdeterminant_create_params(
439
+ service_id: str | int,
440
+ name: str,
441
+ operator: str,
442
+ target_form_field_key: str,
443
+ select_value: str,
444
+ ) -> dict[str, Any]:
445
+ """Validate selectdeterminant_create parameters (pre-flight).
446
+
447
+ Returns validated params dict or raises ToolError if invalid.
448
+ No audit record is created for validation failures.
449
+
450
+ Args:
451
+ service_id: Parent service ID (required).
452
+ name: Determinant name (required).
453
+ operator: Comparison operator (required). Valid values: equals, notEquals,
454
+ contains, notContains, startsWith, endsWith, isEmpty, isNotEmpty.
455
+ target_form_field_key: The form field key this determinant targets (required).
456
+ select_value: The select option value this determinant matches (required).
457
+
458
+ Returns:
459
+ dict: Validated parameters ready for API call.
460
+
461
+ Raises:
462
+ ToolError: If validation fails.
463
+ """
464
+ errors = []
465
+
466
+ if not service_id:
467
+ errors.append("'service_id' is required")
468
+
469
+ if not name or not name.strip():
470
+ errors.append("'name' is required and cannot be empty")
471
+
472
+ if name and len(name.strip()) > 255:
473
+ errors.append("'name' must be 255 characters or less")
474
+
475
+ if not operator or not operator.strip():
476
+ errors.append("'operator' is required")
477
+
478
+ valid_operators = [
479
+ "EQUAL",
480
+ "NOT_EQUAL",
481
+ ]
482
+ # Normalize operator to canonical format (Story 10-4)
483
+ normalized_operator = _normalize_operator(operator) if operator else ""
484
+ if operator and normalized_operator not in valid_operators:
485
+ errors.append(f"'operator' must be one of: {', '.join(valid_operators)}")
486
+
487
+ if not target_form_field_key or not target_form_field_key.strip():
488
+ errors.append("'target_form_field_key' is required")
489
+
490
+ if not select_value or not select_value.strip():
491
+ errors.append("'select_value' is required")
492
+
493
+ if errors:
494
+ error_msg = "; ".join(errors)
495
+ raise ToolError(
496
+ f"Cannot create select determinant: {error_msg}. "
497
+ "Provide valid 'service_id', 'name', 'operator', "
498
+ "'target_form_field_key', and 'select_value' parameters."
499
+ )
500
+
501
+ return {
502
+ "name": name.strip(),
503
+ "operator": normalized_operator,
504
+ "targetFormFieldKey": target_form_field_key.strip(),
505
+ "determinantType": "FORMFIELD",
506
+ "type": "radio",
507
+ "selectValue": select_value.strip(),
508
+ "determinantInsideGrid": False,
509
+ }
510
+
511
+
512
+ async def textdeterminant_create(
513
+ service_id: str | int,
514
+ name: str,
515
+ operator: str,
516
+ target_form_field_key: str,
517
+ text_value: str = "",
518
+ condition_logic: str | None = None,
519
+ json_condition: str | None = None,
520
+ ) -> dict[str, Any]:
521
+ """Create text determinant in a service. Audited write operation.
522
+
523
+ Args:
524
+ service_id: Parent service ID.
525
+ name: Determinant name.
526
+ operator: EQUAL, NOT_EQUAL, CONTAINS, STARTS_WITH, or ENDS_WITH.
527
+ target_form_field_key: Form field key to evaluate.
528
+ text_value: Value to compare (default: "" for isEmpty).
529
+ condition_logic: Optional condition expression.
530
+ json_condition: Optional JSON condition.
531
+
532
+ Returns:
533
+ dict with id, name, type, operator, target_form_field_key, service_id, audit_id.
534
+ """
535
+ # Pre-flight validation (no audit record for validation failures)
536
+ validated_params = _validate_textdeterminant_create_params(
537
+ service_id, name, operator, target_form_field_key, text_value
538
+ )
539
+
540
+ # Add optional parameters
541
+ if condition_logic is not None:
542
+ validated_params["conditionLogic"] = condition_logic
543
+ if json_condition is not None:
544
+ validated_params["jsonCondition"] = json_condition
545
+
546
+ # Get authenticated user for audit (before any API calls)
547
+ try:
548
+ user_email = get_current_user_email()
549
+ except NotAuthenticatedError as e:
550
+ raise ToolError(str(e))
551
+
552
+ # Use single BPAClient connection for all operations
553
+ try:
554
+ async with BPAClient() as client:
555
+ # Verify parent service exists before creating audit record
556
+ try:
557
+ await client.get(
558
+ "/service/{id}",
559
+ path_params={"id": service_id},
560
+ resource_type="service",
561
+ resource_id=service_id,
562
+ )
563
+ except BPANotFoundError:
564
+ raise ToolError(
565
+ f"Cannot create text determinant: Service '{service_id}' "
566
+ "not found. Use 'service_list' to see available services."
567
+ )
568
+
569
+ # Create audit record BEFORE API call (audit-before-write pattern)
570
+ audit_logger = AuditLogger()
571
+ audit_id = await audit_logger.record_pending(
572
+ user_email=user_email,
573
+ operation_type="create",
574
+ object_type="textdeterminant",
575
+ params={
576
+ "service_id": str(service_id),
577
+ **validated_params,
578
+ },
579
+ )
580
+
581
+ try:
582
+ determinant_data = await client.post(
583
+ "/service/{service_id}/textdeterminant",
584
+ path_params={"service_id": service_id},
585
+ json=validated_params,
586
+ resource_type="determinant",
587
+ )
588
+
589
+ # Save rollback state (for create, save ID to enable deletion)
590
+ created_id = determinant_data.get("id")
591
+ await audit_logger.save_rollback_state(
592
+ audit_id=audit_id,
593
+ object_type="textdeterminant",
594
+ object_id=str(created_id),
595
+ previous_state={
596
+ "id": created_id,
597
+ "name": determinant_data.get("name"),
598
+ "operator": determinant_data.get("operator"),
599
+ "targetFormFieldKey": determinant_data.get(
600
+ "targetFormFieldKey"
601
+ ),
602
+ "textValue": determinant_data.get("textValue"),
603
+ "serviceId": str(service_id),
604
+ "_operation": "create", # Marker for rollback to DELETE
605
+ },
606
+ )
607
+
608
+ # Mark audit as success
609
+ await audit_logger.mark_success(
610
+ audit_id,
611
+ result={
612
+ "determinant_id": created_id,
613
+ "name": determinant_data.get("name"),
614
+ "service_id": str(service_id),
615
+ },
616
+ )
617
+
618
+ return {
619
+ "id": created_id,
620
+ "name": determinant_data.get("name"),
621
+ "type": "text",
622
+ "operator": determinant_data.get("operator"),
623
+ "target_form_field_key": determinant_data.get("targetFormFieldKey"),
624
+ "text_value": determinant_data.get("textValue"),
625
+ "condition_logic": determinant_data.get("conditionLogic"),
626
+ "json_condition": determinant_data.get("jsonCondition"),
627
+ "service_id": service_id,
628
+ "audit_id": audit_id,
629
+ }
630
+
631
+ except BPAClientError as e:
632
+ # Mark audit as failed
633
+ await audit_logger.mark_failed(audit_id, str(e))
634
+ raise translate_error(e, resource_type="determinant")
635
+
636
+ except ToolError:
637
+ raise
638
+ except BPAClientError as e:
639
+ raise translate_error(e, resource_type="service", resource_id=service_id)
640
+
641
+
642
+ async def textdeterminant_update(
643
+ service_id: str | int,
644
+ determinant_id: str | int,
645
+ name: str | None = None,
646
+ operator: str | None = None,
647
+ target_form_field_key: str | None = None,
648
+ condition_logic: str | None = None,
649
+ json_condition: str | None = None,
650
+ ) -> dict[str, Any]:
651
+ """Update a text determinant. Audited write operation.
652
+
653
+ Args:
654
+ service_id: Parent service ID.
655
+ determinant_id: Determinant ID to update.
656
+ name: New name (optional).
657
+ operator: New operator (optional).
658
+ target_form_field_key: New field key (optional).
659
+ condition_logic: New condition (optional).
660
+ json_condition: New JSON condition (optional).
661
+
662
+ Returns:
663
+ dict with id, name, operator, target_form_field_key, previous_state, audit_id.
664
+ """
665
+ # Pre-flight validation (no audit record for validation failures)
666
+ validated_params = _validate_textdeterminant_update_params(
667
+ service_id,
668
+ determinant_id,
669
+ name,
670
+ operator,
671
+ target_form_field_key,
672
+ condition_logic,
673
+ json_condition,
674
+ )
675
+
676
+ # Get authenticated user for audit
677
+ try:
678
+ user_email = get_current_user_email()
679
+ except NotAuthenticatedError as e:
680
+ raise ToolError(str(e))
681
+
682
+ # Use single BPAClient connection for all operations
683
+ try:
684
+ async with BPAClient() as client:
685
+ # Capture current state for rollback BEFORE making changes
686
+ try:
687
+ previous_state = await client.get(
688
+ "/determinant/{id}",
689
+ path_params={"id": determinant_id},
690
+ resource_type="determinant",
691
+ resource_id=determinant_id,
692
+ )
693
+ except BPANotFoundError:
694
+ raise ToolError(
695
+ f"Determinant '{determinant_id}' not found. "
696
+ "Use 'determinant_list' with service_id to see determinants."
697
+ )
698
+
699
+ # Normalize previous_state to snake_case for consistency
700
+ normalized_previous_state = {
701
+ "id": previous_state.get("id"),
702
+ "name": previous_state.get("name"),
703
+ "operator": previous_state.get("operator"),
704
+ "target_form_field_key": previous_state.get("targetFormFieldKey"),
705
+ "condition_logic": previous_state.get("conditionLogic"),
706
+ "json_condition": previous_state.get("jsonCondition"),
707
+ }
708
+
709
+ # Create audit record BEFORE API call (audit-before-write pattern)
710
+ audit_logger = AuditLogger()
711
+ audit_id = await audit_logger.record_pending(
712
+ user_email=user_email,
713
+ operation_type="update",
714
+ object_type="textdeterminant",
715
+ object_id=str(determinant_id),
716
+ params={
717
+ "service_id": str(service_id),
718
+ "changes": validated_params,
719
+ },
720
+ )
721
+
722
+ # Save rollback state for undo capability
723
+ await audit_logger.save_rollback_state(
724
+ audit_id=audit_id,
725
+ object_type="textdeterminant",
726
+ object_id=str(determinant_id),
727
+ previous_state={
728
+ "id": previous_state.get("id"),
729
+ "name": previous_state.get("name"),
730
+ "operator": previous_state.get("operator"),
731
+ "targetFormFieldKey": previous_state.get("targetFormFieldKey"),
732
+ "conditionLogic": previous_state.get("conditionLogic"),
733
+ "jsonCondition": previous_state.get("jsonCondition"),
734
+ "serviceId": service_id,
735
+ },
736
+ )
737
+
738
+ try:
739
+ determinant_data = await client.put(
740
+ "/service/{service_id}/textdeterminant",
741
+ path_params={"service_id": service_id},
742
+ json=validated_params,
743
+ resource_type="determinant",
744
+ resource_id=determinant_id,
745
+ )
746
+
747
+ # Mark audit as success
748
+ await audit_logger.mark_success(
749
+ audit_id,
750
+ result={
751
+ "determinant_id": determinant_data.get("id"),
752
+ "name": determinant_data.get("name"),
753
+ "changes_applied": {
754
+ k: v for k, v in validated_params.items() if k != "id"
755
+ },
756
+ },
757
+ )
758
+
759
+ return {
760
+ "id": determinant_data.get("id"),
761
+ "name": determinant_data.get("name"),
762
+ "type": "text",
763
+ "operator": determinant_data.get("operator"),
764
+ "target_form_field_key": determinant_data.get("targetFormFieldKey"),
765
+ "condition_logic": determinant_data.get("conditionLogic"),
766
+ "json_condition": determinant_data.get("jsonCondition"),
767
+ "service_id": service_id,
768
+ "previous_state": normalized_previous_state,
769
+ "audit_id": audit_id,
770
+ }
771
+
772
+ except BPAClientError as e:
773
+ # Mark audit as failed
774
+ await audit_logger.mark_failed(audit_id, str(e))
775
+ raise translate_error(
776
+ e, resource_type="determinant", resource_id=determinant_id
777
+ )
778
+
779
+ except ToolError:
780
+ raise
781
+ except BPAClientError as e:
782
+ raise translate_error(
783
+ e, resource_type="determinant", resource_id=determinant_id
784
+ )
785
+
786
+
787
+ async def selectdeterminant_create(
788
+ service_id: str | int,
789
+ name: str,
790
+ operator: str,
791
+ target_form_field_key: str,
792
+ select_value: str,
793
+ condition_logic: str | None = None,
794
+ json_condition: str | None = None,
795
+ ) -> dict[str, Any]:
796
+ """Create select determinant in a service. Audited write operation.
797
+
798
+ Args:
799
+ service_id: Parent service ID.
800
+ name: Determinant name.
801
+ operator: EQUAL or NOT_EQUAL.
802
+ target_form_field_key: Form field key to evaluate.
803
+ select_value: Option value to match.
804
+ condition_logic: Optional condition expression.
805
+ json_condition: Optional JSON condition.
806
+
807
+ Returns:
808
+ dict with id, name, type, operator, select_value, service_id, audit_id.
809
+ """
810
+ # Pre-flight validation
811
+ validated_params = _validate_selectdeterminant_create_params(
812
+ service_id, name, operator, target_form_field_key, select_value
813
+ )
814
+
815
+ # Add optional parameters
816
+ if condition_logic is not None:
817
+ validated_params["conditionLogic"] = condition_logic
818
+ if json_condition is not None:
819
+ validated_params["jsonCondition"] = json_condition
820
+
821
+ # Get authenticated user for audit (before any API calls)
822
+ try:
823
+ user_email = get_current_user_email()
824
+ except NotAuthenticatedError as e:
825
+ raise ToolError(str(e))
826
+
827
+ # Use single BPAClient connection for all operations
828
+ try:
829
+ async with BPAClient() as client:
830
+ # Verify parent service exists before creating audit record
831
+ try:
832
+ await client.get(
833
+ "/service/{id}",
834
+ path_params={"id": service_id},
835
+ resource_type="service",
836
+ resource_id=service_id,
837
+ )
838
+ except BPANotFoundError:
839
+ raise ToolError(
840
+ f"Cannot create select determinant: Service '{service_id}' "
841
+ "not found. Use 'service_list' to see available services."
842
+ )
843
+
844
+ # Create audit record BEFORE API call (audit-before-write pattern)
845
+ audit_logger = AuditLogger()
846
+ audit_id = await audit_logger.record_pending(
847
+ user_email=user_email,
848
+ operation_type="create",
849
+ object_type="selectdeterminant",
850
+ params={
851
+ "service_id": str(service_id),
852
+ **validated_params,
853
+ },
854
+ )
855
+
856
+ try:
857
+ determinant_data = await client.post(
858
+ "/service/{service_id}/selectdeterminant",
859
+ path_params={"service_id": service_id},
860
+ json=validated_params,
861
+ resource_type="determinant",
862
+ )
863
+
864
+ # Save rollback state (for create, save ID to enable deletion)
865
+ created_id = determinant_data.get("id")
866
+ await audit_logger.save_rollback_state(
867
+ audit_id=audit_id,
868
+ object_type="selectdeterminant",
869
+ object_id=str(created_id),
870
+ previous_state={
871
+ "id": created_id,
872
+ "name": determinant_data.get("name"),
873
+ "operator": determinant_data.get("operator"),
874
+ "targetFormFieldKey": determinant_data.get(
875
+ "targetFormFieldKey"
876
+ ),
877
+ "selectValue": determinant_data.get("selectValue"),
878
+ "serviceId": str(service_id),
879
+ "_operation": "create", # Marker for rollback to DELETE
880
+ },
881
+ )
882
+
883
+ # Mark audit as success
884
+ await audit_logger.mark_success(
885
+ audit_id,
886
+ result={
887
+ "determinant_id": created_id,
888
+ "name": determinant_data.get("name"),
889
+ "service_id": str(service_id),
890
+ },
891
+ )
892
+
893
+ return {
894
+ "id": created_id,
895
+ "name": determinant_data.get("name"),
896
+ "type": "radio",
897
+ "operator": determinant_data.get("operator"),
898
+ "target_form_field_key": determinant_data.get("targetFormFieldKey"),
899
+ "select_value": determinant_data.get("selectValue"),
900
+ "condition_logic": determinant_data.get("conditionLogic"),
901
+ "json_condition": determinant_data.get("jsonCondition"),
902
+ "service_id": service_id,
903
+ "audit_id": audit_id,
904
+ }
905
+
906
+ except BPAClientError as e:
907
+ # Mark audit as failed
908
+ await audit_logger.mark_failed(audit_id, str(e))
909
+ raise translate_error(e, resource_type="determinant")
910
+
911
+ except ToolError:
912
+ raise
913
+ except BPAClientError as e:
914
+ raise translate_error(e, resource_type="service", resource_id=service_id)
915
+
916
+
917
+ # =============================================================================
918
+ # numericdeterminant_create
919
+ # =============================================================================
920
+
921
+
922
+ def _validate_numericdeterminant_create_params(
923
+ service_id: str | int,
924
+ name: str,
925
+ operator: str,
926
+ target_form_field_key: str,
927
+ numeric_value: int | float,
928
+ ) -> dict[str, Any]:
929
+ """Validate numericdeterminant_create parameters (pre-flight).
930
+
931
+ Returns validated params dict or raises ToolError if invalid.
932
+ No audit record is created for validation failures.
933
+
934
+ Args:
935
+ service_id: Parent service ID (required).
936
+ name: Determinant name (required).
937
+ operator: Comparison operator (required). Valid values: EQUAL, NOT_EQUAL,
938
+ GREATER_THAN, LESS_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL.
939
+ target_form_field_key: The form field key this determinant targets (required).
940
+ numeric_value: The numeric value to compare against (required).
941
+
942
+ Returns:
943
+ dict: Validated parameters ready for API call.
944
+
945
+ Raises:
946
+ ToolError: If validation fails.
947
+ """
948
+ errors = []
949
+
950
+ if not service_id:
951
+ errors.append("'service_id' is required")
952
+
953
+ if not name or not name.strip():
954
+ errors.append("'name' is required and cannot be empty")
955
+
956
+ if name and len(name.strip()) > 255:
957
+ errors.append("'name' must be 255 characters or less")
958
+
959
+ if not operator or not operator.strip():
960
+ errors.append("'operator' is required")
961
+
962
+ valid_operators = [
963
+ "EQUAL",
964
+ "NOT_EQUAL",
965
+ "GREATER_THAN",
966
+ "LESS_THAN",
967
+ "GREATER_THAN_OR_EQUAL",
968
+ "LESS_THAN_OR_EQUAL",
969
+ ]
970
+ # Normalize operator to canonical format (Story 10-4)
971
+ normalized_operator = _normalize_operator(operator) if operator else ""
972
+ if operator and normalized_operator not in valid_operators:
973
+ errors.append(f"'operator' must be one of: {', '.join(valid_operators)}")
974
+
975
+ if not target_form_field_key or not target_form_field_key.strip():
976
+ errors.append("'target_form_field_key' is required")
977
+
978
+ if numeric_value is None:
979
+ errors.append("'numeric_value' is required")
980
+ elif not isinstance(numeric_value, int | float):
981
+ errors.append("'numeric_value' must be a number (int or float)")
982
+
983
+ if errors:
984
+ error_msg = "; ".join(errors)
985
+ raise ToolError(
986
+ f"Cannot create numeric determinant: {error_msg}. "
987
+ "Provide valid 'service_id', 'name', 'operator', "
988
+ "'target_form_field_key', and 'numeric_value' parameters."
989
+ )
990
+
991
+ return {
992
+ "name": name.strip(),
993
+ "operator": normalized_operator,
994
+ "targetFormFieldKey": target_form_field_key.strip(),
995
+ "determinantType": "FORMFIELD",
996
+ "type": "numeric",
997
+ "numericValue": numeric_value,
998
+ "determinantInsideGrid": False,
999
+ }
1000
+
1001
+
1002
+ async def numericdeterminant_create(
1003
+ service_id: str | int,
1004
+ name: str,
1005
+ operator: str,
1006
+ target_form_field_key: str,
1007
+ numeric_value: int | float,
1008
+ ) -> dict[str, Any]:
1009
+ """Create numeric determinant in a service. Audited write operation.
1010
+
1011
+ Args:
1012
+ service_id: Parent service ID.
1013
+ name: Determinant name.
1014
+ operator: EQUAL, NOT_EQUAL, GREATER_THAN, LESS_THAN,
1015
+ GREATER_THAN_OR_EQUAL, or LESS_THAN_OR_EQUAL.
1016
+ target_form_field_key: Form field key to evaluate.
1017
+ numeric_value: Numeric value to compare against.
1018
+
1019
+ Returns:
1020
+ dict with id, name, type, operator, numeric_value, target_form_field_key,
1021
+ service_id, audit_id.
1022
+ """
1023
+ # Pre-flight validation (no audit record for validation failures)
1024
+ validated_params = _validate_numericdeterminant_create_params(
1025
+ service_id, name, operator, target_form_field_key, numeric_value
1026
+ )
1027
+
1028
+ # Get authenticated user for audit (before any API calls)
1029
+ try:
1030
+ user_email = get_current_user_email()
1031
+ except NotAuthenticatedError as e:
1032
+ raise ToolError(str(e))
1033
+
1034
+ # Use single BPAClient connection for all operations
1035
+ try:
1036
+ async with BPAClient() as client:
1037
+ # Verify parent service exists before creating audit record
1038
+ try:
1039
+ await client.get(
1040
+ "/service/{id}",
1041
+ path_params={"id": service_id},
1042
+ resource_type="service",
1043
+ resource_id=service_id,
1044
+ )
1045
+ except BPANotFoundError:
1046
+ raise ToolError(
1047
+ f"Cannot create numeric determinant: Service '{service_id}' "
1048
+ "not found. Use 'service_list' to see available services."
1049
+ )
1050
+
1051
+ # Create audit record BEFORE API call (audit-before-write pattern)
1052
+ audit_logger = AuditLogger()
1053
+ audit_id = await audit_logger.record_pending(
1054
+ user_email=user_email,
1055
+ operation_type="create",
1056
+ object_type="numericdeterminant",
1057
+ params={
1058
+ "service_id": str(service_id),
1059
+ **validated_params,
1060
+ },
1061
+ )
1062
+
1063
+ try:
1064
+ determinant_data = await client.post(
1065
+ "/service/{service_id}/numericdeterminant",
1066
+ path_params={"service_id": service_id},
1067
+ json=validated_params,
1068
+ resource_type="determinant",
1069
+ )
1070
+
1071
+ # Save rollback state (for create, save ID to enable deletion)
1072
+ created_id = determinant_data.get("id")
1073
+ await audit_logger.save_rollback_state(
1074
+ audit_id=audit_id,
1075
+ object_type="numericdeterminant",
1076
+ object_id=str(created_id),
1077
+ previous_state={
1078
+ "id": created_id,
1079
+ "name": determinant_data.get("name"),
1080
+ "operator": determinant_data.get("operator"),
1081
+ "targetFormFieldKey": determinant_data.get(
1082
+ "targetFormFieldKey"
1083
+ ),
1084
+ "numericValue": determinant_data.get("numericValue"),
1085
+ "serviceId": str(service_id),
1086
+ "_operation": "create", # Marker for rollback to DELETE
1087
+ },
1088
+ )
1089
+
1090
+ # Mark audit as success
1091
+ await audit_logger.mark_success(
1092
+ audit_id,
1093
+ result={
1094
+ "determinant_id": created_id,
1095
+ "name": determinant_data.get("name"),
1096
+ "service_id": str(service_id),
1097
+ },
1098
+ )
1099
+
1100
+ return {
1101
+ "id": created_id,
1102
+ "name": determinant_data.get("name"),
1103
+ "type": "numeric",
1104
+ "operator": determinant_data.get("operator"),
1105
+ "target_form_field_key": determinant_data.get("targetFormFieldKey"),
1106
+ "numeric_value": determinant_data.get("numericValue"),
1107
+ "service_id": service_id,
1108
+ "audit_id": audit_id,
1109
+ }
1110
+
1111
+ except BPAClientError as e:
1112
+ # Mark audit as failed
1113
+ await audit_logger.mark_failed(audit_id, str(e))
1114
+ raise translate_error(e, resource_type="determinant")
1115
+
1116
+ except ToolError:
1117
+ raise
1118
+ except BPAClientError as e:
1119
+ raise translate_error(e, resource_type="service", resource_id=service_id)
1120
+
1121
+
1122
+ # =============================================================================
1123
+ # booleandeterminant_create
1124
+ # =============================================================================
1125
+
1126
+
1127
+ def _validate_booleandeterminant_create_params(
1128
+ service_id: str | int,
1129
+ name: str,
1130
+ target_form_field_key: str,
1131
+ boolean_value: bool,
1132
+ ) -> dict[str, Any]:
1133
+ """Validate booleandeterminant_create parameters (pre-flight).
1134
+
1135
+ Returns validated params dict or raises ToolError if invalid.
1136
+ No audit record is created for validation failures.
1137
+
1138
+ Args:
1139
+ service_id: Parent service ID (required).
1140
+ name: Determinant name (required).
1141
+ target_form_field_key: The form field key this determinant targets (required).
1142
+ boolean_value: The boolean value to check (True/False) (required).
1143
+
1144
+ Returns:
1145
+ dict: Validated parameters ready for API call.
1146
+
1147
+ Raises:
1148
+ ToolError: If validation fails.
1149
+ """
1150
+ errors = []
1151
+
1152
+ if not service_id:
1153
+ errors.append("'service_id' is required")
1154
+
1155
+ if not name or not name.strip():
1156
+ errors.append("'name' is required and cannot be empty")
1157
+
1158
+ if name and len(name.strip()) > 255:
1159
+ errors.append("'name' must be 255 characters or less")
1160
+
1161
+ if not target_form_field_key or not target_form_field_key.strip():
1162
+ errors.append("'target_form_field_key' is required")
1163
+
1164
+ if boolean_value is None:
1165
+ errors.append("'boolean_value' is required")
1166
+ elif not isinstance(boolean_value, bool):
1167
+ errors.append("'boolean_value' must be a boolean (True or False)")
1168
+
1169
+ if errors:
1170
+ error_msg = "; ".join(errors)
1171
+ raise ToolError(
1172
+ f"Cannot create boolean determinant: {error_msg}. "
1173
+ "Provide valid 'service_id', 'name', 'target_form_field_key', "
1174
+ "and 'boolean_value' parameters."
1175
+ )
1176
+
1177
+ return {
1178
+ "name": name.strip(),
1179
+ "targetFormFieldKey": target_form_field_key.strip(),
1180
+ "determinantType": "FORMFIELD",
1181
+ "type": "boolean",
1182
+ "booleanValue": boolean_value,
1183
+ "determinantInsideGrid": False,
1184
+ }
1185
+
1186
+
1187
+ async def booleandeterminant_create(
1188
+ service_id: str | int,
1189
+ name: str,
1190
+ target_form_field_key: str,
1191
+ boolean_value: bool,
1192
+ ) -> dict[str, Any]:
1193
+ """Create boolean determinant in a service. Audited write operation.
1194
+
1195
+ Args:
1196
+ service_id: Parent service ID.
1197
+ name: Determinant name.
1198
+ target_form_field_key: Form field key to evaluate (checkbox field).
1199
+ boolean_value: Boolean value to check (True or False).
1200
+
1201
+ Returns:
1202
+ dict with id, name, type, boolean_value, target_form_field_key,
1203
+ service_id, audit_id.
1204
+ """
1205
+ # Pre-flight validation (no audit record for validation failures)
1206
+ validated_params = _validate_booleandeterminant_create_params(
1207
+ service_id, name, target_form_field_key, boolean_value
1208
+ )
1209
+
1210
+ # Get authenticated user for audit (before any API calls)
1211
+ try:
1212
+ user_email = get_current_user_email()
1213
+ except NotAuthenticatedError as e:
1214
+ raise ToolError(str(e))
1215
+
1216
+ # Use single BPAClient connection for all operations
1217
+ try:
1218
+ async with BPAClient() as client:
1219
+ # Verify parent service exists before creating audit record
1220
+ try:
1221
+ await client.get(
1222
+ "/service/{id}",
1223
+ path_params={"id": service_id},
1224
+ resource_type="service",
1225
+ resource_id=service_id,
1226
+ )
1227
+ except BPANotFoundError:
1228
+ raise ToolError(
1229
+ f"Cannot create boolean determinant: Service '{service_id}' "
1230
+ "not found. Use 'service_list' to see available services."
1231
+ )
1232
+
1233
+ # Create audit record BEFORE API call (audit-before-write pattern)
1234
+ audit_logger = AuditLogger()
1235
+ audit_id = await audit_logger.record_pending(
1236
+ user_email=user_email,
1237
+ operation_type="create",
1238
+ object_type="booleandeterminant",
1239
+ params={
1240
+ "service_id": str(service_id),
1241
+ **validated_params,
1242
+ },
1243
+ )
1244
+
1245
+ try:
1246
+ determinant_data = await client.post(
1247
+ "/service/{service_id}/booleandeterminant",
1248
+ path_params={"service_id": service_id},
1249
+ json=validated_params,
1250
+ resource_type="determinant",
1251
+ )
1252
+
1253
+ # Save rollback state (for create, save ID to enable deletion)
1254
+ created_id = determinant_data.get("id")
1255
+ await audit_logger.save_rollback_state(
1256
+ audit_id=audit_id,
1257
+ object_type="booleandeterminant",
1258
+ object_id=str(created_id),
1259
+ previous_state={
1260
+ "id": created_id,
1261
+ "name": determinant_data.get("name"),
1262
+ "targetFormFieldKey": determinant_data.get(
1263
+ "targetFormFieldKey"
1264
+ ),
1265
+ "booleanValue": determinant_data.get("booleanValue"),
1266
+ "serviceId": str(service_id),
1267
+ "_operation": "create", # Marker for rollback to DELETE
1268
+ },
1269
+ )
1270
+
1271
+ # Mark audit as success
1272
+ await audit_logger.mark_success(
1273
+ audit_id,
1274
+ result={
1275
+ "determinant_id": created_id,
1276
+ "name": determinant_data.get("name"),
1277
+ "service_id": str(service_id),
1278
+ },
1279
+ )
1280
+
1281
+ return {
1282
+ "id": created_id,
1283
+ "name": determinant_data.get("name"),
1284
+ "type": "boolean",
1285
+ "target_form_field_key": determinant_data.get("targetFormFieldKey"),
1286
+ "boolean_value": determinant_data.get("booleanValue"),
1287
+ "service_id": service_id,
1288
+ "audit_id": audit_id,
1289
+ }
1290
+
1291
+ except BPAClientError as e:
1292
+ # Mark audit as failed
1293
+ await audit_logger.mark_failed(audit_id, str(e))
1294
+ raise translate_error(e, resource_type="determinant")
1295
+
1296
+ except ToolError:
1297
+ raise
1298
+ except BPAClientError as e:
1299
+ raise translate_error(e, resource_type="service", resource_id=service_id)
1300
+
1301
+
1302
+ # =============================================================================
1303
+ # datedeterminant_create
1304
+ # =============================================================================
1305
+
1306
+ # Valid operators for date determinants
1307
+ DATE_DETERMINANT_OPERATORS = frozenset(
1308
+ {
1309
+ "EQUAL",
1310
+ "NOT_EQUAL",
1311
+ "GREATER_THAN",
1312
+ "LESS_THAN",
1313
+ "GREATER_THAN_OR_EQUAL",
1314
+ "LESS_THAN_OR_EQUAL",
1315
+ }
1316
+ )
1317
+
1318
+
1319
+ def _validate_datedeterminant_create_params(
1320
+ service_id: str | int,
1321
+ name: str,
1322
+ target_form_field_key: str,
1323
+ operator: str,
1324
+ is_current_date: bool | None = None,
1325
+ date_value: str | None = None,
1326
+ ) -> dict[str, Any]:
1327
+ """Validate datedeterminant_create parameters (pre-flight).
1328
+
1329
+ Returns validated params dict or raises ToolError if invalid.
1330
+ No audit record is created for validation failures.
1331
+
1332
+ Args:
1333
+ service_id: Parent service ID (required).
1334
+ name: Determinant name (required).
1335
+ target_form_field_key: Form field key to evaluate (required).
1336
+ operator: Comparison operator (required).
1337
+ is_current_date: Whether to compare against current date.
1338
+ date_value: Specific date to compare against (ISO 8601: YYYY-MM-DD).
1339
+
1340
+ Returns:
1341
+ dict: Validated parameters ready for API call.
1342
+
1343
+ Raises:
1344
+ ToolError: If validation fails.
1345
+ """
1346
+ import re
1347
+
1348
+ errors = []
1349
+
1350
+ if not service_id:
1351
+ errors.append("'service_id' is required")
1352
+
1353
+ if not name or not name.strip():
1354
+ errors.append("'name' is required and cannot be empty")
1355
+
1356
+ if name and len(name.strip()) > 255:
1357
+ errors.append("'name' must be 255 characters or less")
1358
+
1359
+ if not target_form_field_key or not target_form_field_key.strip():
1360
+ errors.append("'target_form_field_key' is required")
1361
+
1362
+ if not operator or not operator.strip():
1363
+ errors.append("'operator' is required")
1364
+ # Normalize operator to canonical format (Story 10-4)
1365
+ normalized_operator = _normalize_operator(operator) if operator else ""
1366
+ if operator and normalized_operator not in DATE_DETERMINANT_OPERATORS:
1367
+ valid_ops = ", ".join(sorted(DATE_DETERMINANT_OPERATORS))
1368
+ errors.append(f"'operator' must be one of: {valid_ops}")
1369
+
1370
+ # Must provide either is_current_date=True or a date_value
1371
+ if not is_current_date and not date_value:
1372
+ errors.append("Either 'is_current_date=True' or 'date_value' must be provided")
1373
+
1374
+ # Validate date format if provided
1375
+ if date_value:
1376
+ date_pattern = r"^\d{4}-\d{2}-\d{2}$"
1377
+ if not re.match(date_pattern, date_value.strip()):
1378
+ errors.append("'date_value' must be in ISO 8601 format (YYYY-MM-DD)")
1379
+
1380
+ if errors:
1381
+ error_msg = "; ".join(errors)
1382
+ raise ToolError(
1383
+ f"Cannot create date determinant: {error_msg}. "
1384
+ "Provide valid 'service_id', 'name', 'target_form_field_key', "
1385
+ "'operator', and either 'is_current_date=True' or 'date_value'."
1386
+ )
1387
+
1388
+ # Build API payload
1389
+ payload: dict[str, Any] = {
1390
+ "name": name.strip(),
1391
+ "targetFormFieldKey": target_form_field_key.strip(),
1392
+ "determinantType": "FORMFIELD",
1393
+ "type": "date",
1394
+ "operator": normalized_operator,
1395
+ "determinantInsideGrid": False,
1396
+ }
1397
+
1398
+ if is_current_date:
1399
+ payload["isCurrentDate"] = True
1400
+ else:
1401
+ payload["isCurrentDate"] = False
1402
+ payload["dateValue"] = date_value.strip() if date_value else None
1403
+
1404
+ return payload
1405
+
1406
+
1407
+ async def datedeterminant_create(
1408
+ service_id: str | int,
1409
+ name: str,
1410
+ target_form_field_key: str,
1411
+ operator: str,
1412
+ is_current_date: bool | None = None,
1413
+ date_value: str | None = None,
1414
+ ) -> dict[str, Any]:
1415
+ """Create date determinant in a service. Audited write operation.
1416
+
1417
+ Args:
1418
+ service_id: Parent service ID.
1419
+ name: Determinant name.
1420
+ target_form_field_key: Form field key to evaluate (date field).
1421
+ operator: Comparison operator (EQUAL, NOT_EQUAL, GREATER_THAN,
1422
+ LESS_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL).
1423
+ is_current_date: Compare against today's date (default: None).
1424
+ date_value: Specific date to compare (ISO 8601: YYYY-MM-DD).
1425
+
1426
+ Returns:
1427
+ dict with id, name, type, operator, target_form_field_key,
1428
+ is_current_date, date_value, service_id, audit_id.
1429
+ """
1430
+ # Pre-flight validation (no audit record for validation failures)
1431
+ validated_params = _validate_datedeterminant_create_params(
1432
+ service_id, name, target_form_field_key, operator, is_current_date, date_value
1433
+ )
1434
+
1435
+ # Get authenticated user for audit (before any API calls)
1436
+ try:
1437
+ user_email = get_current_user_email()
1438
+ except NotAuthenticatedError as e:
1439
+ raise ToolError(str(e))
1440
+
1441
+ # Use single BPAClient connection for all operations
1442
+ try:
1443
+ async with BPAClient() as client:
1444
+ # Verify parent service exists before creating audit record
1445
+ try:
1446
+ await client.get(
1447
+ "/service/{id}",
1448
+ path_params={"id": service_id},
1449
+ resource_type="service",
1450
+ resource_id=service_id,
1451
+ )
1452
+ except BPANotFoundError:
1453
+ raise ToolError(
1454
+ f"Cannot create date determinant: Service '{service_id}' "
1455
+ "not found. Use 'service_list' to see available services."
1456
+ )
1457
+
1458
+ # Create audit record BEFORE API call (audit-before-write pattern)
1459
+ audit_logger = AuditLogger()
1460
+ audit_id = await audit_logger.record_pending(
1461
+ user_email=user_email,
1462
+ operation_type="create",
1463
+ object_type="datedeterminant",
1464
+ params={
1465
+ "service_id": str(service_id),
1466
+ **validated_params,
1467
+ },
1468
+ )
1469
+
1470
+ try:
1471
+ determinant_data = await client.post(
1472
+ "/service/{service_id}/datedeterminant",
1473
+ path_params={"service_id": service_id},
1474
+ json=validated_params,
1475
+ resource_type="determinant",
1476
+ )
1477
+
1478
+ # Save rollback state (for create, save ID to enable deletion)
1479
+ created_id = determinant_data.get("id")
1480
+ await audit_logger.save_rollback_state(
1481
+ audit_id=audit_id,
1482
+ object_type="datedeterminant",
1483
+ object_id=str(created_id),
1484
+ previous_state={
1485
+ "id": created_id,
1486
+ "name": determinant_data.get("name"),
1487
+ "targetFormFieldKey": determinant_data.get(
1488
+ "targetFormFieldKey"
1489
+ ),
1490
+ "operator": determinant_data.get("operator"),
1491
+ "isCurrentDate": determinant_data.get("isCurrentDate"),
1492
+ "dateValue": determinant_data.get("dateValue"),
1493
+ "serviceId": str(service_id),
1494
+ "_operation": "create", # Marker for rollback to DELETE
1495
+ },
1496
+ )
1497
+
1498
+ # Mark audit as success
1499
+ await audit_logger.mark_success(
1500
+ audit_id,
1501
+ result={
1502
+ "determinant_id": created_id,
1503
+ "name": determinant_data.get("name"),
1504
+ "service_id": str(service_id),
1505
+ },
1506
+ )
1507
+
1508
+ return {
1509
+ "id": created_id,
1510
+ "name": determinant_data.get("name"),
1511
+ "type": "date",
1512
+ "operator": determinant_data.get("operator"),
1513
+ "target_form_field_key": determinant_data.get("targetFormFieldKey"),
1514
+ "is_current_date": determinant_data.get("isCurrentDate"),
1515
+ "date_value": determinant_data.get("dateValue"),
1516
+ "service_id": service_id,
1517
+ "audit_id": audit_id,
1518
+ }
1519
+
1520
+ except BPAClientError as e:
1521
+ # Mark audit as failed
1522
+ await audit_logger.mark_failed(audit_id, str(e))
1523
+ raise translate_error(e, resource_type="determinant")
1524
+
1525
+ except ToolError:
1526
+ raise
1527
+ except BPAClientError as e:
1528
+ raise translate_error(e, resource_type="service", resource_id=service_id)
1529
+
1530
+
1531
+ # =============================================================================
1532
+ # classificationdeterminant_create
1533
+ # =============================================================================
1534
+
1535
+ # Valid operators for classification determinants
1536
+ CLASSIFICATION_DETERMINANT_OPERATORS = frozenset({"EQUAL", "NOT_EQUAL"})
1537
+
1538
+ # Valid subjects for classification determinants
1539
+ CLASSIFICATION_DETERMINANT_SUBJECTS = frozenset({"ALL", "ANY", "NONE"})
1540
+
1541
+
1542
+ def _validate_classificationdeterminant_create_params(
1543
+ service_id: str | int,
1544
+ name: str,
1545
+ target_form_field_key: str,
1546
+ classification_field: str,
1547
+ operator: str,
1548
+ subject: str | None = None,
1549
+ ) -> dict[str, Any]:
1550
+ """Validate classificationdeterminant_create parameters (pre-flight).
1551
+
1552
+ Returns validated params dict or raises ToolError if invalid.
1553
+ No audit record is created for validation failures.
1554
+
1555
+ Args:
1556
+ service_id: Parent service ID (required).
1557
+ name: Determinant name (required).
1558
+ target_form_field_key: Form field key to evaluate (required).
1559
+ classification_field: Catalog field ID (required).
1560
+ operator: Comparison operator (EQUAL, NOT_EQUAL) (required).
1561
+ subject: How to evaluate multi-select (ALL, ANY, NONE) (default: ALL).
1562
+
1563
+ Returns:
1564
+ dict: Validated parameters ready for API call.
1565
+
1566
+ Raises:
1567
+ ToolError: If validation fails.
1568
+ """
1569
+ errors = []
1570
+
1571
+ if not service_id:
1572
+ errors.append("'service_id' is required")
1573
+
1574
+ if not name or not name.strip():
1575
+ errors.append("'name' is required and cannot be empty")
1576
+
1577
+ if name and len(name.strip()) > 255:
1578
+ errors.append("'name' must be 255 characters or less")
1579
+
1580
+ if not target_form_field_key or not target_form_field_key.strip():
1581
+ errors.append("'target_form_field_key' is required")
1582
+
1583
+ if not classification_field or not classification_field.strip():
1584
+ errors.append("'classification_field' is required")
1585
+
1586
+ if not operator or not operator.strip():
1587
+ errors.append("'operator' is required")
1588
+ elif operator.strip().upper() not in CLASSIFICATION_DETERMINANT_OPERATORS:
1589
+ valid_ops = ", ".join(sorted(CLASSIFICATION_DETERMINANT_OPERATORS))
1590
+ errors.append(f"'operator' must be one of: {valid_ops}")
1591
+
1592
+ # Default subject to ALL if not provided
1593
+ resolved_subject = subject.strip().upper() if subject else "ALL"
1594
+ if resolved_subject not in CLASSIFICATION_DETERMINANT_SUBJECTS:
1595
+ valid_subjects = ", ".join(sorted(CLASSIFICATION_DETERMINANT_SUBJECTS))
1596
+ errors.append(f"'subject' must be one of: {valid_subjects}")
1597
+
1598
+ if errors:
1599
+ error_msg = "; ".join(errors)
1600
+ raise ToolError(
1601
+ f"Cannot create classification determinant: {error_msg}. "
1602
+ "Provide valid 'service_id', 'name', 'target_form_field_key', "
1603
+ "'classification_field', 'operator', and optionally 'subject'."
1604
+ )
1605
+
1606
+ return {
1607
+ "name": name.strip(),
1608
+ "targetFormFieldKey": target_form_field_key.strip(),
1609
+ "determinantType": "FORMFIELD",
1610
+ "type": "classification",
1611
+ "operator": operator.strip().upper(),
1612
+ "subject": resolved_subject,
1613
+ "classificationField": classification_field.strip(),
1614
+ "determinantInsideGrid": False,
1615
+ }
1616
+
1617
+
1618
+ async def classificationdeterminant_create(
1619
+ service_id: str | int,
1620
+ name: str,
1621
+ target_form_field_key: str,
1622
+ classification_field: str,
1623
+ operator: str,
1624
+ subject: str | None = None,
1625
+ ) -> dict[str, Any]:
1626
+ """Create classification determinant in a service. Audited write operation.
1627
+
1628
+ Args:
1629
+ service_id: Parent service ID.
1630
+ name: Determinant name.
1631
+ target_form_field_key: Form field key to evaluate.
1632
+ classification_field: Catalog field ID (UUID).
1633
+ operator: Comparison operator (EQUAL, NOT_EQUAL).
1634
+ subject: How to evaluate multi-select (ALL, ANY, NONE; default: ALL).
1635
+
1636
+ Returns:
1637
+ dict with id, name, type, operator, subject, target_form_field_key,
1638
+ classification_field, service_id, audit_id.
1639
+ """
1640
+ # Pre-flight validation (no audit record for validation failures)
1641
+ validated_params = _validate_classificationdeterminant_create_params(
1642
+ service_id, name, target_form_field_key, classification_field, operator, subject
1643
+ )
1644
+
1645
+ # Get authenticated user for audit (before any API calls)
1646
+ try:
1647
+ user_email = get_current_user_email()
1648
+ except NotAuthenticatedError as e:
1649
+ raise ToolError(str(e))
1650
+
1651
+ # Use single BPAClient connection for all operations
1652
+ try:
1653
+ async with BPAClient() as client:
1654
+ # Verify parent service exists before creating audit record
1655
+ try:
1656
+ await client.get(
1657
+ "/service/{id}",
1658
+ path_params={"id": service_id},
1659
+ resource_type="service",
1660
+ resource_id=service_id,
1661
+ )
1662
+ except BPANotFoundError:
1663
+ raise ToolError(
1664
+ f"Cannot create classification determinant: Service '{service_id}' "
1665
+ "not found. Use 'service_list' to see available services."
1666
+ )
1667
+
1668
+ # Create audit record BEFORE API call (audit-before-write pattern)
1669
+ audit_logger = AuditLogger()
1670
+ audit_id = await audit_logger.record_pending(
1671
+ user_email=user_email,
1672
+ operation_type="create",
1673
+ object_type="classificationdeterminant",
1674
+ params={
1675
+ "service_id": str(service_id),
1676
+ **validated_params,
1677
+ },
1678
+ )
1679
+
1680
+ try:
1681
+ determinant_data = await client.post(
1682
+ "/service/{service_id}/classificationdeterminant",
1683
+ path_params={"service_id": service_id},
1684
+ json=validated_params,
1685
+ resource_type="determinant",
1686
+ )
1687
+
1688
+ # Save rollback state (for create, save ID to enable deletion)
1689
+ created_id = determinant_data.get("id")
1690
+ await audit_logger.save_rollback_state(
1691
+ audit_id=audit_id,
1692
+ object_type="classificationdeterminant",
1693
+ object_id=str(created_id),
1694
+ previous_state={
1695
+ "id": created_id,
1696
+ "name": determinant_data.get("name"),
1697
+ "targetFormFieldKey": determinant_data.get(
1698
+ "targetFormFieldKey"
1699
+ ),
1700
+ "operator": determinant_data.get("operator"),
1701
+ "subject": determinant_data.get("subject"),
1702
+ "classificationField": determinant_data.get(
1703
+ "classificationField"
1704
+ ),
1705
+ "serviceId": str(service_id),
1706
+ "_operation": "create", # Marker for rollback to DELETE
1707
+ },
1708
+ )
1709
+
1710
+ # Mark audit as success
1711
+ await audit_logger.mark_success(
1712
+ audit_id,
1713
+ result={
1714
+ "determinant_id": created_id,
1715
+ "name": determinant_data.get("name"),
1716
+ "service_id": str(service_id),
1717
+ },
1718
+ )
1719
+
1720
+ return {
1721
+ "id": created_id,
1722
+ "name": determinant_data.get("name"),
1723
+ "type": "classification",
1724
+ "operator": determinant_data.get("operator"),
1725
+ "subject": determinant_data.get("subject"),
1726
+ "target_form_field_key": determinant_data.get("targetFormFieldKey"),
1727
+ "classification_field": determinant_data.get("classificationField"),
1728
+ "service_id": service_id,
1729
+ "audit_id": audit_id,
1730
+ }
1731
+
1732
+ except BPAClientError as e:
1733
+ # Mark audit as failed
1734
+ await audit_logger.mark_failed(audit_id, str(e))
1735
+ raise translate_error(e, resource_type="determinant")
1736
+
1737
+ except ToolError:
1738
+ raise
1739
+ except BPAClientError as e:
1740
+ raise translate_error(e, resource_type="service", resource_id=service_id)
1741
+
1742
+
1743
+ # =============================================================================
1744
+ # griddeterminant_create
1745
+ # =============================================================================
1746
+
1747
+
1748
+ def _validate_griddeterminant_create_params(
1749
+ service_id: str | int,
1750
+ name: str,
1751
+ target_form_field_key: str,
1752
+ row_determinant_id: str,
1753
+ ) -> dict[str, Any]:
1754
+ """Validate griddeterminant_create parameters (pre-flight).
1755
+
1756
+ Returns validated params dict or raises ToolError if invalid.
1757
+ No audit record is created for validation failures.
1758
+
1759
+ Args:
1760
+ service_id: Parent service ID (required).
1761
+ name: Determinant name (required).
1762
+ target_form_field_key: Grid component key to evaluate (required).
1763
+ row_determinant_id: ID of the determinant that evaluates each row (required).
1764
+
1765
+ Returns:
1766
+ dict: Validated parameters ready for API call.
1767
+
1768
+ Raises:
1769
+ ToolError: If validation fails.
1770
+ """
1771
+ errors = []
1772
+
1773
+ if not service_id:
1774
+ errors.append("'service_id' is required")
1775
+
1776
+ if not name or not name.strip():
1777
+ errors.append("'name' is required and cannot be empty")
1778
+
1779
+ if name and len(name.strip()) > 255:
1780
+ errors.append("'name' must be 255 characters or less")
1781
+
1782
+ if not target_form_field_key or not target_form_field_key.strip():
1783
+ errors.append("'target_form_field_key' is required")
1784
+
1785
+ if not row_determinant_id or not row_determinant_id.strip():
1786
+ errors.append("'row_determinant_id' is required")
1787
+
1788
+ if errors:
1789
+ error_msg = "; ".join(errors)
1790
+ raise ToolError(
1791
+ f"Cannot create grid determinant: {error_msg}. "
1792
+ "Provide valid 'service_id', 'name', 'target_form_field_key', "
1793
+ "and 'row_determinant_id' parameters."
1794
+ )
1795
+
1796
+ return {
1797
+ "name": name.strip(),
1798
+ "targetFormFieldKey": target_form_field_key.strip(),
1799
+ "determinantType": "FORMFIELD",
1800
+ "type": "grid",
1801
+ "determinantInsideGrid": True,
1802
+ "rowDeterminantId": row_determinant_id.strip(),
1803
+ }
1804
+
1805
+
1806
+ async def griddeterminant_create(
1807
+ service_id: str | int,
1808
+ name: str,
1809
+ target_form_field_key: str,
1810
+ row_determinant_id: str,
1811
+ ) -> dict[str, Any]:
1812
+ """Create grid determinant in a service. Audited write operation.
1813
+
1814
+ Args:
1815
+ service_id: Parent service ID.
1816
+ name: Determinant name.
1817
+ target_form_field_key: Grid component key to evaluate.
1818
+ row_determinant_id: ID of the determinant evaluating each row.
1819
+
1820
+ Returns:
1821
+ dict with id, name, type, target_form_field_key, determinant_inside_grid,
1822
+ row_determinant_id, service_id, audit_id.
1823
+ """
1824
+ # Pre-flight validation (no audit record for validation failures)
1825
+ validated_params = _validate_griddeterminant_create_params(
1826
+ service_id, name, target_form_field_key, row_determinant_id
1827
+ )
1828
+
1829
+ # Get authenticated user for audit (before any API calls)
1830
+ try:
1831
+ user_email = get_current_user_email()
1832
+ except NotAuthenticatedError as e:
1833
+ raise ToolError(str(e))
1834
+
1835
+ # Use single BPAClient connection for all operations
1836
+ try:
1837
+ async with BPAClient() as client:
1838
+ # Verify parent service exists before creating audit record
1839
+ try:
1840
+ await client.get(
1841
+ "/service/{id}",
1842
+ path_params={"id": service_id},
1843
+ resource_type="service",
1844
+ resource_id=service_id,
1845
+ )
1846
+ except BPANotFoundError:
1847
+ raise ToolError(
1848
+ f"Cannot create grid determinant: Service '{service_id}' "
1849
+ "not found. Use 'service_list' to see available services."
1850
+ )
1851
+
1852
+ # Verify row determinant exists before creating audit record
1853
+ try:
1854
+ await client.get(
1855
+ "/determinant/{id}",
1856
+ path_params={"id": row_determinant_id},
1857
+ resource_type="determinant",
1858
+ resource_id=row_determinant_id,
1859
+ )
1860
+ except BPANotFoundError:
1861
+ raise ToolError(
1862
+ f"Cannot create grid determinant: Row determinant "
1863
+ f"'{row_determinant_id}' not found. Create the row determinant "
1864
+ "first using textdeterminant_create, selectdeterminant_create, "
1865
+ "or similar tools."
1866
+ )
1867
+
1868
+ # Create audit record BEFORE API call (audit-before-write pattern)
1869
+ audit_logger = AuditLogger()
1870
+ audit_id = await audit_logger.record_pending(
1871
+ user_email=user_email,
1872
+ operation_type="create",
1873
+ object_type="griddeterminant",
1874
+ params={
1875
+ "service_id": str(service_id),
1876
+ **validated_params,
1877
+ },
1878
+ )
1879
+
1880
+ try:
1881
+ determinant_data = await client.post(
1882
+ "/service/{service_id}/griddeterminant",
1883
+ path_params={"service_id": service_id},
1884
+ json=validated_params,
1885
+ resource_type="determinant",
1886
+ )
1887
+
1888
+ # Save rollback state (for create, save ID to enable deletion)
1889
+ created_id = determinant_data.get("id")
1890
+ await audit_logger.save_rollback_state(
1891
+ audit_id=audit_id,
1892
+ object_type="griddeterminant",
1893
+ object_id=str(created_id),
1894
+ previous_state={
1895
+ "id": created_id,
1896
+ "name": determinant_data.get("name"),
1897
+ "targetFormFieldKey": determinant_data.get(
1898
+ "targetFormFieldKey"
1899
+ ),
1900
+ "rowDeterminantId": determinant_data.get("rowDeterminantId"),
1901
+ "determinantInsideGrid": determinant_data.get(
1902
+ "determinantInsideGrid"
1903
+ ),
1904
+ "serviceId": str(service_id),
1905
+ "_operation": "create", # Marker for rollback to DELETE
1906
+ },
1907
+ )
1908
+
1909
+ # Mark audit as success
1910
+ await audit_logger.mark_success(
1911
+ audit_id,
1912
+ result={
1913
+ "determinant_id": created_id,
1914
+ "name": determinant_data.get("name"),
1915
+ "service_id": str(service_id),
1916
+ },
1917
+ )
1918
+
1919
+ return {
1920
+ "id": created_id,
1921
+ "name": determinant_data.get("name"),
1922
+ "type": "grid",
1923
+ "target_form_field_key": determinant_data.get("targetFormFieldKey"),
1924
+ "determinant_inside_grid": determinant_data.get(
1925
+ "determinantInsideGrid"
1926
+ ),
1927
+ "row_determinant_id": determinant_data.get("rowDeterminantId"),
1928
+ "service_id": service_id,
1929
+ "audit_id": audit_id,
1930
+ }
1931
+
1932
+ except BPAClientError as e:
1933
+ # Mark audit as failed
1934
+ await audit_logger.mark_failed(audit_id, str(e))
1935
+ raise translate_error(e, resource_type="determinant")
1936
+
1937
+ except ToolError:
1938
+ raise
1939
+ except BPAClientError as e:
1940
+ raise translate_error(e, resource_type="service", resource_id=service_id)
1941
+
1942
+
1943
+ # =============================================================================
1944
+ # determinant_delete
1945
+ # =============================================================================
1946
+
1947
+
1948
+ def _validate_determinant_delete_params(
1949
+ service_id: str | int, determinant_id: str | int
1950
+ ) -> None:
1951
+ """Validate determinant_delete parameters before processing.
1952
+
1953
+ Args:
1954
+ service_id: ID of the service containing the determinant.
1955
+ determinant_id: ID of the determinant to delete.
1956
+
1957
+ Raises:
1958
+ ToolError: If validation fails.
1959
+ """
1960
+ if not service_id or (isinstance(service_id, str) and not service_id.strip()):
1961
+ raise ToolError(
1962
+ "'service_id' is required. Use 'service_list' to see available services."
1963
+ )
1964
+ if not determinant_id or (
1965
+ isinstance(determinant_id, str) and not determinant_id.strip()
1966
+ ):
1967
+ raise ToolError(
1968
+ "'determinant_id' is required. "
1969
+ "Use 'determinant_list' with service_id to see available determinants."
1970
+ )
1971
+
1972
+
1973
+ async def determinant_delete(
1974
+ service_id: str | int, determinant_id: str | int
1975
+ ) -> dict[str, Any]:
1976
+ """Delete a determinant. Audited write operation.
1977
+
1978
+ Args:
1979
+ service_id: Service ID containing the determinant.
1980
+ determinant_id: Determinant ID to delete.
1981
+
1982
+ Returns:
1983
+ dict with deleted (bool), determinant_id, service_id, deleted_determinant,
1984
+ audit_id.
1985
+ """
1986
+ # Pre-flight validation (no audit record for validation failures)
1987
+ _validate_determinant_delete_params(service_id, determinant_id)
1988
+
1989
+ # Get authenticated user for audit
1990
+ try:
1991
+ user_email = get_current_user_email()
1992
+ except NotAuthenticatedError as e:
1993
+ raise ToolError(str(e))
1994
+
1995
+ # Use single BPAClient connection for all operations
1996
+ try:
1997
+ async with BPAClient() as client:
1998
+ # Capture current state for rollback BEFORE making changes
1999
+ try:
2000
+ previous_state = await client.get(
2001
+ "/determinant/{determinant_id}",
2002
+ path_params={"determinant_id": determinant_id},
2003
+ resource_type="determinant",
2004
+ resource_id=determinant_id,
2005
+ )
2006
+ except BPANotFoundError:
2007
+ raise ToolError(
2008
+ f"Determinant '{determinant_id}' not found. "
2009
+ "Use 'determinant_list' with service_id to see available "
2010
+ "determinants."
2011
+ )
2012
+
2013
+ # Create audit record BEFORE API call (audit-before-write pattern)
2014
+ audit_logger = AuditLogger()
2015
+ audit_id = await audit_logger.record_pending(
2016
+ user_email=user_email,
2017
+ operation_type="delete",
2018
+ object_type="determinant",
2019
+ object_id=str(determinant_id),
2020
+ params={"service_id": str(service_id)},
2021
+ )
2022
+
2023
+ # Save rollback state for undo capability (recreate on rollback)
2024
+ await audit_logger.save_rollback_state(
2025
+ audit_id=audit_id,
2026
+ object_type="determinant",
2027
+ object_id=str(determinant_id),
2028
+ previous_state=previous_state, # Keep full state for recreation
2029
+ )
2030
+
2031
+ try:
2032
+ await client.delete(
2033
+ "/service/{service_id}/determinant/{determinant_id}",
2034
+ path_params={
2035
+ "service_id": service_id,
2036
+ "determinant_id": determinant_id,
2037
+ },
2038
+ resource_type="determinant",
2039
+ resource_id=determinant_id,
2040
+ )
2041
+
2042
+ # Mark audit as success
2043
+ await audit_logger.mark_success(
2044
+ audit_id,
2045
+ result={
2046
+ "deleted": True,
2047
+ "determinant_id": str(determinant_id),
2048
+ "service_id": str(service_id),
2049
+ },
2050
+ )
2051
+
2052
+ return {
2053
+ "deleted": True,
2054
+ "determinant_id": str(determinant_id),
2055
+ "service_id": str(service_id),
2056
+ "deleted_determinant": {
2057
+ "id": previous_state.get("id"),
2058
+ "name": previous_state.get("name"),
2059
+ "type": previous_state.get("type"),
2060
+ "operator": previous_state.get("operator"),
2061
+ "target_form_field_key": previous_state.get(
2062
+ "targetFormFieldKey"
2063
+ ),
2064
+ },
2065
+ "audit_id": audit_id,
2066
+ }
2067
+
2068
+ except BPAClientError as e:
2069
+ # Mark audit as failed
2070
+ await audit_logger.mark_failed(audit_id, str(e))
2071
+ raise translate_error(
2072
+ e, resource_type="determinant", resource_id=determinant_id
2073
+ )
2074
+
2075
+ except ToolError:
2076
+ raise
2077
+ except BPAClientError as e:
2078
+ raise translate_error(
2079
+ e, resource_type="determinant", resource_id=determinant_id
2080
+ )
2081
+
2082
+
2083
+ # =============================================================================
2084
+ # determinant_search
2085
+ # =============================================================================
2086
+
2087
+
2088
+ async def determinant_search(
2089
+ service_id: str | int,
2090
+ name_pattern: str | None = None,
2091
+ determinant_type: str | None = None,
2092
+ operator: str | None = None,
2093
+ target_field_key: str | None = None,
2094
+ limit: int = 20,
2095
+ ) -> dict[str, Any]:
2096
+ """Search determinants by criteria to discover reusable conditions.
2097
+
2098
+ This read-only tool helps find existing determinants before creating new ones,
2099
+ promoting reuse and consistency.
2100
+
2101
+ Args:
2102
+ service_id: Service ID to search within.
2103
+ name_pattern: Substring to match in determinant names (case-insensitive).
2104
+ determinant_type: Filter by type (text, boolean, date, radio, numeric,
2105
+ classification, grid).
2106
+ operator: Filter by operator (e.g., EQUAL, NOT_EQUAL, GREATER_THAN).
2107
+ target_field_key: Filter by target form field key.
2108
+ limit: Maximum results to return (default: 20, max: 100).
2109
+
2110
+ Returns:
2111
+ dict with determinants, total, returned, service_id, filters_applied.
2112
+ """
2113
+ import re
2114
+
2115
+ # Validate limit
2116
+ if limit < 1:
2117
+ limit = 20
2118
+ elif limit > 100:
2119
+ limit = 100
2120
+
2121
+ try:
2122
+ async with BPAClient() as client:
2123
+ # Fetch all determinants for the service
2124
+ determinants_data = await client.get_list(
2125
+ "/service/{service_id}/determinant",
2126
+ path_params={"service_id": service_id},
2127
+ resource_type="determinant",
2128
+ )
2129
+ except BPANotFoundError:
2130
+ raise ToolError(
2131
+ f"Service '{service_id}' not found. "
2132
+ "Use 'service_list' to see available services."
2133
+ )
2134
+ except BPAClientError as e:
2135
+ raise translate_error(e, resource_type="determinant")
2136
+
2137
+ # Apply filters
2138
+ filtered_determinants = []
2139
+ for det in determinants_data:
2140
+ # Filter by name pattern (case-insensitive substring match)
2141
+ if name_pattern:
2142
+ det_name = det.get("name", "") or ""
2143
+ if not re.search(re.escape(name_pattern), det_name, re.IGNORECASE):
2144
+ continue
2145
+
2146
+ # Filter by type
2147
+ if determinant_type:
2148
+ if det.get("type", "").lower() != determinant_type.lower():
2149
+ continue
2150
+
2151
+ # Filter by operator
2152
+ if operator:
2153
+ det_operator = det.get("operator", "") or ""
2154
+ if det_operator.upper() != operator.upper():
2155
+ continue
2156
+
2157
+ # Filter by target field key
2158
+ if target_field_key:
2159
+ det_field = det.get("targetFormFieldKey", "") or ""
2160
+ if det_field != target_field_key:
2161
+ continue
2162
+
2163
+ # Transform to consistent output format with snake_case keys
2164
+ transformed = {
2165
+ "id": det.get("id"),
2166
+ "name": det.get("name"),
2167
+ "type": det.get("type"),
2168
+ "operator": det.get("operator"),
2169
+ "target_field_key": det.get("targetFormFieldKey"),
2170
+ "condition_summary": det.get("conditionSummary"),
2171
+ }
2172
+
2173
+ # Add type-specific value fields
2174
+ if det.get("textValue") is not None:
2175
+ transformed["text_value"] = det.get("textValue")
2176
+ if det.get("selectValue") is not None:
2177
+ transformed["select_value"] = det.get("selectValue")
2178
+ if det.get("numericValue") is not None:
2179
+ transformed["numeric_value"] = det.get("numericValue")
2180
+ if det.get("booleanValue") is not None:
2181
+ transformed["boolean_value"] = det.get("booleanValue")
2182
+ if det.get("dateValue") is not None:
2183
+ transformed["date_value"] = det.get("dateValue")
2184
+ if det.get("isCurrentDate") is not None:
2185
+ transformed["is_current_date"] = det.get("isCurrentDate")
2186
+
2187
+ filtered_determinants.append(transformed)
2188
+
2189
+ # Sort by name for consistent ordering
2190
+ filtered_determinants.sort(key=lambda d: (d.get("name") or "").lower())
2191
+
2192
+ # Apply limit
2193
+ total_matches = len(filtered_determinants)
2194
+ limited_determinants = filtered_determinants[:limit]
2195
+
2196
+ # Build filters_applied for response
2197
+ filters_applied: dict[str, Any] = {}
2198
+ if name_pattern:
2199
+ filters_applied["name_pattern"] = name_pattern
2200
+ if determinant_type:
2201
+ filters_applied["determinant_type"] = determinant_type
2202
+ if operator:
2203
+ filters_applied["operator"] = operator
2204
+ if target_field_key:
2205
+ filters_applied["target_field_key"] = target_field_key
2206
+
2207
+ return {
2208
+ "determinants": limited_determinants,
2209
+ "total": total_matches,
2210
+ "returned": len(limited_determinants),
2211
+ "service_id": service_id,
2212
+ "filters_applied": filters_applied,
2213
+ }
2214
+
2215
+
2216
+ def register_determinant_tools(mcp: Any) -> None:
2217
+ """Register determinant tools with the MCP server.
2218
+
2219
+ Args:
2220
+ mcp: The FastMCP server instance.
2221
+ """
2222
+ # Read operations
2223
+ mcp.tool()(determinant_list)
2224
+ mcp.tool()(determinant_get)
2225
+ mcp.tool()(determinant_search)
2226
+ # Write operations (audit-before-write pattern)
2227
+ mcp.tool()(textdeterminant_create)
2228
+ mcp.tool()(textdeterminant_update)
2229
+ mcp.tool()(selectdeterminant_create)
2230
+ mcp.tool()(numericdeterminant_create)
2231
+ mcp.tool()(booleandeterminant_create)
2232
+ mcp.tool()(datedeterminant_create)
2233
+ mcp.tool()(classificationdeterminant_create)
2234
+ mcp.tool()(griddeterminant_create)
2235
+ mcp.tool()(determinant_delete)