QAIL.rs v1.3.0 deserves a longer explanation than a compact changelog row. The main feature is native vertical access policy: QAIL can now check table, operation, and column permissions before a QAIL AST reaches PostgreSQL. The rest of the release is the audit pass that hardened the execution paths around that new policy surface.
The Short Version
- PostgreSQL RLS remains the horizontal row boundary.
- QAIL native access policy adds the vertical boundary: tables, operations, read columns, write columns, and RETURNING columns.
- The gateway can load the policy from TOML or JSON through qail.toml's [access] section.
- The policy path is enforced across REST CRUD, nested and expanded reads, RPC probes, QAIL text/binary/batch endpoints, transaction queries, and WebSocket live-query paths.
- The same release also hardens prepared statement caching, strict migrations, MERGE/source-query access checks, numeric response handling, workflow resume behavior, Qdrant encoding, and SDK route construction.
Why RLS Was Not Enough
RLS is very good at answering one question: which rows can this database role see or mutate? It is horizontal isolation. It does not naturally model every application-level vertical rule, such as whether an operator may read orders.private_note, whether a support role may update orders.status but not orders.total, or whether a generated API route should reject a RETURNING clause that exposes a protected column.
Before v1.3.0, QAIL had gateway policy machinery and PostgreSQL RLS helpers, but there was no core-native policy model that every AST entry point could share. v1.3.0 moves that vertical check into qail_core::access so it is not only a gateway YAML concern. The policy can be evaluated before the AST is encoded or sent to the driver.
The New Policy Shape
The policy is deny-by-default unless configured otherwise. A table policy can define allowed operations, denied operations, read column rules, write column rules, returning column rules, role gates, and scope gates. A wildcard table policy can act as fallback, and super-admin bypass is only available through the core SuperAdminToken path.
[access]
enabled = true
path = "access-policy.toml"
default_decision = "deny"
[tables.orders]
operations = ["read", "update"]
read_columns = { only = ["id", "status", "total", "created_at"] }
write_columns = { only = ["status"] }
returning_columns = { only = ["id", "status", "updated_at"] }
require_any_role = ["operator", "administrator"]
require_scopes = ["orders:read"]
The important part is where this runs. It is not a cosmetic response filter. If a request projects a denied column, filters on a denied column, writes a denied payload column, returns a denied column, or hides a denied read inside a subquery, the request is rejected before normal database execution.
What Gateway Now Enforces
- REST list/get/create/update/delete routes check the native access policy before execution.
- Nested and expanded read paths check both parent and child access.
- RPC policy probes check the table access implied by the function contract.
- QAIL text, binary, fast, and batch endpoints check the decoded AST.
- Transaction queries check each command before it enters the session.
- WebSocket live-query paths check the subscribed AST instead of assuming the HTTP route was enough.
A Realistic Before and After
The old operational answer for vertical permissions was usually a mix of route discipline, carefully chosen SELECT lists, RLS for row ownership, and gateway policy behavior. That works until a second endpoint, transaction path, binary AST endpoint, nested expansion, or RETURNING clause forgets the same rule.
// Operator can read public order fields.
Qail::get("orders")
.columns(["id", "status", "total"])
.eq("tenant_id", tenant_id);
// Same subject should not read this.
Qail::get("orders")
.columns(["id", "private_note"]);
With native access policy enabled, the second query fails because private_note is outside the read column rule. The same policy is applied if private_note appears in a filter, window partition, payload expression, RETURNING projection, transaction query, or nested gateway route.
Prepared Statements Now Track Backend Reality
The PostgreSQL driver changes in this release are about not pretending the client cache knows more than the backend. Hot statements are promoted only after parse success. Stale hot entries are evicted on preprepare failure. Cached statements survive execute errors when the statement identity is still valid. Stale AST handles are reparsed, stale single-statement state is cleared, prepared identity is aligned, and NOTIFY flushes drain before returning.
The rule is simple: if the backend statement lifecycle did not reach a valid state, the QAIL cache should not remember it as valid.
Migration Verification Got Stricter
- Composite foreign-key options survive parse, diff, and apply paths.
- Strict migrations support composite foreign-key options.
- State diff rejects composite foreign-key drift.
- Post-apply checks verify table constraints against the live database.
MERGE, Source Queries, and Existing-Row Reads
The vertical policy work made one subtle class of bugs more visible: write statements can read too. MERGE sources, INSERT ... ON CONFLICT DO UPDATE expressions, update payload expressions, and source queries may depend on existing row data. v1.3.0 tightens those paths so value expressions that read existing columns require read access to those columns.
- MERGE table sources require read access to the source table.
- MERGE query sources are checked recursively.
- Transaction subqueries are fully validated.
- ON CONFLICT and MERGE value expressions require read access when they reference target columns.
- Invalid policy numeric values fail closed instead of being treated as harmless data.
Gateway Numeric and Replay Safety
The gateway hardening in this release focuses on precision and replay correctness. Oversized REST integers are preserved, precise numeric responses stay precise, Qdrant JSON integer drift is rejected, branch replay integers compare exactly, and tenant guard exemptions on REST writes stay explicit instead of accidental.
Workflow, Encoder, Qdrant, and SDK Fixes
- Workflow transition guards are counted per run.
- Charge amounts are validated before execution.
- Branch resume cursors reject drift.
- Null bind batch params and zero-parameter binds are preserved.
- Qdrant vectors are encoded little-endian.
- TypeScript, Kotlin, and Swift SDKs encode table and ID path segments before constructing REST routes.
Compatibility and Upgrade Notes
- The native access policy is opt-in through [access], so existing deployments do not suddenly deny traffic just by updating.
- When enabled, access.path is required and may point to a TOML or JSON policy file.
- RLS should still be used for tenant row isolation; native access policy is the vertical layer next to it.
- Services that rely on SELECT * should move to explicit projections before enabling restrictive read column rules.
- Mutation paths that use positional payloads or INSERT ... SELECT need explicit target columns when column policy is restrictive.
Validation
This release was checked with the Rust workspace test suite, Clippy, TypeScript/Kotlin/Swift SDK tests, and live PostgreSQL lab coverage for strict migrations, MERGE, access-checked execution, seeded RLS, and gateway native access policy behavior. The gateway policy live test exercises allowed projections, denied private columns, denied filters, denied write payloads, transaction denial, and administrator bypass.
Bottom Line
QAIL started as an AST-native way to express database intent without handing strings around. v1.3.0 makes that intent more useful for production authorization: the AST can now be checked against a native vertical policy before execution, while PostgreSQL RLS continues to own row isolation. That combination is the release story.
← Back to Blog