Request lifecycle¶
Every query passes the same stages, over REST and over MCP. Each stage can stop the request with an error code.
sequenceDiagram
autonumber
participant T as Transport (REST / MCP)
participant S as queryservice
participant P as parser
participant E as policy engine
participant B as approval broker
participant R as rewriter
participant D as DuckDB executor
participant A as audit dispatcher
T->>S: query (authenticated subject)
S->>S: slot, rate limit, budget, input caps
S->>P: parse (pg_query)
S->>E: evaluate (or rewrite-cache hit)
alt deny / reject
S->>A: decision record
S-->>T: ACL_DENIED / ACL_REJECTED
else approval required
S->>B: consume grant, or park + sync wait
end
S->>R: rewrite (filters, masks, LIMIT, sample)
S->>D: execute (read-only, streaming)
S->>S: attach post-query masks
S->>A: access record (fail-closed gate)
S-->>T: stream rows
S->>A: completion record + budget usage (at close) Diagram numbers count messages, not stages.
Stages¶
- Transport limits and login. The REST API and MCP over HTTP check their rate limits. Sluice maps the API key or token to a subject.
- Admission. Sluice checks the concurrency limit, the subject's rate limit and budget and the SQL size. It caps the requested timeout and row count.
- Parse. Sluice parses the statement with the PostgreSQL grammar and finds its tables.
- Statement checks. Sluice checks function calls and table names.
- Policy. The engine allows, denies or rejects the query.
- Approval. An approval policy can hold the query until a person accepts it.
- Rewrite. Sluice refuses writes and DDL. It adds row filters, column masks and limits to the SQL.
- Execute. DuckDB runs the rewritten SQL.
- Post-query masks. Some mask types change values in the result rows.
- Audit, then rows. Sluice queues the audit record, then sends the rows.
- Close. Sluice writes a second audit record with the row count and updates the budget.
Example¶
Goal: see which stage stops a request. One policy allows the group analytics to read shop.main.customers.
apiVersion: sluice.bino.bi/v1alpha1
kind: SqlAccessPolicy
metadata:
name: allow-customers
spec:
effect: allow
match:
any:
- subjects:
groups: ["analytics"]
resources:
catalogs: ["shop"]
schemas: ["main"]
tables: ["customers"]
No policy allows shop.main.payroll. The policy stage denies the query:
curl -s http://localhost:8080/v1/query \
-H "X-Api-Key: sl_demo_ana.supersecret" \
-H "Content-Type: application/json" \
-d '{"sql": "SELECT name, salary FROM shop.main.payroll"}'
{
"code": "ACL_DENIED",
"message": "no SqlAccessPolicy matched (default-deny)",
"query_id": "<query-id>"
}
A write on the allowed table passes the policy stage. The rewrite stage rejects it:
curl -s http://localhost:8080/v1/query \
-H "X-Api-Key: sl_demo_ana.supersecret" \
-H "Content-Type: application/json" \
-d '{"sql": "DELETE FROM shop.main.customers WHERE id = 1"}'
{
"code": "ACL_REJECTED",
"message": "write operations are not permitted",
"query_id": "<query-id>"
}
A typing error gives a parse error:
curl -s http://localhost:8080/v1/query \
-H "X-Api-Key: sl_demo_ana.supersecret" \
-H "Content-Type: application/json" \
-d '{"sql": "SELEC id, name FROM shop.main.customers"}'
What happened: each request stopped at a different stage. Several stages share the code ACL_REJECTED.
Statements Sluice runs¶
| Statement | Result |
|---|---|
SELECT | Runs |
EXPLAIN of a SELECT | Runs. ERR_UNSUPPORTED_SYNTAX if a row filter or column mask applies |
INSERT, UPDATE, DELETE, also inside a WITH | ACL_REJECTED |
DDL, COPY, SELECT ... INTO, SET, RESET, SHOW | ERR_UNSUPPORTED_SYNTAX |
ATTACH | ERR_SYNTAX |
Policies see no table in SET and SHOW. An allow policy that names a table does not match them, so the answer is ACL_DENIED. The code in the table shows under an allow policy without resources.
Where a request stops¶
| Stage | What stops the request | Error code |
|---|---|---|
| Transport limits | limits.globalRps or limits.perIpRps reached | ERR_RATE_LIMITED |
| Login | No valid API key or token | ERR_UNAUTHORIZED |
| Admission | limits.maxConcurrent or subject rate limit reached | ERR_RATE_LIMITED |
| Daily budget used up | ERR_BUDGET_EXCEEDED | |
SQL over limits.maxSqlBytes | ERR_PAYLOAD_TOO_LARGE | |
| Parse | Parser cannot read the statement | ERR_SYNTAX |
| Two statements in one request | ERR_MULTIPLE_STATEMENTS | |
| Identifier over 63 bytes, nesting too deep | ERR_UNSUPPORTED_SYNTAX | |
| Statement checks | Function not on the allow list, or written as schema.f(x) or x.f() | ACL_REJECTED |
Function in FROM other than generate_series, range, unnest | ACL_REJECTED | |
TABLESAMPLE | ACL_REJECTED | |
Table name with /, \, :, *, ? or [ | ACL_REJECTED | |
Table written as catalog.table | ACL_REJECTED | |
Second catalog with limits.disableCrossCatalog | ACL_REJECTED | |
Parameter such as $1 | ERR_UNSUPPORTED_SYNTAX | |
| Policy | No allow, or a deny policy | ACL_DENIED or the policy's code |
| Reject rule | ACL_REJECTED or the rule's code | |
| Engine error | ERR_POLICY_UNAVAILABLE, ERR_TIMEOUT | |
| Approval | Waiting for a person | ERR_APPROVAL_PENDING |
| Rejected or expired | ERR_APPROVAL_REJECTED, ERR_APPROVAL_EXPIRED | |
| Queue full | ERR_RATE_LIMITED | |
ApprovalPolicy added by a reload, approval.publicBaseUrl not set | ERR_INTERNAL | |
| Rewrite | Write statement | ACL_REJECTED |
DDL, COPY, SET, RESET, SHOW | ERR_UNSUPPORTED_SYNTAX | |
| Form refused on a filtered or masked table | ERR_UNSUPPORTED_SYNTAX, ERR_SYNTAX, ERR_MASK_UNSUPPORTED_CONTEXT | |
| Masked table missing in the schema cache | ERR_REWRITE_FAILED | |
| Schema cannot be loaded | ERR_DATASOURCE_UNAVAILABLE | |
| Template value the subject lacks | ACL_DENIED | |
| Statement too large for the DuckDB parse check | ERR_PAYLOAD_TOO_LARGE | |
| Rewrite or execute | Timeout, client gone | ERR_TIMEOUT, ERR_CANCELED |
| Execute | DuckDB refuses the statement | ERR_QUERY_FAILED |
| DuckDB cannot parse a statement that Sluice did not rewrite | ERR_SYNTAX | |
| Data source not reachable | ERR_DATASOURCE_UNAVAILABLE | |
| Other error | ERR_INTERNAL | |
| Post-query masks | Result columns do not match, mask cannot be built | ERR_MASK_UNSUPPORTED_CONTEXT, ERR_INTERNAL |
| Audit | Audit queue full | ERR_AUDIT_UNAVAILABLE |
Limits¶
- Parse and rewrite errors show only if the policies allow the statement. A deny or reject comes first.
ERR_SYNTAXalso covers a table alias that is a DuckDB keyword and an unquoted identifier with a non-letter Unicode character.- A request stopped by the transport limits or the login leaves no audit record.
- With
cache.rewrite.enabled, a cached rewrite keeps the old column list of a masked table untilcache.rewrite.ttlends or a reload.