Skip to content

Approval rules

Approval rules are per-database, table-glob policies that gate writes at /v1/query, /v1/batch, and /v1/apply. A write that touches a matching table either gets blocked outright (deny) or returns a 403 with an approval URL the human accepts (require_approval).

Reads are never gated. For row-level visibility, use Row-Level Security instead.

Console → Database → Approval rules. Or programmatically via the SDK (admin-role bearer required):

await db.approvalRules.create({
tableGlob: "production_*",
action: "require_approval",
note: "All production tables need human sign-off",
});

Same operations as HTTP — read with db.approvalRules.list(), remove with db.approvalRules.delete(ruleId). The cookie-session console endpoints still work for the UI:

POST /api/namespaces/{ns}/databases/{slug}/approval-rules

Glob syntax: * matches any chars, ? matches one. Glob is matched (case-insensitive) against unqualified table names parsed out of the SQL.

When a write hits a require_approval rule the API returns 403 with the approval shape:

{
"success": false,
"error": "Statement requires human approval before it can run",
"approvalToken": "appr_…",
"approvalUrl": "https://console.persql.com/approve/appr_…",
"hits": [
{ "tableGlob": "production_*", "action": "require_approval", "matchedTable": "production_users", "note": "" }
],
"expiresAt": "2026-05-05T12:30:00.000Z"
}

The SDK throws ApprovalRequiredError:

import { ApprovalRequiredError } from "@persql/sdk";
try {
await db.query("UPDATE production_users SET …");
} catch (e) {
if (e instanceof ApprovalRequiredError) {
console.log(`Open ${e.approvalUrl} to approve`);
// Block until a reviewer decides (or the token expires):
const status = await db.approvals.poll(e.approvalToken, {
intervalMs: 2000,
timeoutMs: 10 * 60 * 1000,
});
if (status.status === "approved") {
const result = await db.approvals.redeem(e.approvalToken);
}
}
}

Or check status without blocking via db.approvals.get(token). For long-running supervisors that want push-style notifications, register a webhook on approval_required / approval_resolved (see below) and let the platform call you instead.

deny rules throw PerSQLError(403) with no approval token — the write can never run as long as the rule exists.

The human opens console.persql.com/approve/{token}. They see:

  • The full SQL (or batch).
  • Every matched rule and the table it matched.
  • Approve / Deny buttons.

Approve → the agent’s db.approvals.redeem(token) runs the original SQL through the same auto-snapshot + query-log + pricing pipeline. Deny → the agent’s redeem call returns 403.

The reviewer must be a member of the namespace.

Approvals expire after 30 minutes by default (max 24h). After expiry, the agent has to start over with a fresh request. The underlying KV row sweeps itself.

Register a webhook (Console → Database → Webhooks) with the approval_required and/or approval_resolved events. PerSQL POSTs the payload to your URL when the corresponding event fires:

{
"id": "8f0…",
"type": "approval.required",
"ts": "2026-05-17T12:30:00.000Z",
"database": "<do-id>",
"approvalToken": "appr_…",
"status": "pending",
"hits": [{ "ruleId": "apprule_…", "tableGlob": "production_*", "action": "require_approval", "matchedTable": "production_users", "note": "" }]
}

approval.resolved is identical except status is approved or denied and type is approval.resolved. Use this to drive a Slack notification, page an oncall reviewer, or kick a downstream workflow that should run once the write actually executes.

Delivery uses the existing webhook queue — at-least-once with exponential backoff, signed with your webhook secret.

  • Glob-only matching. Row-shape predicates (WHERE tier='paid') are not supported — use RLS for that.
  • Mutating SQL only. Reads pass through.
  • Approvals are per-request, not per-table. An UPDATE touching three matched tables creates one pending approval, not three.