postgres-aiops 0.12.0__tar.gz → 0.13.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 (115) hide show
  1. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/.claude-plugin/plugin.json +1 -1
  2. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/.mcp.json +1 -1
  3. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/CHANGELOG.md +8 -0
  4. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/PKG-INFO +4 -2
  5. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/README.md +3 -1
  6. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/SECURITY.md +18 -1
  7. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/docs/VERIFICATION.md +4 -0
  8. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/openclaw.plugin.json +1 -1
  9. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/package.json +1 -1
  10. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/audit.py +178 -34
  11. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/decorators.py +78 -14
  12. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/pyproject.toml +1 -1
  13. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/server.json +2 -2
  14. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/SKILL.md +3 -1
  15. postgres_aiops-0.13.0/tests/test_audit_guardrails.py +209 -0
  16. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_gov_decorators.py +3 -5
  17. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/uv.lock +1 -1
  18. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/.github/workflows/mcp-publish.yml +0 -0
  19. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/.github/workflows/publish.yml +0 -0
  20. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/.gitignore +0 -0
  21. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/Dockerfile +0 -0
  22. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/LICENSE +0 -0
  23. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/RELEASE_NOTES.md +0 -0
  24. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/glama.json +0 -0
  25. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/__init__.py +0 -0
  26. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/__init__.py +0 -0
  27. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/_common.py +0 -0
  28. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/_root.py +0 -0
  29. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/activity.py +0 -0
  30. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/analyze.py +0 -0
  31. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/doctor.py +0 -0
  32. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/index.py +0 -0
  33. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/init.py +0 -0
  34. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/overview.py +0 -0
  35. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/query.py +0 -0
  36. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/remediate.py +0 -0
  37. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/replication.py +0 -0
  38. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/secret.py +0 -0
  39. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/server.py +0 -0
  40. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/table.py +0 -0
  41. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/undo.py +0 -0
  42. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/config.py +0 -0
  43. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/connection.py +0 -0
  44. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/doctor.py +0 -0
  45. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/__init__.py +0 -0
  46. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/budget.py +0 -0
  47. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/outcome.py +0 -0
  48. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/paths.py +0 -0
  49. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/patterns.py +0 -0
  50. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/policy.py +0 -0
  51. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/sanitize.py +0 -0
  52. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/undo.py +0 -0
  53. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/__init__.py +0 -0
  54. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/_annotations.py +0 -0
  55. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/_shared.py +0 -0
  56. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/server.py +0 -0
  57. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/__init__.py +0 -0
  58. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/activity.py +0 -0
  59. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/analysis.py +0 -0
  60. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/indexes.py +0 -0
  61. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/queries.py +0 -0
  62. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/remediation.py +0 -0
  63. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/replication.py +0 -0
  64. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/server.py +0 -0
  65. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/tables.py +0 -0
  66. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/undo.py +0 -0
  67. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/__init__.py +0 -0
  68. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/_util.py +0 -0
  69. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/activity.py +0 -0
  70. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/analysis.py +0 -0
  71. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/indexes.py +0 -0
  72. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/overview.py +0 -0
  73. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/queries.py +0 -0
  74. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/remediation.py +0 -0
  75. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/replication.py +0 -0
  76. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/server.py +0 -0
  77. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/tables.py +0 -0
  78. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/secretstore.py +0 -0
  79. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/references/agent-guardrails.md +0 -0
  80. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/references/capabilities.md +0 -0
  81. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/references/cli-reference.md +0 -0
  82. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/references/setup-guide.md +0 -0
  83. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/smithery.yaml +0 -0
  84. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/conftest.py +0 -0
  85. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_analysis.py +0 -0
  86. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_cli_audited.py +0 -0
  87. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_cli_governed_exit.py +0 -0
  88. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_cli_reads.py +0 -0
  89. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_cli_remediate.py +0 -0
  90. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_cli_secret.py +0 -0
  91. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_cli_writes.py +0 -0
  92. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_config.py +0 -0
  93. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_connection.py +0 -0
  94. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_connection_lost.py +0 -0
  95. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_connection_more.py +0 -0
  96. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_doctor.py +0 -0
  97. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_gov_audit.py +0 -0
  98. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_gov_patterns.py +0 -0
  99. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_gov_policy.py +0 -0
  100. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_governance_persistence.py +0 -0
  101. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_init.py +0 -0
  102. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_mcp_tools.py +0 -0
  103. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_ops_more.py +0 -0
  104. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_optional_fields.py +0 -0
  105. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_package_layout.py +0 -0
  106. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_reads.py +0 -0
  107. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_secretstore.py +0 -0
  108. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_secretstore_more.py +0 -0
  109. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_self_lockout.py +0 -0
  110. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_smoke.py +0 -0
  111. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_tool_annotations.py +0 -0
  112. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_truncation.py +0 -0
  113. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_undo_executor.py +0 -0
  114. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_undo_replay.py +0 -0
  115. {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_writes.py +0 -0
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "postgres-aiops",
4
4
  "displayName": "Postgres AIops",
5
- "version": "0.12.0",
5
+ "version": "0.13.0",
6
6
  "description": "Governed PostgreSQL DBA ops: slow-query RCA, bloat/vacuum & blocking-lock analysis; 35 MCP tools.",
7
7
  "author": {
8
8
  "name": "Wei Zhou",
@@ -5,7 +5,7 @@
5
5
  "command": "uvx",
6
6
  "args": [
7
7
  "--from",
8
- "postgres-aiops==0.12.0",
8
+ "postgres-aiops==0.13.0",
9
9
  "postgres-aiops-mcp"
10
10
  ]
11
11
  }
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.13.0 — 2026-10-30
4
+
5
+ ### Security and observability (BREAKING)
6
+ - **BREAKING:** Writes now require a durable audit intent before contacting the upstream platform. If the audit database cannot accept the intent, the write fails closed; a failed final audit update leaves an unresolved `pending` intent.
7
+ - Real writes now commit a durable `pending` audit intent before the upstream call. If audit storage is unavailable, the write does not start; a failed final update leaves a visible pending intent and blocks further writes in that process.
8
+ - Audit rows retain outcome metadata instead of full business responses. `AIOPS_WORKFLOW_ID` links calls across a workflow; optional `AIOPS_TELEMETRY=1` emits metadata-only JSON events and `AuditEngine.health()` reports audit failures and pending intents.
9
+
10
+
3
11
  ## v0.12.0 — 2026-10-15
4
12
 
5
13
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: postgres-aiops
3
- Version: 0.12.0
3
+ Version: 0.13.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
5
  Author-email: Wei Zhou <zhouwei008@gmail.com>
6
6
  License-Expression: MIT
@@ -18,6 +18,8 @@ Description-Content-Type: text/markdown
18
18
 
19
19
  # Postgres AIops
20
20
 
21
+ > **Audit availability:** Real writes require a committed `pending` audit row and stop before contacting the platform if storage is unavailable. Reads and previews may continue with a warning; new audit rows store outcome summaries rather than full responses.
22
+
21
23
  > **Disclaimer**: Community-maintained open-source project. **Not affiliated with, endorsed by, or sponsored by the PostgreSQL Global Development Group or any vendor.** "PostgreSQL" and the elephant logo are trademarks of the PostgreSQL Community Association; all product/trademark names belong to their respective owners. MIT licensed.
22
24
 
23
25
  Governed AI-ops for **PostgreSQL DBA operations** — connecting to a server with
@@ -152,7 +154,7 @@ postgres-aiops-mcp
152
154
 
153
155
  Every MCP tool passes through the bundled `@governed_tool` harness:
154
156
 
155
- - **Audit** — every call (params, result, status, duration, risk tier, and any
157
+ - **Audit** — every call (params, outcome summary, status, duration, risk tier, and any
156
158
  operator-supplied approver/rationale) is logged to `~/.postgres-aiops/audit.db`
157
159
  (relocatable via `POSTGRES_AIOPS_HOME`). The CLI writes the same row the MCP
158
160
  path does — there is no unaudited entry point.
@@ -2,6 +2,8 @@
2
2
 
3
3
  # Postgres AIops
4
4
 
5
+ > **Audit availability:** Real writes require a committed `pending` audit row and stop before contacting the platform if storage is unavailable. Reads and previews may continue with a warning; new audit rows store outcome summaries rather than full responses.
6
+
5
7
  > **Disclaimer**: Community-maintained open-source project. **Not affiliated with, endorsed by, or sponsored by the PostgreSQL Global Development Group or any vendor.** "PostgreSQL" and the elephant logo are trademarks of the PostgreSQL Community Association; all product/trademark names belong to their respective owners. MIT licensed.
6
8
 
7
9
  Governed AI-ops for **PostgreSQL DBA operations** — connecting to a server with
@@ -136,7 +138,7 @@ postgres-aiops-mcp
136
138
 
137
139
  Every MCP tool passes through the bundled `@governed_tool` harness:
138
140
 
139
- - **Audit** — every call (params, result, status, duration, risk tier, and any
141
+ - **Audit** — every call (params, outcome summary, status, duration, risk tier, and any
140
142
  operator-supplied approver/rationale) is logged to `~/.postgres-aiops/audit.db`
141
143
  (relocatable via `POSTGRES_AIOPS_HOME`). The CLI writes the same row the MCP
142
144
  path does — there is no unaudited entry point.
@@ -41,7 +41,7 @@ reports.
41
41
  ### Governed Operations
42
42
  Every MCP tool runs through the bundled `@governed_tool` harness
43
43
  (`postgres_aiops.governance`):
44
- - **Audit** — every call logged to a local SQLite DB under `~/.postgres-aiops/`
44
+ - **Audit** — calls logged to a local SQLite DB when storage is available under `~/.postgres-aiops/`
45
45
  (relocatable via `POSTGRES_AIOPS_HOME`), agent-attributed, secret-redacted.
46
46
  - **Token/runaway budget** — hard ceilings (`POSTGRES_MAX_TOOL_CALLS` /
47
47
  `POSTGRES_MAX_TOOL_SECONDS` — the env-var names the bundled harness reads) plus
@@ -90,3 +90,20 @@ uv run ruff check .
90
90
 
91
91
  The latest released version receives security fixes. This is a preview (0.x);
92
92
  pin a version in production.
93
+
94
+ ## Audit availability and telemetry
95
+
96
+ Real writes first commit a `pending` audit intent. If audit storage is unavailable,
97
+ the upstream write does not start. If finalization fails after an upstream call,
98
+ the intent stays visible and further writes in that process are blocked. Check
99
+ that the process is no longer executing and verify server state before calling
100
+ `AuditEngine.reconcile_pending(audit_id)` to mark the intent `unknown`.
101
+
102
+ Reads and dry-run previews continue when audit storage fails, with a warning
103
+ rather than a durable row. New audit rows store outcome summaries instead of
104
+ full business responses; older rows and rotated archives are not rewritten
105
+ automatically. Parameter redaction still follows each tool's
106
+ `sensitive_params`. `AIOPS_WORKFLOW_ID` supplies a correlation ID, and optional
107
+ `AIOPS_TELEMETRY=1` emits metadata-only JSON logs. `AuditEngine.health()`
108
+ reports availability, write blocking, failure count, and pending intent count.
109
+ On POSIX the rotation lock spans processes; on Windows it is process-local.
@@ -174,3 +174,7 @@ based analysis, replication reads, session termination, `REINDEX`, and
174
174
  `pg_basebackup`) and re-run `repl status` / `repl slots` / `repl wal`.
175
175
  - The analyses also accept **injected records**, so exported rows from a cluster
176
176
  you cannot write to still exercise section 3 without any write access.
177
+
178
+ ## Governance hardening — local synthetic verification (2026-10-30)
179
+
180
+ The shared governance tests exercise a durable `pending` row before a write, refusal when the audit DB cannot persist that row, a visible pending row after finalization failure, explicit reconciliation to `unknown`, async/preview parity, result minimization, and metadata-only telemetry with `AIOPS_WORKFLOW_ID`. The tests use a temporary SQLite DB and synthetic tools; they do not claim a new live upstream write was performed. Existing audit rows and rotated archives from earlier releases are not automatically scrubbed. A pending intent from another process is never auto-reconciled. On POSIX the rotation/intent lock spans processes; on Windows it currently protects only threads in this process.
@@ -11,7 +11,7 @@
11
11
  "command": "uvx",
12
12
  "args": [
13
13
  "--from",
14
- "postgres-aiops==0.12.0",
14
+ "postgres-aiops==0.13.0",
15
15
  "postgres-aiops-mcp"
16
16
  ]
17
17
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zw008/postgres-aiops",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Governed PostgreSQL DBA ops: slow-query RCA, bloat/vacuum & blocking-lock analysis; 35 MCP tools.",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -1,4 +1,4 @@
1
- """Unified audit logging engine — all PostgreSQL skills write to a single SQLite database.
1
+ """Unified audit logging engine — all Monitoring skills write to a single SQLite database.
2
2
 
3
3
  Replaces 7 per-skill JSON Lines audit loggers with one shared ``~/.postgres-aiops/audit.db``.
4
4
  Framework-agnostic: works with Claude, Codex, local agents, or any MCP client.
@@ -12,12 +12,18 @@ import logging
12
12
  import os
13
13
  import sqlite3
14
14
  import threading
15
+ from contextlib import contextmanager
15
16
  from datetime import UTC, datetime, timedelta
16
17
  from pathlib import Path
17
18
  from typing import Any
18
19
 
19
20
  from postgres_aiops.governance.paths import ops_path
20
21
 
22
+ try:
23
+ import fcntl
24
+ except ImportError: # Windows has no fcntl; the in-process lock still applies.
25
+ fcntl = None
26
+
21
27
  _log = logging.getLogger("postgres-aiops.audit")
22
28
 
23
29
  # Back-compat override hook. When None (default) the path resolves via
@@ -29,6 +35,26 @@ _DEFAULT_DB: Path | None = None
29
35
  _MAX_DB_SIZE_BYTES = 100 * 1024 * 1024 # 100 MB
30
36
  _MAX_ARCHIVES = 5
31
37
  _BUSY_TIMEOUT_MS = 5000
38
+ _rotation_thread_lock = threading.RLock()
39
+
40
+
41
+ @contextmanager
42
+ def _rotation_guard(path: Path):
43
+ """Serialize rotation and intent insertion across local processes."""
44
+ with _rotation_thread_lock:
45
+ fd = os.open(path.with_name("audit.lock"), os.O_CREAT | os.O_RDWR, 0o600)
46
+ try:
47
+ if fcntl is not None:
48
+ fcntl.flock(fd, fcntl.LOCK_EX)
49
+ yield
50
+ finally:
51
+ if fcntl is not None:
52
+ fcntl.flock(fd, fcntl.LOCK_UN)
53
+ os.close(fd)
54
+
55
+
56
+ class AuditUnavailable(RuntimeError):
57
+ """A write was stopped because its durable audit intent could not be recorded."""
32
58
 
33
59
  _CREATE_TABLE = """\
34
60
  CREATE TABLE IF NOT EXISTS audit_log (
@@ -78,6 +104,8 @@ class AuditEngine:
78
104
  else:
79
105
  self._path = _DEFAULT_DB or ops_path("audit.db")
80
106
  self._ok = False
107
+ self._write_blocked = False
108
+ self._write_failures = 0
81
109
  try:
82
110
  self._path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
83
111
  self._init_db()
@@ -90,6 +118,7 @@ class AuditEngine:
90
118
  conn = self._connect()
91
119
  conn.execute(_CREATE_TABLE)
92
120
  conn.execute("PRAGMA journal_mode=WAL")
121
+ conn.execute("PRAGMA synchronous=FULL")
93
122
  self._migrate(conn)
94
123
  conn.commit()
95
124
  conn.close()
@@ -126,6 +155,7 @@ class AuditEngine:
126
155
  def _connect(self) -> sqlite3.Connection:
127
156
  conn = sqlite3.connect(str(self._path), timeout=_BUSY_TIMEOUT_MS / 1000)
128
157
  conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
158
+ conn.execute("PRAGMA synchronous=FULL")
129
159
  return conn
130
160
 
131
161
  def log(
@@ -144,43 +174,128 @@ class AuditEngine:
144
174
  rationale: str = "",
145
175
  approved_by: str = "",
146
176
  risk_tier: str = "",
147
- ) -> None:
148
- """Write one audit record. Never raises — swallows errors to avoid
149
- disrupting the actual tool execution.
177
+ strict: bool = False,
178
+ ) -> int | None:
179
+ """Append a row. Strict mode refuses writes if the intent cannot persist.
150
180
 
151
181
  rationale / approved_by / risk_tier carry the accountability trail:
152
182
  *why* a change was made, *who* signed off, and the *approval tier* the
153
183
  policy engine assigned (none/confirm/dual/review).
154
184
  """
155
185
  if not self._ok:
156
- return
186
+ if strict:
187
+ self._write_blocked = True
188
+ self._write_failures += 1
189
+ raise AuditUnavailable("Audit storage is unavailable; write was not started")
190
+ self._write_failures += 1
191
+ return None
192
+ try:
193
+ with _rotation_guard(self._path):
194
+ self._maybe_rotate()
195
+ conn = self._connect()
196
+ try:
197
+ cursor = conn.execute(
198
+ _INSERT,
199
+ (
200
+ datetime.now(tz=UTC).isoformat(),
201
+ skill,
202
+ tool,
203
+ _safe_json(params),
204
+ _safe_json(_summarize_result(result)),
205
+ status,
206
+ duration_ms,
207
+ agent,
208
+ workflow_id,
209
+ user or _current_user(),
210
+ risk_level,
211
+ rationale,
212
+ approved_by,
213
+ risk_tier,
214
+ ),
215
+ )
216
+ conn.commit()
217
+ audit_id = cursor.lastrowid
218
+ finally:
219
+ conn.close()
220
+ _log.debug("[AUDIT] %s.%s -> %s (%dms)", skill, tool, status, duration_ms)
221
+ return audit_id
222
+ except Exception:
223
+ self._write_failures += 1
224
+ _log.warning("Failed to write audit log", exc_info=True)
225
+ if strict:
226
+ self._write_blocked = True
227
+ raise AuditUnavailable("Audit storage is unavailable; write was not started") from None
228
+ return None
229
+
230
+ def begin_write(self, **metadata: Any) -> int:
231
+ """Commit a durable intent before the caller sends an upstream write."""
232
+ if self._write_blocked:
233
+ raise AuditUnavailable("Audit storage failed; further writes are blocked")
234
+ audit_id = self.log(status="pending", strict=True, **metadata)
235
+ assert audit_id is not None
236
+ return audit_id
237
+
238
+ def finish_write(self, audit_id: int, *, result: Any, status: str, duration_ms: int) -> None:
239
+ """Resolve a write intent; on failure leave a recoverable pending row."""
157
240
  try:
158
- self._maybe_rotate()
159
241
  conn = self._connect()
160
- conn.execute(
161
- _INSERT,
162
- (
163
- datetime.now(tz=UTC).isoformat(),
164
- skill,
165
- tool,
166
- _safe_json(params),
167
- _safe_json(result),
168
- status,
169
- duration_ms,
170
- agent,
171
- workflow_id,
172
- user or _current_user(),
173
- risk_level,
174
- rationale,
175
- approved_by,
176
- risk_tier,
177
- ),
242
+ try:
243
+ cursor = conn.execute(
244
+ "UPDATE audit_log SET result = ?, status = ?, duration_ms = ? "
245
+ "WHERE id = ? AND status = 'pending'",
246
+ (_safe_json(_summarize_result(result)), status, duration_ms, audit_id),
247
+ )
248
+ if cursor.rowcount != 1:
249
+ raise RuntimeError(f"audit intent {audit_id} is missing")
250
+ conn.commit()
251
+ finally:
252
+ conn.close()
253
+ except Exception:
254
+ self._write_blocked = True
255
+ self._write_failures += 1
256
+ _log.warning("Failed to finalize audit intent %s", audit_id, exc_info=True)
257
+ raise AuditUnavailable(
258
+ f"Audit intent {audit_id} remains pending; operation may have completed"
259
+ ) from None
260
+
261
+ def reconcile_pending(self, audit_id: int) -> bool:
262
+ """Mark an orphaned intent unknown after an operator checks the process.
263
+
264
+ Never run this automatically: another process may still be executing
265
+ the write. The row remains visible until explicitly reconciled.
266
+ """
267
+ conn = self._connect()
268
+ try:
269
+ cursor = conn.execute(
270
+ "UPDATE audit_log SET status = 'unknown', result = ? "
271
+ "WHERE id = ? AND status = 'pending'",
272
+ (_safe_json({"auditInterrupted": True}), audit_id),
178
273
  )
179
274
  conn.commit()
275
+ return cursor.rowcount == 1
276
+ finally:
180
277
  conn.close()
181
- _log.debug("[AUDIT] %s.%s -> %s (%dms)", skill, tool, status, duration_ms)
182
- except Exception:
183
- _log.warning("Failed to write audit log", exc_info=True)
278
+
279
+ def health(self) -> dict[str, Any]:
280
+ """Safe local health counters for operators and telemetry exporters."""
281
+ pending: int | None = None
282
+ if self._ok:
283
+ try:
284
+ conn = self._connect()
285
+ try:
286
+ pending = conn.execute(
287
+ "SELECT COUNT(*) FROM audit_log WHERE status = 'pending'"
288
+ ).fetchone()[0]
289
+ finally:
290
+ conn.close()
291
+ except Exception:
292
+ pass
293
+ return {
294
+ "available": self._ok and pending is not None and not self._write_blocked,
295
+ "writeBlocked": self._write_blocked,
296
+ "auditWriteFailures": self._write_failures,
297
+ "pendingWrites": pending,
298
+ }
184
299
 
185
300
  # ── Rotation ──────────────────────────────────────────────────────
186
301
 
@@ -191,20 +306,27 @@ class AuditEngine:
191
306
  return
192
307
  if self._path.stat().st_size < _MAX_DB_SIZE_BYTES:
193
308
  return
309
+ pending_conn = self._connect()
310
+ try:
311
+ if pending_conn.execute(
312
+ "SELECT 1 FROM audit_log WHERE status = 'pending' LIMIT 1"
313
+ ).fetchone():
314
+ return
315
+ finally:
316
+ pending_conn.close()
194
317
  archive_name = self._path.with_suffix(
195
318
  f".{datetime.now(tz=UTC).strftime('%Y%m%d-%H%M%S')}.db"
196
319
  )
197
320
  # Checkpoint the WAL into the main file before renaming, otherwise
198
321
  # un-checkpointed rows in audit.db-wal are silently lost (the
199
322
  # sidecars belong to the OLD path and never follow the archive).
323
+ conn = self._connect()
200
324
  try:
201
- conn = self._connect()
202
- try:
203
- conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
204
- finally:
205
- conn.close()
206
- except Exception:
207
- _log.warning("WAL checkpoint before rotation failed", exc_info=True)
325
+ checkpoint = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
326
+ if checkpoint is None or checkpoint[0]:
327
+ raise RuntimeError(f"WAL checkpoint incomplete: {checkpoint}")
328
+ finally:
329
+ conn.close()
208
330
  self._path.rename(archive_name)
209
331
  # Drop now-stale sidecars (empty after a successful checkpoint).
210
332
  for suffix in ("-wal", "-shm"):
@@ -327,6 +449,28 @@ def _safe_json(obj: Any) -> str:
327
449
  return json.dumps({"_raw": str(obj)})
328
450
 
329
451
 
452
+ def _summarize_result(result: Any) -> dict[str, Any]:
453
+ """Persist only outcome metadata; never copy a tool's business payload."""
454
+ if isinstance(result, dict):
455
+ summary: dict[str, Any] = {"kind": "object"}
456
+ if result.get("error"):
457
+ summary["error"] = True
458
+ if result.get("outcomeUnknown") is True:
459
+ summary["outcomeUnknown"] = True
460
+ if result.get("_undo_id"):
461
+ summary["undoRecorded"] = True
462
+ if result.get("_pattern_id"):
463
+ summary["patternMatched"] = True
464
+ for key in ("truncated", "returned", "limit"):
465
+ value = result.get(key)
466
+ if type(value) in (bool, int):
467
+ summary[key] = value
468
+ return summary
469
+ if isinstance(result, (list, tuple)):
470
+ return {"kind": "list", "count": len(result)}
471
+ return {"kind": type(result).__name__}
472
+
473
+
330
474
  def detect_agent() -> str:
331
475
  """Infer the calling AI agent from environment variables."""
332
476
  if os.environ.get("CLAUDE_SESSION_ID") or os.environ.get("CLAUDE_CODE"):
@@ -25,6 +25,7 @@ Registration enforcement::
25
25
  from __future__ import annotations
26
26
 
27
27
  import inspect
28
+ import json
28
29
  import logging
29
30
  import os
30
31
  import re
@@ -107,6 +108,7 @@ def governed_tool(
107
108
  )
108
109
  try:
109
110
  _pre_check(state)
111
+ _begin_write(state)
110
112
  return _annotate_result(state, await func(*args, **kwargs))
111
113
  except (PolicyDenied, BudgetExceeded):
112
114
  raise
@@ -124,6 +126,7 @@ def governed_tool(
124
126
  )
125
127
  try:
126
128
  _pre_check(state)
129
+ _begin_write(state)
127
130
  return _annotate_result(state, func(*args, **kwargs))
128
131
  except (PolicyDenied, BudgetExceeded):
129
132
  raise
@@ -163,6 +166,7 @@ class _CallState:
163
166
  "pattern_match", "audit",
164
167
  "safe_params", "env", "risk_level", "timeout_seconds",
165
168
  "rationale", "approved_by", "risk_tier", "undo", "dry_run",
169
+ "intent_id", "workflow_id",
166
170
  )
167
171
 
168
172
  def __init__(
@@ -191,6 +195,9 @@ class _CallState:
191
195
  self.risk_level = risk_level
192
196
  self.timeout_seconds = timeout_seconds
193
197
  self.audit = get_engine()
198
+ self.intent_id: int | None = None
199
+ candidate = os.environ.get("AIOPS_WORKFLOW_ID", "")
200
+ self.workflow_id = candidate if re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", candidate) else ""
194
201
 
195
202
  # Map positional args to parameter names so they appear in the audit
196
203
  # log and participate in env scoping (previously only kwargs did).
@@ -272,6 +279,23 @@ def _pre_check(state: _CallState) -> None:
272
279
  state.pattern_match = None
273
280
 
274
281
 
282
+ def _begin_write(state: _CallState) -> None:
283
+ """Refuse an upstream write unless its intent is durably committed."""
284
+ if state.risk_level == "low" or state.dry_run:
285
+ return
286
+ state.intent_id = state.audit.begin_write(
287
+ skill=state.skill,
288
+ tool=state.tool_name,
289
+ params=state.safe_params,
290
+ agent=state.agent,
291
+ workflow_id=state.workflow_id,
292
+ risk_level=state.risk_level,
293
+ rationale=state.rationale,
294
+ approved_by=state.approved_by,
295
+ risk_tier=state.risk_tier,
296
+ )
297
+
298
+
275
299
  def _annotate_result(state: _CallState, result: Any) -> Any:
276
300
  """Record the result, surface pattern context, and record an undo token.
277
301
 
@@ -425,20 +449,60 @@ def _finalize(state: _CallState) -> None:
425
449
  pattern_id = state.pattern_match.pattern.pattern_id if state.pattern_match else ""
426
450
  pattern_armed = bool(state.pattern_match and state.pattern_match.armed)
427
451
 
428
- state.audit.log(
429
- skill=state.skill,
430
- tool=state.tool_name,
431
- params=state.safe_params,
432
- result=_with_pattern_context(state.result, pattern_id, pattern_armed),
433
- status=final_status,
434
- duration_ms=duration,
435
- agent=state.agent,
436
- user="",
437
- risk_level=state.risk_level,
438
- rationale=state.rationale,
439
- approved_by=state.approved_by,
440
- risk_tier=state.risk_tier,
441
- )
452
+ audit_id = state.intent_id
453
+ persisted = False
454
+ try:
455
+ if audit_id is not None:
456
+ state.audit.finish_write(
457
+ audit_id,
458
+ result=_with_pattern_context(state.result, pattern_id, pattern_armed),
459
+ status=final_status,
460
+ duration_ms=duration,
461
+ )
462
+ persisted = True
463
+ else:
464
+ audit_id = state.audit.log(
465
+ skill=state.skill,
466
+ tool=state.tool_name,
467
+ params=state.safe_params,
468
+ result=_with_pattern_context(state.result, pattern_id, pattern_armed),
469
+ status=final_status,
470
+ duration_ms=duration,
471
+ agent=state.agent,
472
+ workflow_id=state.workflow_id,
473
+ user="",
474
+ risk_level=state.risk_level,
475
+ rationale=state.rationale,
476
+ approved_by=state.approved_by,
477
+ risk_tier=state.risk_tier,
478
+ )
479
+ persisted = audit_id is not None
480
+ finally:
481
+ _emit_telemetry(state, audit_id, final_status if persisted else "unknown", duration, persisted)
482
+
483
+
484
+ def _emit_telemetry(
485
+ state: _CallState, audit_id: int | None, status: str, duration_ms: int, persisted: bool
486
+ ) -> None:
487
+ """Optional metadata-only event for a local log collector."""
488
+ if os.environ.get("AIOPS_TELEMETRY") != "1":
489
+ return
490
+ truncated = state.result.get("truncated") if isinstance(state.result, dict) else None
491
+ if type(truncated) is not bool:
492
+ truncated = None
493
+ payload = {
494
+ "event": "aiops.tool",
495
+ "skill": state.skill,
496
+ "tool": state.tool_name,
497
+ "workflowId": state.workflow_id,
498
+ "auditId": audit_id,
499
+ "riskLevel": state.risk_level,
500
+ "status": status,
501
+ "durationMs": duration_ms,
502
+ "auditPersisted": persisted,
503
+ "truncated": truncated,
504
+ }
505
+ _log.info("AIOPS_TELEMETRY %s", json.dumps(payload, ensure_ascii=False))
442
506
 
443
507
 
444
508
  def _infer_skill(func: Any) -> str:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "postgres-aiops"
7
- version = "0.12.0"
7
+ version = "0.13.0"
8
8
  description = "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)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/AIops-tools/Postgres-AIops",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.12.0",
10
+ "version": "0.13.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "postgres-aiops",
15
- "version": "0.12.0",
15
+ "version": "0.13.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -32,6 +32,8 @@ compatibility: >
32
32
 
33
33
  # Postgres AIops
34
34
 
35
+ > **Audit availability:** Real writes require a durable audit intent. Reads and previews can continue if audit storage fails; check the warning and audit health before relying on their audit trail.
36
+
35
37
  > **Disclaimer**: Community-maintained open-source project, **not affiliated with, endorsed by, or sponsored by the PostgreSQL Global Development Group or any vendor.** "PostgreSQL" and related trademarks belong to their owners. Source at [github.com/AIops-tools/Postgres-AIops](https://github.com/AIops-tools/Postgres-AIops) under the MIT license.
36
38
 
37
39
  Governed PostgreSQL DBA operations — **35 MCP tools**, every one wrapped with the bundled `@governed_tool` harness: a local unified audit log under `~/.postgres-aiops/`, token/runaway budget guard, undo-token recording, and descriptive risk-tier labels. The role password is stored **encrypted** (`~/.postgres-aiops/secrets.enc`, Fernet + scrypt) — never plaintext on disk.
@@ -144,7 +146,7 @@ permitted. That is your agent's judgement, or the permission of the account you
144
146
  INSERT/UPDATE/DELETE/DDL) — writes then fail at the server). There is no read-only switch, policy
145
147
  file, or approval gate.
146
148
 
147
- - **Audit is the guarantee, and it is not bypassable.** Every operation — MCP and CLI alike — is logged to `~/.postgres-aiops/audit.db` (relocatable via `POSTGRES_AIOPS_HOME`): params, result, status, duration, and the risk tier. The CLI writes the same row the MCP path does.
149
+ - **Audit is the guarantee, and it is not bypassable.** Every operation — MCP and CLI alike — is logged to `~/.postgres-aiops/audit.db` (relocatable via `POSTGRES_AIOPS_HOME`): params, outcome summary, status, duration, and the risk tier. The CLI writes the same row the MCP path does.
148
150
  - `POSTGRES_AUDIT_APPROVED_BY` / `POSTGRES_AUDIT_RATIONALE` are optional annotations recorded on the audit row (who/why); they are never required and never block.
149
151
  - **Runaway guard** — a safety backstop, not authorization: the same call looped in a tight window trips a circuit breaker. Disable with `POSTGRES_RUNAWAY_MAX=0`.
150
152
  - Writes support `--dry-run` / `dry_run=True` and double confirmation at the CLI.