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,616 @@
1
+ """Rollback manager for BPA operations.
2
+
3
+ This module provides the RollbackManager class for executing rollback operations
4
+ on BPA objects. It handles endpoint resolution, state retrieval, and API calls
5
+ to restore objects to their previous state.
6
+
7
+ Rollback strategies by operation type:
8
+ - create: DELETE the created object
9
+ - update: PUT with previous_state values to restore
10
+ - delete: POST to recreate object with previous_state
11
+ - link: Reverse the link operation (unlink)
12
+ - unlink: Reverse the unlink operation (link)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from datetime import UTC, datetime
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from mcp_eregistrations_bpa.bpa_client import BPAClient
23
+ from mcp_eregistrations_bpa.bpa_client.errors import BPAClientError
24
+ from mcp_eregistrations_bpa.db import get_connection
25
+
26
+ __all__ = [
27
+ "RollbackManager",
28
+ "RollbackError",
29
+ "RollbackNotPossibleError",
30
+ "ROLLBACK_ENDPOINTS",
31
+ ]
32
+
33
+
34
+ class RollbackError(Exception):
35
+ """Base exception for rollback operations."""
36
+
37
+ pass
38
+
39
+
40
+ class RollbackNotPossibleError(RollbackError):
41
+ """Raised when rollback cannot be performed for a given audit entry."""
42
+
43
+ pass
44
+
45
+
46
+ # Mapping: object_type -> (delete_endpoint, update_endpoint, create_endpoint)
47
+ # Endpoints use {id}, {service_id}, {registration_id} placeholders for substitution
48
+ # IMPORTANT: These endpoints must match the actual BPA API specification
49
+ # See: _bmad-output/implementation-artifacts/bpa-api-reference.md
50
+ ROLLBACK_ENDPOINTS: dict[str, tuple[str | None, str | None, str | None]] = {
51
+ # Service: PUT /service with ID in body, DELETE /service/{id}
52
+ "service": ("/service/{id}", "/service", "/service"),
53
+ # Registration: No PUT endpoint exists in BPA API, create via service
54
+ "registration": (
55
+ "/registration/{id}",
56
+ None, # No PUT /registration endpoint in BPA API
57
+ "/service/{service_id}/registration",
58
+ ),
59
+ # Determinants: Scoped to service for both update and create
60
+ "textdeterminant": (
61
+ None,
62
+ "/service/{service_id}/textdeterminant",
63
+ "/service/{service_id}/textdeterminant",
64
+ ),
65
+ "selectdeterminant": (
66
+ None,
67
+ None,
68
+ "/service/{service_id}/selectdeterminant",
69
+ ),
70
+ # Bot: No DELETE endpoint, PUT /bot with ID in body
71
+ "bot": (
72
+ None, # No DELETE /bot endpoint in BPA API
73
+ "/bot",
74
+ "/service/{service_id}/bot",
75
+ ),
76
+ # Role: DELETE /role/{id}, PUT /role with ID in body
77
+ "role": (
78
+ "/role/{id}",
79
+ "/role",
80
+ "/service/{service_id}/role",
81
+ ),
82
+ # Document Requirement: Uses underscore in endpoint path
83
+ "documentrequirement": (
84
+ "/document_requirement/{id}",
85
+ "/document_requirement",
86
+ "/registration/{registration_id}/document_requirement",
87
+ ),
88
+ # Cost: DELETE /cost/{id}, PUT /cost with ID in body
89
+ # Note: Create has fixcost/formulacost variants, handled by cost_type param
90
+ "cost": (
91
+ "/cost/{id}",
92
+ "/cost",
93
+ None, # Create endpoint depends on cost_type, handled in _get_create_endpoint
94
+ ),
95
+ # Message: Global templates, DELETE /message/{id}, PUT /message, POST /message
96
+ "message": (
97
+ "/message/{id}",
98
+ "/message",
99
+ "/message",
100
+ ),
101
+ }
102
+
103
+ # Alias for document_requirement (audit logs use underscore variant)
104
+ ROLLBACK_ENDPOINTS["document_requirement"] = ROLLBACK_ENDPOINTS["documentrequirement"]
105
+
106
+
107
+ class RollbackManager:
108
+ """Manages rollback operations for BPA write operations.
109
+
110
+ This class handles:
111
+ 1. Validation of rollback eligibility
112
+ 2. Retrieval of previous state from rollback_states
113
+ 3. Endpoint resolution based on object type
114
+ 4. Execution of the rollback via BPA API
115
+ 5. Marking the original operation as rolled back
116
+ """
117
+
118
+ def __init__(self, db_path: Path | None = None) -> None:
119
+ """Initialize the RollbackManager.
120
+
121
+ Args:
122
+ db_path: Optional path to SQLite database. Uses default if not provided.
123
+ """
124
+ self._db_path = db_path
125
+
126
+ async def _get_audit_entry(self, audit_id: str) -> dict[str, Any] | None:
127
+ """Fetch an audit entry by ID.
128
+
129
+ Args:
130
+ audit_id: The UUID of the audit entry.
131
+
132
+ Returns:
133
+ Dict with audit entry data, or None if not found.
134
+ """
135
+ async with get_connection(self._db_path) as conn:
136
+ cursor = await conn.execute(
137
+ """
138
+ SELECT id, timestamp, user_email, operation_type, object_type,
139
+ object_id, params, status, result, rollback_state_id
140
+ FROM audit_logs
141
+ WHERE id = ?
142
+ """,
143
+ (audit_id,),
144
+ )
145
+ row = await cursor.fetchone()
146
+ if row is None:
147
+ return None
148
+ return dict(row)
149
+
150
+ async def _get_rollback_state(
151
+ self, rollback_state_id: str
152
+ ) -> dict[str, Any] | None:
153
+ """Fetch a rollback state by ID.
154
+
155
+ Args:
156
+ rollback_state_id: The UUID of the rollback state.
157
+
158
+ Returns:
159
+ Dict with rollback state data, or None if not found.
160
+ """
161
+ async with get_connection(self._db_path) as conn:
162
+ cursor = await conn.execute(
163
+ """
164
+ SELECT id, audit_log_id, object_type, object_id,
165
+ previous_state, created_at
166
+ FROM rollback_states
167
+ WHERE id = ?
168
+ """,
169
+ (rollback_state_id,),
170
+ )
171
+ row = await cursor.fetchone()
172
+ if row is None:
173
+ return None
174
+ return dict(row)
175
+
176
+ def _check_already_rolled_back(self, result: dict[str, Any] | None) -> bool:
177
+ """Check if an operation was already rolled back.
178
+
179
+ Args:
180
+ result: The result field from audit_logs (parsed JSON).
181
+
182
+ Returns:
183
+ True if already rolled back, False otherwise.
184
+ """
185
+ if result is None:
186
+ return False
187
+ return "rolled_back_at" in result
188
+
189
+ async def validate_rollback(self, audit_id: str) -> dict[str, Any]:
190
+ """Validate that a rollback can be performed for the given audit entry.
191
+
192
+ This performs all pre-flight checks:
193
+ 1. Audit entry exists
194
+ 2. Operation status is 'success' (not failed or pending)
195
+ 3. Operation has not already been rolled back
196
+ 4. Rollback state exists
197
+
198
+ Args:
199
+ audit_id: The UUID of the audit entry to validate.
200
+
201
+ Returns:
202
+ The audit entry dict if validation passes.
203
+
204
+ Raises:
205
+ RollbackNotPossibleError: If rollback cannot be performed.
206
+ """
207
+ # Check audit entry exists
208
+ entry = await self._get_audit_entry(audit_id)
209
+ if entry is None:
210
+ raise RollbackNotPossibleError(
211
+ f"Audit entry '{audit_id}' not found. "
212
+ "Use 'audit_list' to see available entries."
213
+ )
214
+
215
+ # Check status
216
+ status = entry["status"]
217
+ if status == "failed":
218
+ raise RollbackNotPossibleError(
219
+ f"Operation '{audit_id}' failed and made no changes. "
220
+ "Nothing to rollback."
221
+ )
222
+ if status == "pending":
223
+ raise RollbackNotPossibleError(
224
+ f"Operation '{audit_id}' is still pending. "
225
+ "Wait for it to complete before attempting rollback."
226
+ )
227
+
228
+ # Check if already rolled back
229
+ result = json.loads(entry["result"]) if entry["result"] else None
230
+ if self._check_already_rolled_back(result):
231
+ # result is guaranteed to be a dict if check returns True
232
+ assert result is not None
233
+ rolled_back_at = result.get("rolled_back_at", "unknown time")
234
+ raise RollbackNotPossibleError(
235
+ f"Operation '{audit_id}' was already rolled back at {rolled_back_at}. "
236
+ "Use 'audit_get' to see current state."
237
+ )
238
+
239
+ # Check rollback state exists
240
+ if entry["rollback_state_id"] is None:
241
+ raise RollbackNotPossibleError(
242
+ f"Operation '{audit_id}' has no saved state for rollback. "
243
+ "This may be an older operation before rollback was enabled."
244
+ )
245
+
246
+ return entry
247
+
248
+ def _get_delete_endpoint(
249
+ self, object_type: str, object_id: str, params: dict[str, Any]
250
+ ) -> str:
251
+ """Resolve the DELETE endpoint for an object type.
252
+
253
+ Args:
254
+ object_type: The type of object (service, registration, etc.)
255
+ object_id: The ID of the object to delete.
256
+ params: Additional parameters for endpoint resolution (service_id, etc.)
257
+
258
+ Returns:
259
+ The resolved DELETE endpoint path.
260
+
261
+ Raises:
262
+ RollbackError: If object type not supported or endpoint not available.
263
+ """
264
+ if object_type not in ROLLBACK_ENDPOINTS:
265
+ raise RollbackError(
266
+ f"Rollback not supported for object type '{object_type}'."
267
+ )
268
+
269
+ delete_endpoint, _, _ = ROLLBACK_ENDPOINTS[object_type]
270
+ if delete_endpoint is None:
271
+ raise RollbackError(
272
+ f"DELETE operation not available for object type '{object_type}'."
273
+ )
274
+
275
+ # Substitute placeholders
276
+ endpoint = delete_endpoint.replace("{id}", str(object_id))
277
+ if "{service_id}" in endpoint:
278
+ service_id = params.get("service_id")
279
+ if not service_id:
280
+ raise RollbackError(
281
+ f"Cannot resolve DELETE endpoint for '{object_type}': "
282
+ "service_id not found in params."
283
+ )
284
+ endpoint = endpoint.replace("{service_id}", str(service_id))
285
+
286
+ return endpoint
287
+
288
+ def _get_update_endpoint(
289
+ self, object_type: str, object_id: str, params: dict[str, Any]
290
+ ) -> str:
291
+ """Resolve the PUT endpoint for an object type.
292
+
293
+ Args:
294
+ object_type: The type of object (service, registration, etc.)
295
+ object_id: The ID of the object to update.
296
+ params: Additional parameters for endpoint resolution.
297
+
298
+ Returns:
299
+ The resolved PUT endpoint path.
300
+
301
+ Raises:
302
+ RollbackError: If object type not supported or endpoint not available.
303
+ """
304
+ if object_type not in ROLLBACK_ENDPOINTS:
305
+ raise RollbackError(
306
+ f"Rollback not supported for object type '{object_type}'."
307
+ )
308
+
309
+ _, update_endpoint, _ = ROLLBACK_ENDPOINTS[object_type]
310
+ if update_endpoint is None:
311
+ raise RollbackError(
312
+ f"UPDATE operation not available for object type '{object_type}'."
313
+ )
314
+
315
+ # Substitute placeholders
316
+ endpoint = update_endpoint.replace("{id}", str(object_id))
317
+ if "{service_id}" in endpoint:
318
+ service_id = params.get("service_id")
319
+ if not service_id:
320
+ raise RollbackError(
321
+ f"Cannot resolve UPDATE endpoint for '{object_type}': "
322
+ "service_id not found in params."
323
+ )
324
+ endpoint = endpoint.replace("{service_id}", str(service_id))
325
+
326
+ return endpoint
327
+
328
+ def _get_create_endpoint(self, object_type: str, params: dict[str, Any]) -> str:
329
+ """Resolve the POST endpoint for an object type.
330
+
331
+ Args:
332
+ object_type: The type of object (service, registration, etc.)
333
+ params: Additional parameters for endpoint resolution.
334
+
335
+ Returns:
336
+ The resolved POST endpoint path.
337
+
338
+ Raises:
339
+ RollbackError: If object type not supported or endpoint not available.
340
+ """
341
+ if object_type not in ROLLBACK_ENDPOINTS:
342
+ raise RollbackError(
343
+ f"Rollback not supported for object type '{object_type}'."
344
+ )
345
+
346
+ # Special case: cost has two create endpoints depending on cost_type
347
+ if object_type == "cost":
348
+ cost_type = params.get("cost_type", "fixed")
349
+ registration_id = params.get("registration_id")
350
+ if not registration_id:
351
+ raise RollbackError(
352
+ "Cannot resolve CREATE endpoint for 'cost': "
353
+ "registration_id not found in params."
354
+ )
355
+ if cost_type == "formula":
356
+ return f"/registration/{registration_id}/formulacost"
357
+ return f"/registration/{registration_id}/fixcost"
358
+
359
+ _, _, create_endpoint = ROLLBACK_ENDPOINTS[object_type]
360
+ if create_endpoint is None:
361
+ raise RollbackError(
362
+ f"CREATE operation not available for object type '{object_type}'."
363
+ )
364
+
365
+ # Substitute placeholders
366
+ endpoint = create_endpoint
367
+ if "{service_id}" in endpoint:
368
+ service_id = params.get("service_id")
369
+ if not service_id:
370
+ raise RollbackError(
371
+ f"Cannot resolve CREATE endpoint for '{object_type}': "
372
+ "service_id not found in params."
373
+ )
374
+ endpoint = endpoint.replace("{service_id}", str(service_id))
375
+ if "{registration_id}" in endpoint:
376
+ registration_id = params.get("registration_id")
377
+ if not registration_id:
378
+ raise RollbackError(
379
+ f"Cannot resolve CREATE endpoint for '{object_type}': "
380
+ "registration_id not found in params."
381
+ )
382
+ endpoint = endpoint.replace("{registration_id}", str(registration_id))
383
+
384
+ return endpoint
385
+
386
+ async def execute_rollback(
387
+ self,
388
+ operation_type: str,
389
+ object_type: str,
390
+ object_id: str,
391
+ previous_state: dict[str, Any] | None,
392
+ params: dict[str, Any],
393
+ ) -> dict[str, Any]:
394
+ """Execute the rollback operation via BPA API.
395
+
396
+ Strategy by operation type:
397
+ - create: DELETE the object (no previous state needed)
398
+ - update: PUT with previous_state values
399
+ - delete: POST to recreate with previous_state
400
+ - link: Call unlink operation
401
+ - unlink: Call link operation
402
+
403
+ Args:
404
+ operation_type: The original operation type (create, update, delete, etc.)
405
+ object_type: The type of object.
406
+ object_id: The ID of the object.
407
+ previous_state: The state to restore (None for create operations).
408
+ params: Additional context parameters from the original operation.
409
+
410
+ Returns:
411
+ Dict with rollback execution result.
412
+
413
+ Raises:
414
+ RollbackError: If the BPA API call fails.
415
+ """
416
+ try:
417
+ async with BPAClient() as client:
418
+ if operation_type == "create":
419
+ # Delete the created object
420
+ endpoint = self._get_delete_endpoint(object_type, object_id, params)
421
+ await client.delete(endpoint)
422
+ return {"action": "deleted", "object_id": object_id}
423
+
424
+ elif operation_type == "update":
425
+ # Restore previous values
426
+ endpoint = self._get_update_endpoint(object_type, object_id, params)
427
+ result = await client.put(endpoint, json=previous_state)
428
+ return {"action": "restored", "state": result}
429
+
430
+ elif operation_type == "delete":
431
+ # Recreate the object
432
+ endpoint = self._get_create_endpoint(object_type, params)
433
+
434
+ # Messages: strip ID fields (BPA rejects recreating with same ID)
435
+ # Other types: preserve original payload
436
+ if object_type == "message":
437
+ create_payload = {
438
+ k: v
439
+ for k, v in (previous_state or {}).items()
440
+ if k not in ("id", "businessKey", "business_key")
441
+ }
442
+ result = await client.post(endpoint, json=create_payload)
443
+ return {
444
+ "action": "recreated",
445
+ "new_id": result.get("id") if result else None,
446
+ "original_id": (
447
+ previous_state.get("id") if previous_state else None
448
+ ),
449
+ }
450
+ else:
451
+ result = await client.post(endpoint, json=previous_state)
452
+ return {
453
+ "action": "recreated",
454
+ "new_id": result.get("id") if result else None,
455
+ }
456
+
457
+ elif operation_type == "link":
458
+ # TODO: Implement link rollback (unlink)
459
+ raise RollbackError(
460
+ f"Rollback for '{operation_type}' operations "
461
+ "is not yet implemented."
462
+ )
463
+
464
+ elif operation_type == "unlink":
465
+ # TODO: Implement unlink rollback (link)
466
+ raise RollbackError(
467
+ f"Rollback for '{operation_type}' operations "
468
+ "is not yet implemented."
469
+ )
470
+
471
+ else:
472
+ raise RollbackError(
473
+ f"Unknown operation type '{operation_type}'. "
474
+ "Cannot determine rollback strategy."
475
+ )
476
+
477
+ except BPAClientError as e:
478
+ raise RollbackError(f"BPA API error during rollback: {e}")
479
+
480
+ async def _mark_rolled_back(
481
+ self,
482
+ audit_id: str,
483
+ rollback_audit_id: str,
484
+ rolled_back_at: str,
485
+ ) -> None:
486
+ """Mark the original audit entry as rolled back.
487
+
488
+ Updates the result field of the original audit entry to include
489
+ rollback metadata, preventing double-rollback.
490
+
491
+ Args:
492
+ audit_id: The original audit entry ID.
493
+ rollback_audit_id: The ID of the rollback audit entry.
494
+ rolled_back_at: ISO 8601 timestamp of when rollback occurred.
495
+ """
496
+ async with get_connection(self._db_path) as conn:
497
+ # Get current result
498
+ cursor = await conn.execute(
499
+ "SELECT result FROM audit_logs WHERE id = ?",
500
+ (audit_id,),
501
+ )
502
+ row = await cursor.fetchone()
503
+ if row is None:
504
+ return
505
+
506
+ # Update result with rollback info
507
+ current_result = json.loads(row["result"]) if row["result"] else {}
508
+ current_result["rolled_back_at"] = rolled_back_at
509
+ current_result["rollback_audit_id"] = rollback_audit_id
510
+
511
+ await conn.execute(
512
+ "UPDATE audit_logs SET result = ? WHERE id = ?",
513
+ (json.dumps(current_result), audit_id),
514
+ )
515
+ await conn.commit()
516
+
517
+ async def perform_rollback(self, audit_id: str) -> dict[str, Any]:
518
+ """Perform a complete rollback operation.
519
+
520
+ This is the main entry point for rollback operations. It:
521
+ 1. Validates the rollback can be performed
522
+ 2. Retrieves the previous state
523
+ 3. Executes the rollback via BPA API
524
+ 4. Marks the original operation as rolled back
525
+
526
+ Note: This method does NOT create an audit record for the rollback
527
+ operation itself - that should be done by the calling tool.
528
+
529
+ Args:
530
+ audit_id: The UUID of the audit entry to rollback.
531
+
532
+ Returns:
533
+ Dict with rollback result including:
534
+ - status: "success"
535
+ - message: Human-readable description
536
+ - original_operation: Details of what was rolled back
537
+ - restored_state: The restored object state (if applicable)
538
+
539
+ Raises:
540
+ RollbackNotPossibleError: If validation fails.
541
+ RollbackError: If execution fails.
542
+ """
543
+ # Validate rollback
544
+ entry = await self.validate_rollback(audit_id)
545
+
546
+ # Get rollback state
547
+ rollback_state = await self._get_rollback_state(entry["rollback_state_id"])
548
+ if rollback_state is None:
549
+ raise RollbackError(
550
+ f"Rollback state '{entry['rollback_state_id']}' not found. "
551
+ "Database may be corrupted."
552
+ )
553
+
554
+ # Parse previous state
555
+ previous_state = (
556
+ json.loads(rollback_state["previous_state"])
557
+ if rollback_state["previous_state"]
558
+ else None
559
+ )
560
+
561
+ # Parse params for context (service_id, registration_id, etc.)
562
+ params = json.loads(entry["params"]) if entry["params"] else {}
563
+
564
+ # Execute rollback
565
+ operation_type = entry["operation_type"]
566
+ object_type = entry["object_type"]
567
+ # For create operations, object_id in audit_logs is None (we don't know ID
568
+ # until after creation). Use object_id from rollback_state as fallback.
569
+ object_id = entry["object_id"] or rollback_state["object_id"]
570
+
571
+ exec_result = await self.execute_rollback(
572
+ operation_type=operation_type,
573
+ object_type=object_type,
574
+ object_id=object_id,
575
+ previous_state=previous_state,
576
+ params=params,
577
+ )
578
+
579
+ # Build message
580
+ action = exec_result.get("action", "unknown")
581
+ if action == "deleted":
582
+ message = (
583
+ f"Rolled back 'create' on {object_type} '{object_id}' - object deleted"
584
+ )
585
+ elif action == "restored":
586
+ message = f"Rolled back 'update' on {object_type} '{object_id}'"
587
+ elif action == "recreated":
588
+ new_id = exec_result.get("new_id", "unknown")
589
+ message = (
590
+ f"Rolled back 'delete' on {object_type} '{object_id}' - "
591
+ f"object recreated as '{new_id}'"
592
+ )
593
+ else:
594
+ message = f"Rolled back '{operation_type}' on {object_type} '{object_id}'"
595
+
596
+ # Build response
597
+ rolled_back_at = datetime.now(UTC).isoformat()
598
+ result: dict[str, Any] = {
599
+ "status": "success",
600
+ "message": message,
601
+ "original_operation": {
602
+ "audit_id": entry["id"],
603
+ "operation_type": operation_type,
604
+ "object_type": object_type,
605
+ "object_id": object_id,
606
+ "timestamp": entry["timestamp"],
607
+ },
608
+ "restored_state": previous_state,
609
+ "rolled_back_at": rolled_back_at,
610
+ }
611
+
612
+ # Add new_id for recreated objects
613
+ if action == "recreated" and exec_result.get("new_id"):
614
+ result["new_object_id"] = exec_result["new_id"]
615
+
616
+ return result