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.
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/.claude-plugin/plugin.json +1 -1
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/.mcp.json +1 -1
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/CHANGELOG.md +8 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/PKG-INFO +4 -2
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/README.md +3 -1
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/SECURITY.md +18 -1
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/docs/VERIFICATION.md +4 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/openclaw.plugin.json +1 -1
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/package.json +1 -1
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/audit.py +178 -34
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/decorators.py +78 -14
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/pyproject.toml +1 -1
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/server.json +2 -2
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/SKILL.md +3 -1
- postgres_aiops-0.13.0/tests/test_audit_guardrails.py +209 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_gov_decorators.py +3 -5
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/uv.lock +1 -1
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/.github/workflows/mcp-publish.yml +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/.github/workflows/publish.yml +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/.gitignore +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/Dockerfile +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/LICENSE +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/RELEASE_NOTES.md +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/glama.json +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/__init__.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/__init__.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/_common.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/_root.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/activity.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/analyze.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/doctor.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/index.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/init.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/overview.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/query.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/remediate.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/replication.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/secret.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/server.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/table.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/undo.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/config.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/connection.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/doctor.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/__init__.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/budget.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/outcome.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/paths.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/patterns.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/policy.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/sanitize.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/undo.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/__init__.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/_annotations.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/_shared.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/server.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/__init__.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/activity.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/analysis.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/indexes.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/queries.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/remediation.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/replication.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/server.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/tables.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/mcp_server/tools/undo.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/__init__.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/_util.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/activity.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/analysis.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/indexes.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/overview.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/queries.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/remediation.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/replication.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/server.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/tables.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/postgres_aiops/secretstore.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/references/agent-guardrails.md +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/references/capabilities.md +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/references/cli-reference.md +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/references/setup-guide.md +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/smithery.yaml +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/conftest.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_analysis.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_cli_audited.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_cli_governed_exit.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_cli_reads.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_cli_remediate.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_cli_secret.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_cli_writes.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_config.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_connection.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_connection_lost.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_connection_more.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_doctor.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_gov_audit.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_gov_patterns.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_gov_policy.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_governance_persistence.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_init.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_mcp_tools.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_ops_more.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_optional_fields.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_package_layout.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_reads.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_secretstore.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_secretstore_more.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_self_lockout.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_smoke.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_tool_annotations.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_truncation.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_undo_executor.py +0 -0
- {postgres_aiops-0.12.0 → postgres_aiops-0.13.0}/tests/test_undo_replay.py +0 -0
- {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.
|
|
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",
|
|
@@ -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.
|
|
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,
|
|
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,
|
|
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** —
|
|
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.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Unified audit logging engine — all
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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.
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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.
|
|
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.
|
|
10
|
+
"version": "0.13.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "postgres-aiops",
|
|
15
|
-
"version": "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,
|
|
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.
|