postgres-aiops 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/CHANGELOG.md +11 -0
  2. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/PKG-INFO +2 -2
  3. postgres_aiops-0.5.0/RELEASE_NOTES.md +50 -0
  4. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/_shared.py +26 -4
  5. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/remediation.py +18 -0
  6. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/undo.py +17 -0
  7. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/_common.py +22 -0
  8. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/remediate.py +23 -12
  9. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/connection.py +23 -1
  10. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/__init__.py +10 -0
  11. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/decorators.py +65 -9
  12. postgres_aiops-0.5.0/postgres_aiops/governance/outcome.py +104 -0
  13. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/sanitize.py +11 -2
  14. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/undo.py +52 -13
  15. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/remediation.py +114 -2
  16. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/pyproject.toml +2 -2
  17. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/server.json +2 -2
  18. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_cli_remediate.py +54 -4
  19. postgres_aiops-0.5.0/tests/test_connection_lost.py +69 -0
  20. postgres_aiops-0.5.0/tests/test_governance_persistence.py +362 -0
  21. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_optional_fields.py +4 -1
  22. postgres_aiops-0.5.0/tests/test_self_lockout.py +195 -0
  23. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_smoke.py +2 -2
  24. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/uv.lock +1 -1
  25. postgres_aiops-0.4.0/RELEASE_NOTES.md +0 -48
  26. postgres_aiops-0.4.0/tests/test_governance_persistence.py +0 -170
  27. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/.github/workflows/mcp-publish.yml +0 -0
  28. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/.github/workflows/publish.yml +0 -0
  29. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/.gitignore +0 -0
  30. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/LICENSE +0 -0
  31. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/README.md +0 -0
  32. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/SECURITY.md +0 -0
  33. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/docs/VERIFICATION.md +0 -0
  34. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/__init__.py +0 -0
  35. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/server.py +0 -0
  36. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/__init__.py +0 -0
  37. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/activity.py +0 -0
  38. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/analysis.py +0 -0
  39. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/indexes.py +0 -0
  40. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/queries.py +0 -0
  41. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/replication.py +0 -0
  42. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/server.py +0 -0
  43. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/tables.py +0 -0
  44. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/__init__.py +0 -0
  45. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/__init__.py +0 -0
  46. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/_root.py +0 -0
  47. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/activity.py +0 -0
  48. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/analyze.py +0 -0
  49. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/doctor.py +0 -0
  50. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/index.py +0 -0
  51. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/init.py +0 -0
  52. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/overview.py +0 -0
  53. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/query.py +0 -0
  54. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/replication.py +0 -0
  55. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/secret.py +0 -0
  56. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/server.py +0 -0
  57. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/table.py +0 -0
  58. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/undo.py +0 -0
  59. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/config.py +0 -0
  60. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/doctor.py +0 -0
  61. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/audit.py +0 -0
  62. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/budget.py +0 -0
  63. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/paths.py +0 -0
  64. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/patterns.py +0 -0
  65. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/policy.py +0 -0
  66. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/readonly.py +0 -0
  67. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/__init__.py +0 -0
  68. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/_util.py +0 -0
  69. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/activity.py +0 -0
  70. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/analysis.py +0 -0
  71. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/indexes.py +0 -0
  72. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/overview.py +0 -0
  73. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/queries.py +0 -0
  74. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/replication.py +0 -0
  75. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/server.py +0 -0
  76. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/tables.py +0 -0
  77. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/secretstore.py +0 -0
  78. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/skills/postgres-aiops/SKILL.md +0 -0
  79. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/skills/postgres-aiops/references/agent-guardrails.md +0 -0
  80. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/skills/postgres-aiops/references/capabilities.md +0 -0
  81. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/skills/postgres-aiops/references/cli-reference.md +0 -0
  82. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/skills/postgres-aiops/references/setup-guide.md +0 -0
  83. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/smithery.yaml +0 -0
  84. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/conftest.py +0 -0
  85. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_analysis.py +0 -0
  86. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_cli_reads.py +0 -0
  87. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_cli_secret.py +0 -0
  88. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_cli_writes.py +0 -0
  89. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_config.py +0 -0
  90. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_connection.py +0 -0
  91. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_connection_more.py +0 -0
  92. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_doctor.py +0 -0
  93. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_gov_audit.py +0 -0
  94. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_gov_decorators.py +0 -0
  95. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_gov_patterns.py +0 -0
  96. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_gov_policy.py +0 -0
  97. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_init.py +0 -0
  98. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_mcp_tools.py +0 -0
  99. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_ops_more.py +0 -0
  100. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_readonly.py +0 -0
  101. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_reads.py +0 -0
  102. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_secretstore.py +0 -0
  103. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_secretstore_more.py +0 -0
  104. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_truncation.py +0 -0
  105. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_undo_executor.py +0 -0
  106. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_undo_replay.py +0 -0
  107. {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_writes.py +0 -0
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.5.0 — 2026-07-20
4
+
5
+ ### Fixed
6
+ - **`terminate_backend` / `cancel_query` refuse this tool's own backend**, and `update_setting` refuses the postmaster settings that would strand the undo at the next restart (`listen_addresses`, `port`, `max_connections`, `ssl`, `hba_file`, …).
7
+ - A connection lost **mid-statement** now raises a dedicated error and is audited as `unknown`, not `error`..
8
+ - Harness: a write whose response is lost is audited `status=unknown`, not `error` — it may have taken effect. Undo tokens gain `effectVerified` (undo.db migrated in place).
9
+ - Harness: a dry-run no longer records an undo token, and no longer requires a named approver. Guards now run on the preview path.
10
+ - Truncated strings end in an ellipsis instead of being cut silently; error messages are capped at 800 chars, not 300.
11
+
12
+ See RELEASE_NOTES.md for the full detail.
13
+
3
14
  ## v0.3.0 — 2026-07-17
4
15
 
5
16
  ### Added
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: postgres-aiops
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Governed AI-ops for PostgreSQL DBA operations: slow-query RCA, bloat/vacuum & blocking-lock analysis with a built-in governance harness (audit, budget, undo, risk tiers)
5
- Author-email: wei <zhouwei008@gmail.com>
5
+ Author-email: Wei Zhou <zhouwei008@gmail.com>
6
6
  License-Expression: MIT
7
7
  License-File: LICENSE
8
8
  Requires-Python: >=3.11
@@ -0,0 +1,50 @@
1
+ # Release notes — postgres-aiops 0.5.0
2
+
3
+ Previous release: 0.4.0.
4
+
5
+ ## In this tool
6
+
7
+ - **`terminate_backend` / `cancel_query` refuse this tool's own backend**, and `update_setting` refuses the postmaster settings that would strand the undo at the next restart (`listen_addresses`, `port`, `max_connections`, `ssl`, `hba_file`, …). The read path already filtered `pg_backend_pid()`; the writes did not.
8
+ - A connection lost **mid-statement** now raises a dedicated error and is audited as `unknown`, not `error`.
9
+
10
+ ## Every tool in the line: previews and undetermined outcomes
11
+
12
+ This release fixes three harness defects that were silently degrading the audit
13
+ trail and the undo store.
14
+
15
+ **A write that loses its response is no longer recorded as a failure.** The
16
+ harness assumed a sanitized error meant nothing had happened. That assumption is
17
+ false in exactly the case that matters most: when a write severs its own
18
+ connection, the request has already landed, the response cannot come back, and
19
+ the operation was recorded as `status=error` with **no undo token created at
20
+ all**. Transport-level failures are now audited as `status=unknown`, the result
21
+ says plainly that the operation may have taken effect and should be verified
22
+ before retrying, and a write that stashed its before-state has its inverse
23
+ recorded anyway — flagged `effectVerified: false`, which `undo_list` and
24
+ `undo_apply` both surface. Existing `undo.db` files are migrated in place; their
25
+ rows read as verified, which is accurate, since the old code only ever recorded
26
+ on the confirmed path.
27
+
28
+ **A dry-run no longer writes an undo token.** Previews were recording inverses
29
+ built from a before-state they never had: the undo callback's permissive default
30
+ filled the gap with a guess, producing a real, applicable token for an operation
31
+ that never happened.
32
+
33
+ **A dry-run no longer demands a named approver.** Requiring an approval in order
34
+ to ask whether something needs approval inverts what a preview is for. The tier
35
+ is still computed and still audited, so the preview can tell you an approver
36
+ will be needed; it just no longer refuses to answer. The write itself is gated
37
+ exactly as before.
38
+
39
+ The invariant, now stated: **a dry_run may read; it must never write.** Guards
40
+ run on the preview path, which means a preview can and does report that an
41
+ operation would be refused.
42
+
43
+ ## Also line-wide
44
+
45
+ - **Truncated text now ends in an ellipsis** instead of being cut silently. This
46
+ line already treats a silent cut as a defect for lists; it was doing exactly
47
+ that to strings.
48
+ - **Error messages are capped at 800 characters, not 300.** These messages end
49
+ with what to do instead, so the cap was removing the most useful sentence of
50
+ every long refusal.
@@ -20,14 +20,30 @@ from typing import Any, Optional
20
20
  from mcp.server.fastmcp import FastMCP
21
21
 
22
22
  from postgres_aiops.config import load_config
23
- from postgres_aiops.connection import ConnectionManager, PgError
24
- from postgres_aiops.governance import sanitize
23
+ from postgres_aiops.connection import ConnectionManager, PgConnectionLostError, PgError
24
+ from postgres_aiops.governance import mark_unknown, sanitize
25
25
 
26
26
  logger = logging.getLogger(__name__)
27
27
 
28
28
  _DOCTOR_HINT = "Run 'postgres-aiops doctor' to verify connectivity and credentials."
29
29
 
30
30
 
31
+ # Long enough to carry the remediation sentence. These messages teach the
32
+ # caller what to do instead, and that clause comes last — a 300-char cap cut
33
+ # it off silently on every refusal long enough to need one.
34
+ _ERROR_MAX = 800
35
+
36
+
37
+ # Failures that leave the statement's fate genuinely undetermined. Raised
38
+ # only from the statement-executing path, so it means an ESTABLISHED link
39
+ # died mid-statement — not that the server was unreachable. The driver gives
40
+ # both the same class, so the connection layer discriminates by position and
41
+ # raises a dedicated class; this layer only has to recognise it.
42
+ # PostgreSQL rolls back on backend termination, so usually nothing landed —
43
+ # but a COMMIT whose acknowledgement was lost did land.
44
+ _UNDETERMINED_ERRORS = (PgConnectionLostError,)
45
+
46
+
31
47
  def _safe_error(exc: Exception, tool: str) -> str:
32
48
  """Return an agent-safe error string; log full detail server-side only."""
33
49
  logger.error("Tool %s failed", tool, exc_info=True)
@@ -41,7 +57,7 @@ def _safe_error(exc: Exception, tool: str) -> str:
41
57
  PgError,
42
58
  )
43
59
  if isinstance(exc, _passthrough):
44
- return sanitize(str(exc), 300)
60
+ return sanitize(str(exc), _ERROR_MAX)
45
61
  return f"{type(exc).__name__}: operation failed."
46
62
 
47
63
 
@@ -65,7 +81,13 @@ def tool_errors(shape: str = "dict") -> Callable:
65
81
  return [{"error": msg, "hint": _DOCTOR_HINT}]
66
82
  if shape == "str":
67
83
  return f"Error: {msg} {_DOCTOR_HINT}"
68
- return {"error": msg, "hint": _DOCTOR_HINT}
84
+ payload = {"error": msg, "hint": _DOCTOR_HINT}
85
+ # Flatten the exception into a dict and its type is gone
86
+ # for good — so classify here, while it is still known,
87
+ # whether the operation may nonetheless have taken effect.
88
+ if isinstance(e, _UNDETERMINED_ERRORS):
89
+ return mark_unknown(payload)
90
+ return payload
69
91
 
70
92
  return wrapper
71
93
 
@@ -73,12 +73,18 @@ def terminate_backend(pid: int, dry_run: bool = False, target: Optional[str] = N
73
73
  Captures the backend's pid + query for the audit trail; a terminate cannot be
74
74
  undone, so no undo is offered. Pass dry_run=True to preview.
75
75
 
76
+ Refuses this tool's own backend pid — including under dry_run, which must
77
+ report a refusal rather than preview a call that will be refused.
78
+
76
79
  Args:
77
80
  pid: Backend process id (from list_activity).
78
81
  dry_run: If True, preview without terminating.
79
82
  target: Target name from config; omit for the default.
80
83
  """
81
84
  conn = _get_connection(target)
85
+ # Ahead of the dry_run return: a preview whose real call would be refused
86
+ # must say so, or the caller reads the refusal as transient and retries.
87
+ ops.guard_terminate_backend(conn, pid, "terminate_backend")
82
88
  if dry_run:
83
89
  return {"dryRun": True, "wouldTerminate": {"pid": pid}}
84
90
  return ops.terminate_backend(conn, pid)
@@ -93,12 +99,16 @@ def cancel_query(pid: int, dry_run: bool = False, target: Optional[str] = None)
93
99
  Captures the backend's pid + query for audit; a cancel has no undo. Pass
94
100
  dry_run=True to preview.
95
101
 
102
+ Refuses this tool's own backend pid — the query it would cancel is this very
103
+ call. Enforced under dry_run too.
104
+
96
105
  Args:
97
106
  pid: Backend process id (from list_activity).
98
107
  dry_run: If True, preview without cancelling.
99
108
  target: Target name from config; omit for the default.
100
109
  """
101
110
  conn = _get_connection(target)
111
+ ops.guard_terminate_backend(conn, pid, "cancel_query")
102
112
  if dry_run:
103
113
  return {"dryRun": True, "wouldCancel": {"pid": pid}}
104
114
  return ops.cancel_query(conn, pid)
@@ -283,6 +293,11 @@ def update_setting(
283
293
  performed automatically. The prior value is captured so the harness records an
284
294
  undo that sets it back. Pass dry_run=True to preview.
285
295
 
296
+ Refuses the connection-affecting postmaster settings (listen_addresses,
297
+ port, max_connections, superuser_reserved_connections, ssl, hba_file):
298
+ nothing breaks now, but the operator's next restart applies them and strands
299
+ the undo. Enforced under dry_run too.
300
+
286
301
  Args:
287
302
  name: The configuration parameter name (e.g. work_mem).
288
303
  value: The new value (as a string).
@@ -290,6 +305,9 @@ def update_setting(
290
305
  target: Target name from config; omit for the default.
291
306
  """
292
307
  conn = _get_connection(target)
308
+ # Static denylist, so this costs nothing and cannot diverge from the real
309
+ # call — a preview of a refused setting must report the refusal.
310
+ ops.guard_update_setting(name)
293
311
  if dry_run:
294
312
  return {"dryRun": True, "wouldSet": {"name": name, "value": value}}
295
313
  return ops.update_setting(conn, name, value)
@@ -36,6 +36,11 @@ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
36
36
  Each entry names the original tool, the inverse tool that ``undo_apply``
37
37
  would run, and a human note. Use the ``undoId`` with ``undo_apply``.
38
38
 
39
+ Each entry carries ``effectVerified``. False means the original write
40
+ lost its response, so the change it reverses is PROBABLE, not confirmed —
41
+ check the live state before applying, and do not report the result as a
42
+ restore of a state that may never have been reached.
43
+
39
44
  Args:
40
45
  limit: Max rows to return (default 50).
41
46
  target: Unused (undo state is host-local); accepted for CLI uniformity.
@@ -52,6 +57,7 @@ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
52
57
  "originalTool": r["tool"],
53
58
  "inverseTool": r["undo_tool"],
54
59
  "note": r.get("note", ""),
60
+ "effectVerified": bool(r.get("effect_verified", 1)),
55
61
  }
56
62
  for r in rows
57
63
  ],
@@ -99,10 +105,12 @@ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None
99
105
  f"Inverse tool '{inverse_tool}' is not registered on this server; cannot apply."
100
106
  )
101
107
 
108
+ effect_verified = bool(rec.get("effect_verified", 1))
102
109
  if dry_run:
103
110
  return {
104
111
  "dryRun": True,
105
112
  "undoId": undo_id,
113
+ "effectVerified": effect_verified,
106
114
  "wouldApply": {"tool": inverse_tool, "params": params},
107
115
  }
108
116
 
@@ -121,6 +129,15 @@ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None
121
129
  return {
122
130
  "undoId": undo_id,
123
131
  "applied": applied,
132
+ "effectVerified": effect_verified,
124
133
  "inverseTool": inverse_tool,
125
134
  "result": result,
135
+ "note": (
136
+ ""
137
+ if effect_verified
138
+ else "The original write lost its response, so it was never confirmed "
139
+ "to have taken effect. This inverse ran regardless; report the "
140
+ "CURRENT server state, not a restore that may have had nothing "
141
+ "to restore."
142
+ ),
126
143
  }
@@ -95,6 +95,28 @@ def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = No
95
95
  console.print("[magenta] Run without --dry-run to execute.[/]\n")
96
96
 
97
97
 
98
+ def dry_run_preview(
99
+ preview: Any, *, operation: str, api_call: str, parameters: dict | None = None
100
+ ) -> None:
101
+ """Render a GOVERNED dry-run result as the human-readable DRY-RUN banner.
102
+
103
+ ``preview`` must come from calling the governed tool with ``dry_run=True``,
104
+ so every guard it carries has already run against the real target. A refusal
105
+ arrives as ``{"error": ...}`` (``tool_errors`` flattens the exception) — it is
106
+ printed like any other CLI error and exits non-zero, exactly as the real
107
+ write would. Printing a green banner for a call that is about to be refused
108
+ is the preview being wrong, not merely incomplete.
109
+
110
+ On the allowed path the banner is byte-for-byte what it always was: routing
111
+ through the governed call buys the guard and the audit row, not a new
112
+ serialization.
113
+ """
114
+ if isinstance(preview, dict) and preview.get("error"):
115
+ console.print(f"[red]Error: {preview['error']}[/]")
116
+ raise typer.Exit(1)
117
+ dry_run_print(operation=operation, api_call=api_call, parameters=parameters)
118
+
119
+
98
120
  def double_confirm(action: str, resource: str) -> None:
99
121
  """Require two confirmations for a destructive operation."""
100
122
  console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
@@ -13,6 +13,7 @@ from postgres_aiops.cli._common import (
13
13
  cli_errors,
14
14
  console,
15
15
  double_confirm,
16
+ dry_run_preview,
16
17
  dry_run_print,
17
18
  )
18
19
 
@@ -31,13 +32,17 @@ def remediate_terminate(
31
32
  dry_run: DryRunOption = False,
32
33
  ) -> None:
33
34
  """Terminate a backend (no undo; dry-run + confirm)."""
35
+ from mcp_server.tools import remediation as gov
36
+
34
37
  if dry_run:
35
- dry_run_print(operation="terminate_backend",
36
- api_call="SELECT pg_terminate_backend(pid)", parameters={"pid": pid})
38
+ # Through the governed call: terminate_backend refuses this tool's own
39
+ # pid, so a preview must report that rather than a green banner.
40
+ dry_run_preview(
41
+ gov.terminate_backend(pid=pid, dry_run=True, target=target),
42
+ operation="terminate_backend",
43
+ api_call="SELECT pg_terminate_backend(pid)", parameters={"pid": pid})
37
44
  return
38
45
  double_confirm("terminate backend", str(pid))
39
- from mcp_server.tools import remediation as gov
40
-
41
46
  console.print_json(json.dumps(gov.terminate_backend(pid=pid, target=target)))
42
47
 
43
48
 
@@ -49,13 +54,15 @@ def remediate_cancel(
49
54
  dry_run: DryRunOption = False,
50
55
  ) -> None:
51
56
  """Cancel a backend's running query (no undo; dry-run + confirm)."""
57
+ from mcp_server.tools import remediation as gov
58
+
52
59
  if dry_run:
53
- dry_run_print(operation="cancel_query",
54
- api_call="SELECT pg_cancel_backend(pid)", parameters={"pid": pid})
60
+ dry_run_preview(
61
+ gov.cancel_query(pid=pid, dry_run=True, target=target),
62
+ operation="cancel_query",
63
+ api_call="SELECT pg_cancel_backend(pid)", parameters={"pid": pid})
55
64
  return
56
65
  double_confirm("cancel query on backend", str(pid))
57
- from mcp_server.tools import remediation as gov
58
-
59
66
  console.print_json(json.dumps(gov.cancel_query(pid=pid, target=target)))
60
67
 
61
68
 
@@ -169,11 +176,15 @@ def remediate_set(
169
176
  dry_run: DryRunOption = False,
170
177
  ) -> None:
171
178
  """ALTER SYSTEM SET a parameter (reversible; dry-run + confirm)."""
179
+ from mcp_server.tools import remediation as gov
180
+
172
181
  if dry_run:
173
- dry_run_print(operation="update_setting",
174
- api_call=f"ALTER SYSTEM SET {name} = ...", parameters={"value": value})
182
+ # Through the governed call: update_setting refuses the
183
+ # connection-affecting postmaster settings, so a preview must report it.
184
+ dry_run_preview(
185
+ gov.update_setting(name=name, value=value, dry_run=True, target=target),
186
+ operation="update_setting",
187
+ api_call=f"ALTER SYSTEM SET {name} = ...", parameters={"value": value})
175
188
  return
176
189
  double_confirm(f"ALTER SYSTEM SET {name} =", value)
177
- from mcp_server.tools import remediation as gov
178
-
179
190
  console.print_json(json.dumps(gov.update_setting(name=name, value=value, target=target)))
@@ -41,6 +41,24 @@ class PgError(Exception):
41
41
  super().__init__(message)
42
42
 
43
43
 
44
+ class PgConnectionLostError(PgError):
45
+ """An ESTABLISHED connection dropped while a statement was running.
46
+
47
+ Distinct from an ordinary failure because the outcome is genuinely
48
+ undetermined: the statement may have committed before the link died.
49
+ PostgreSQL rolls back on backend termination, so usually nothing landed —
50
+ but a COMMIT whose acknowledgement was lost did land, and from here the two
51
+ are indistinguishable. The MCP layer maps this to ``status=unknown`` rather
52
+ than asserting a failure it cannot vouch for.
53
+
54
+ Note the discriminator is WHERE it was raised, not the class or sqlstate:
55
+ psycopg reports both "could not connect" and "server closed the connection
56
+ unexpectedly" as ``OperationalError``, and a client-side detection of a
57
+ dropped link carries no sqlstate at all — so position is the only reliable
58
+ signal.
59
+ """
60
+
61
+
44
62
  def _teaching_message(exc: psycopg.Error, target: TargetConfig) -> str:
45
63
  """Map a psycopg error to an actionable, teaching message."""
46
64
  sqlstate = getattr(exc, "sqlstate", None)
@@ -124,7 +142,11 @@ class PgConnection:
124
142
  cur.execute(sql, params)
125
143
  return getattr(cur, "statusmessage", "") or "OK"
126
144
  except psycopg.Error as exc:
127
- raise PgError(
145
+ # Reached only on an established connection, so an OperationalError
146
+ # here means the link died mid-statement — not that we failed to
147
+ # reach the server.
148
+ cls = PgConnectionLostError if isinstance(exc, psycopg.OperationalError) else PgError
149
+ raise cls(
128
150
  _teaching_message(exc, self._target),
129
151
  sqlstate=getattr(exc, "sqlstate", None),
130
152
  ) from exc
@@ -16,6 +16,12 @@ State lives under ``ops_home()`` (default ``~/.postgres-aiops``).
16
16
  from postgres_aiops.governance.audit import AuditEngine, get_engine
17
17
  from postgres_aiops.governance.budget import BudgetExceeded, BudgetTracker, get_budget
18
18
  from postgres_aiops.governance.decorators import PolicyDenied, governed_tool
19
+ from postgres_aiops.governance.outcome import (
20
+ UNKNOWN_FIELD,
21
+ capture_prior_state,
22
+ is_unknown,
23
+ mark_unknown,
24
+ )
19
25
  from postgres_aiops.governance.patterns import Pattern, PatternMatch, get_pattern_engine
20
26
  from postgres_aiops.governance.policy import TierDecision, get_policy_engine
21
27
  from postgres_aiops.governance.readonly import READ_ONLY_ENV, is_read_only
@@ -28,6 +34,10 @@ __all__ = [
28
34
  "opt_str",
29
35
  "is_read_only",
30
36
  "READ_ONLY_ENV",
37
+ "capture_prior_state",
38
+ "mark_unknown",
39
+ "is_unknown",
40
+ "UNKNOWN_FIELD",
31
41
  "PolicyDenied",
32
42
  "get_engine",
33
43
  "AuditEngine",
@@ -34,6 +34,11 @@ from typing import Any
34
34
 
35
35
  from postgres_aiops.governance.audit import detect_agent, get_engine
36
36
  from postgres_aiops.governance.budget import BudgetExceeded, get_budget
37
+ from postgres_aiops.governance.outcome import (
38
+ clear_prior_state,
39
+ is_unknown,
40
+ take_prior_state,
41
+ )
37
42
  from postgres_aiops.governance.patterns import PatternMatch, get_pattern_engine
38
43
  from postgres_aiops.governance.policy import PolicyResult, get_policy_engine
39
44
  from postgres_aiops.governance.readonly import is_read_only, read_only_denial
@@ -155,7 +160,7 @@ class _CallState:
155
160
  "skill", "tool_name", "agent", "start", "status", "result",
156
161
  "policy_result", "pattern_match", "audit", "policy",
157
162
  "safe_params", "env", "risk_level", "timeout_seconds",
158
- "rationale", "approved_by", "risk_tier", "undo",
163
+ "rationale", "approved_by", "risk_tier", "undo", "dry_run",
159
164
  )
160
165
 
161
166
  def __init__(
@@ -169,6 +174,10 @@ class _CallState:
169
174
  timeout_seconds: int,
170
175
  undo: Any = None,
171
176
  ) -> None:
177
+ # A previous call on this thread/task may have captured before-state
178
+ # that its own wrapper never consumed (it raised past _annotate_result).
179
+ # Start clean so one call's prior state can never be recorded as another's.
180
+ clear_prior_state()
172
181
  self.undo = undo
173
182
  self.skill = _infer_skill(func)
174
183
  self.tool_name = func.__name__
@@ -187,6 +196,10 @@ class _CallState:
187
196
  # log and participate in env scoping (previously only kwargs did).
188
197
  params = _bind_params(signature, args, kwargs)
189
198
  self.safe_params = _redact(params, sensitive)
199
+ # Previews are governed like any other call — audited, policy-checked,
200
+ # budget-counted — but they change nothing, so two things must not apply
201
+ # to them: the named-approver gate, and undo recording.
202
+ self.dry_run = bool(params.get("dry_run", False))
190
203
  env = params.get("target", params.get("env", ""))
191
204
  self.env = str(env) if env else ""
192
205
 
@@ -269,7 +282,15 @@ def _pre_check(state: _CallState) -> None:
269
282
  params=state.safe_params,
270
283
  )
271
284
  state.risk_tier = tier.tier
272
- if tier.requires_approver and not state.approved_by:
285
+ # A preview is not the operation. Requiring a named human approver before a
286
+ # caller may ask "would this even be permitted?" inverts what a preview is
287
+ # for — you would have to obtain the approval in order to learn whether the
288
+ # thing needs one, or would be refused outright. The tier is still computed
289
+ # and still audited, so the preview can TELL the caller an approver will be
290
+ # needed; it just does not demand one to answer. Deny rules, budget, the
291
+ # read-only switch and every self-lockout guard still apply, and the write
292
+ # itself is gated normally.
293
+ if tier.requires_approver and not state.approved_by and not state.dry_run:
273
294
  reason = (
274
295
  f"Operation '{state.tool_name}' on '{state.env or 'target'}' requires "
275
296
  f"'{tier.tier}' approval (rule: {tier.rule}) but no approver is recorded. "
@@ -322,17 +343,51 @@ def _annotate_result(state: _CallState, result: Any) -> Any:
322
343
 
323
344
 
324
345
  def _record_undo(state: _CallState, result: Any) -> None:
325
- """Compute and persist the inverse descriptor for a successful write.
346
+ """Compute and persist the inverse descriptor for a write.
347
+
348
+ Two paths:
326
349
 
327
- Best-effort: a broken undo callable or store must never fail the call.
328
- Attaches ``_undo_id`` to dict results so the agent / pilot can reference it.
350
+ * **Confirmed** — the tool returned normally, so ``result`` carries the
351
+ before-state its undo callable needs. Recorded ``effect_verified=True``.
352
+ * **Undetermined** — the response was lost (see
353
+ :mod:`postgres_aiops.governance.outcome`). The change may be live, so an
354
+ inverse still matters, but ``result`` is only an error payload. An
355
+ inverse is recorded ONLY when the write explicitly stashed its
356
+ before-state via ``capture_prior_state``; synthesizing one from an
357
+ empty result would produce a *wrong* inverse, which is worse than none.
358
+
359
+ Best-effort throughout: a broken undo callable or store must never fail
360
+ the call. Attaches ``_undo_id`` to dict results for later reference.
329
361
  """
330
362
  if state.undo is None:
331
363
  return
332
- if isinstance(result, dict) and result.get("error"):
333
- return # sanitized failure — no change happened, so no inverse to record
364
+ if state.dry_run:
365
+ # A preview changed nothing, so there is nothing to invert — and the
366
+ # descriptor it would produce is actively dangerous. An undo callable
367
+ # reads the before-state out of the result, and a preview has none, so
368
+ # permissive defaults (``(result.get("priorState") or {}).get("running",
369
+ # True)``) fill the gap with a guess. That yielded a REAL, applicable
370
+ # start_container token for a stop that never happened.
371
+ return
372
+
373
+ verified = True
374
+ undo_input = result
375
+ if is_unknown(result):
376
+ prior = take_prior_state()
377
+ if prior is None:
378
+ _log.warning(
379
+ "%s.%s outcome undetermined and no prior state captured — "
380
+ "no inverse recorded; the change may be live and unrecorded",
381
+ state.skill, state.tool_name,
382
+ )
383
+ return
384
+ verified = False
385
+ undo_input = {"priorState": prior, "outcomeUnknown": True}
386
+ elif isinstance(result, dict) and result.get("error"):
387
+ return # definite failure — no change happened, so no inverse
388
+
334
389
  try:
335
- descriptor = state.undo(state.safe_params, result)
390
+ descriptor = state.undo(state.safe_params, undo_input)
336
391
  except Exception: # noqa: BLE001 — undo computation must not fail the call
337
392
  _log.warning("undo callable for %s.%s raised", state.skill, state.tool_name,
338
393
  exc_info=True)
@@ -347,6 +402,7 @@ def _record_undo(state: _CallState, result: Any) -> None:
347
402
  tool=state.tool_name,
348
403
  undo_descriptor=descriptor,
349
404
  orig_params=state.safe_params,
405
+ effect_verified=verified,
350
406
  )
351
407
  if undo_id and isinstance(result, dict):
352
408
  result.setdefault("_undo_id", undo_id)
@@ -374,7 +430,7 @@ def _finalize(state: _CallState) -> None:
374
430
  # dicts BEFORE this harness sees them) must not be audited as success —
375
431
  # compliance exception reports are built from this status.
376
432
  if state.status == "ok" and isinstance(state.result, dict) and state.result.get("error"):
377
- state.status = "error"
433
+ state.status = "unknown" if is_unknown(state.result) else "error"
378
434
 
379
435
  duration = int((time.time() - state.start) * 1000)
380
436
 
@@ -0,0 +1,104 @@
1
+ """Tell "the write did not happen" apart from "we cannot tell whether it happened".
2
+
3
+ A governed write that loses its response is not a failed write. The request may
4
+ already have landed; the only thing that certainly failed is our knowledge of
5
+ the outcome. The harness used to collapse both into ``status='error'`` with no
6
+ undo token — and it did so precisely where the operation was most dangerous,
7
+ because the two coincide:
8
+
9
+ a write whose target is the very thing serving this tool's API
10
+ → the change takes effect
11
+ → the response never comes back, because what would have sent it is gone
12
+ → sanitized to {"error": ...} → audited as a FAILURE → no inverse recorded
13
+
14
+ The audit trail then reports that the destructive call failed while the change
15
+ is live and unrecorded. For a line whose product is "governed and reversible",
16
+ a row that confidently states the wrong outcome is worse than a missing one.
17
+
18
+ Two primitives fix that:
19
+
20
+ ``mark_unknown`` / ``is_unknown``
21
+ The MCP layer's ``tool_errors`` classifies the exception it caught. Only
22
+ errors that leave the request's fate genuinely undetermined (a read
23
+ timeout, a half-closed socket, a protocol error mid-response) are marked.
24
+ A connect error or a pool timeout means the request never left, and an HTTP
25
+ status error means the server answered — both are ordinary failures and are
26
+ NOT marked. The distinction is per-tool knowledge, so each tool's
27
+ ``mcp_server/_shared.py`` owns the exception tuple; this module only carries
28
+ the marker.
29
+
30
+ ``capture_prior_state`` / ``take_prior_state``
31
+ An undo callable receives ``(params, result)``. When the write raises, the
32
+ before-state that was read moments earlier dies with the exception and no
33
+ inverse can be computed. A write that wants its undo to survive a lost
34
+ response stashes the before-state *before* issuing the mutating request::
35
+
36
+ before = conn.get(...)
37
+ capture_prior_state({"running": before["running"]})
38
+ conn.post(...) # may never return
39
+
40
+ The harness then records the inverse even on the unknown path, flagged
41
+ ``effect_verified=False`` so nobody mistakes it for a confirmed change.
42
+ Opt-in by design: fabricating a descriptor from an empty result would
43
+ produce a *wrong* inverse, which is worse than none.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ from contextvars import ContextVar
49
+ from typing import Any
50
+
51
+ # Public result field. Explicit rather than inferred: a caller must never have
52
+ # to guess from the error text whether the operation may have taken effect.
53
+ UNKNOWN_FIELD = "outcomeUnknown"
54
+
55
+ _UNKNOWN_NOTE = (
56
+ "The request was sent but no usable response came back, so this operation "
57
+ "MAY have taken effect on the server. Verify the current state before "
58
+ "retrying — a blind retry could apply it twice."
59
+ )
60
+
61
+ # Set by a write immediately before it issues its mutating request; consumed by
62
+ # the harness. A ContextVar (not a module global) so concurrent calls and async
63
+ # tools never read each other's state.
64
+ _prior_state: ContextVar[dict[str, Any] | None] = ContextVar(
65
+ "governed_prior_state", default=None
66
+ )
67
+
68
+
69
+ def mark_unknown(payload: dict[str, Any]) -> dict[str, Any]:
70
+ """Return a NEW error payload flagged as having an undetermined outcome."""
71
+ marked = dict(payload)
72
+ marked[UNKNOWN_FIELD] = True
73
+ marked["note"] = _UNKNOWN_NOTE
74
+ return marked
75
+
76
+
77
+ def is_unknown(result: Any) -> bool:
78
+ """True when ``result`` is an error payload whose effect is undetermined."""
79
+ return isinstance(result, dict) and result.get(UNKNOWN_FIELD) is True
80
+
81
+
82
+ def capture_prior_state(state: dict[str, Any]) -> None:
83
+ """Stash before-state so the inverse survives a lost response.
84
+
85
+ Call this AFTER reading the before-state and BEFORE issuing the mutating
86
+ request. Overwrites any previously captured state for this call.
87
+ """
88
+ _prior_state.set(dict(state))
89
+
90
+
91
+ def take_prior_state() -> dict[str, Any] | None:
92
+ """Consume the captured before-state (harness-internal).
93
+
94
+ Consuming rather than peeking keeps one call's state from leaking into the
95
+ next on a reused thread or task.
96
+ """
97
+ state = _prior_state.get()
98
+ _prior_state.set(None)
99
+ return state
100
+
101
+
102
+ def clear_prior_state() -> None:
103
+ """Drop any captured state. Called at the start of every governed call."""
104
+ _prior_state.set(None)