Skip to content

Dynamic read

Some products decide what to query at runtime. A BI tool assembles a customer's data model per project and stores widget SQL in a catalog; a semantic layer compiles a measure into a statement; a report builder turns a saved definition into a query. The statement is data by the time it reaches your handler, so no amount of wiring can register it in advance — and the only path that ever accepted it was the raw client, which is permitted precisely because it is ungoverned: you own validation, tenancy, and portability.

That posture is fine for a migration script run twice a year. It is the wrong one for the statement your product executes on every dashboard render. The dynamic read plane is that hot path with the governance put back: the framework, not the caller, owns read-only enforcement, tenancy confinement, resource limits, and the error taxonomy for statements it cannot inspect.

Where it sits

Four ways a handler reaches the database, sorted by when the statement is known:

Approach SQL known at Output shape known at Governance
Raw client runtime runtime none — by documented policy
Dynamic read runtime runtime read-only enforced by the engine; tenancy = container confinement; limits, taxonomy, capture
Procedures wiring wiring full; command-only
Analytics wiring wiring (select_run moves it to runtime) full

The missing cell was runtime statement text with framework governance, and it is deliberately a separate plane rather than a method on the analytics port. Analytics promises that handlers never pass SQL strings; grafting a dynamic call onto it would rot that promise for every analytics route and hide the dangerous capability inside a familiar one. Kept apart, a reviewer can grep the wiring and find every route that has it.

The shape in code

A spec is one governed surface, not one statement. Every statement executed through it shares these caps and the route's wiring:

from forze.application.contracts.dynamic_read import DynamicReadSpec

WIDGETS = DynamicReadSpec(
    name="widgets",
    row_cap=10_000,            # exceeding it raises; there is no "unlimited"
    max_statement_bytes=65_536,
)

There are no input or output type parameters, because both shapes are runtime data — that is the plane's definition. A handler resolves the port and passes text plus bound parameters:

port = ctx.dynamic_read.query(WIDGETS)

rows = await port.run(widget.sql, {"since": since})        # list of mapping rows
typed = await port.select(RevenueRow, widget.sql, {"since": since})

run returns mapping rows in the statement's column order — the shape a widget renders from, and the only honest one when the columns are chosen at runtime. select is the analytics select_run twin: the output type is a call-site argument, because the caller that compiled the statement is the one that knows what it selects. The port is read-plane, so a QUERY operation can use it; that is the whole point, and it deliberately inverts the procedures plane's command-only stance.

Parameters are bound by the engine, never formatted into the text. Two consequences worth knowing up front: a literal % in the statement must be doubled (LIKE 'foo%%'), and there is no pagination — a read that needs to page past row_cap is a mis-authored statement, and offset paging over runtime SQL invites exactly the fan-out costs the caps exist to surface.

Who wrote the statement

This is the design input everything else follows from, and the wiring author has to answer it because the framework cannot: it never sees the statement until it is time to run it. provenance has no default, so a route cannot be wired without naming its threat tier.

Tier Statement author Example Required confinement
A — trusted your own release artifacts, selected at runtime a shared visualization catalog; a compiler's output from reviewed templates engine read-only + namespace routing + limits
B — untrusted a program whose output nobody reviews per statement and that is not crafted to escape generated SQL from your templates, user-configurable report definitions tier A plus SET LOCAL ROLE to a schema-confined role, or a routed (dedicated) client
C — adversarial an author who may deliberately construct escape gadgets an end-user SQL console, a hostile tenant dedicated tier only — separate credentials per tenant

Tier C is a documentation stance, not a config value, and the reason is structural rather than a missing feature. On a shared connection the statement and the adapter wield the same identity: any privilege the adapter can invoke mid-session, a hostile statement can invoke too. Postgres 16's GRANT … WITH SET FALSE cannot fix it either — it disarms the adapter's own role switch symmetrically. The only scoping key a statement cannot forge from inside the session is the connection's login identity, which makes "adversarial" an operator's choice of topology rather than a flag.

What role confinement is, exactly

A NOLOGIN role with USAGE on one schema blocks cross-schema reads for any statement that simply references the wrong relation — the entire mistake class a non-adversarial generator produces. Against a deliberately crafted statement it is porous: direct FROM references are permission-checked at executor startup, but dynamic-SQL builtins like query_to_xml check their inner query at execution time, so one statement can call set_config('role', …) and then read across schemas. Treat the role as mistake-proofing plus defence in depth, and reach for the dedicated tier when the author might be hostile.

What survives every such gadget is SET TRANSACTION READ ONLY, which is sticky for the transaction's lifetime: writes stay impossible throughout.

Provisioning the role

PostgresSchemaTenantProvisioner creates it alongside the tenant's schema, so the two never drift apart:

PostgresSchemaTenantProvisioner(
    client=pg_client,
    schema=lambda tid: f"project_{tid.hex}",
    role=lambda tid: f"project_{tid.hex}_reader",   # NOLOGIN, USAGE + SELECT on that schema
)

Onboarding issues GRANT USAGE, GRANT SELECT ON ALL TABLES, and ALTER DEFAULT PRIVILEGES … GRANT SELECT ON TABLES — the last one is what keeps the confinement true when a pipeline creates tomorrow's table. Two deployment facts the grants cannot cover on their own:

  • The connection user must be able to SET ROLE into it, or every read is refused with dynamic_read_role_unavailable (a configuration error, because the statement is not at fault and was never sent). Creating the role is not enough: since Postgres 16 the creator is granted ADMIN OPTION but SET FALSE, so a non-superuser needs one of

    GRANT project_x_reader TO app_user WITH SET TRUE;   -- explicitly, after creation
    SET createrole_self_grant = 'set, inherit';         -- or, before creating roles
    

    A superuser connection needs neither. - ALTER DEFAULT PRIVILEGES applies to relations created by the role that ran the provisioning. If your pipeline writes as a different user, it issues its own default privileges.

Set drop_on_deprovision=True to tear the role down with the schema.

Mapping it to Postgres

One config maps a route to its container, its confinement, and its clock:

from datetime import timedelta
from forze_postgres.execution.deps.configs import PostgresDynamicReadConfig

PostgresDynamicReadConfig(
    provenance="trusted",                            # mandatory — no default
    query_schema=lambda tid: f"project_{tid.hex}",   # SET LOCAL search_path
    role=None,                                       # SET LOCAL ROLE (tier B)
    statement_timeout=timedelta(seconds=5),          # always on
    tenant_aware=True,
)

Wire it on PostgresDepsModule(dynamic_reads={"widgets": config}) and every statement on the route runs as:

BEGIN READ ONLY                        -- sticky; survives role games
SET LOCAL statement_timeout = …        -- always on
SET LOCAL search_path TO …             -- when query_schema is set
SET LOCAL ROLE …                       -- when role is set
<statement>  via the extended protocol -- one command, server-enforced

Every one of those lines is a refusal Postgres makes. No SQL is parsed, matched, or rewritten anywhere in the plane: INSERT is refused because the transaction is read-only, 'SELECT 1; DROP …' is refused because the extended query protocol carries one command, and a cross-schema read is refused because the role lacks the grant. A parser the framework maintains is a parser a statement outgrows, so there is none.

The statement runs on its own connection and its own root transaction — a read-only mode applied inside a caller's transaction would silently not apply, and that is this plane's one load-bearing guarantee. It also means a dynamic read does not see the caller's uncommitted writes, which is the right trade for a read plane.

Tenancy

Tenancy here is the container, not a predicate. A statement the framework cannot read cannot be trusted to carry one, so the boundary is the schema, role, or database it runs inside.

Tier Mechanism Posture
namespace per-tenant schema via query_schema supported — the statement runs in the tenant's own schema
dedicated a routed client per tenant supported — strongest isolation
tagged a %(tenant)s predicate inside the statement refused at wiring
none nothing scopes the route refused at wiring

Every route on this plane needs a per-tenant container

Routes here declare origin="compiled", whose statement-origin floor is namespace. Anything weaker raises statement_origin_isolation_floor at startup, so a route needs a per-tenant query_schema or a routed client — including a route that says nothing about tenancy at all, which is the wiring that reads across every tenant in the database.

The registered-SQL planes keep tagged because their compensating controls exist — frozen text, a freeze-time placeholder guard, review — and none of them survive a statement written at runtime. Worse, the failure mode is the bad one: a missing predicate does not error, it succeeds, with another tenant's rows in a correctly-rendered widget. On namespace or dedicated the identical mistake either fails loudly or stays inside the tenant's container.

A static query_schema does not clear the floor. It is real confinement, but it points every tenant at the same place, so it draws no boundary between them.

A route declaring provenance="untrusted" without a role or a routed client fails separately, as dynamic_read_untrusted_unconfined. The two axes are independent and a route clears both: the role answers who authored this, the container answers what the text may reach.

A role shared across tenants confines to nothing

Resolve the role per tenant (tenant_id -> str) whenever query_schema is per-tenant. PostgresSchemaTenantProvisioner resolves the role per tenant and grants it USAGE + SELECT on that tenant's schema, so a static name collects read access to every schema it is onboarded with. Since search_path is routing rather than a boundary, a statement reaches the others by naming them — and the role meant to stop a cross-schema reference is what permits it. Wiring that combination raises dynamic_read_shared_role_across_tenants.

PostgresSchemaTenantProvisioner refuses the same pairing at construction (tenant_role_shared_across_schemas), since it is reachable without any route at all. A resolver is taken on trust there — no tenant ids exist yet and it may be async — so a constant one like lambda _: "reader" has the shape of per-tenant scoping without the substance. That is caught at the second onboarding instead (tenant_role_already_bound), where the server can say whether the role is already provisioned for a different schema. That check and the grant that follows it run under a transaction-scoped advisory lock keyed on the role, so two onboardings resolving the same role cannot both pass it. Teardown breaks there too: deprovision drops the role per tenant, and PostgreSQL refuses to drop one another schema's grants still name — IF EXISTS covers absence, not dependency — so the DROP SCHEMA behind it never runs and the tenant's data stays.

For the same reason the provisioner refuses to adopt an existing role that already reaches somewhere — LOGIN, SUPERUSER, BYPASSRLS, or a member of another role (tenant_role_not_confinable). Statements run as this role, so pointing role at an application user or an inheriting one would hand them that reach and add the tenant's schema on top. Adopting a plain NOLOGIN role with no memberships is fine — that is the idempotent onboarding path.

This is adoption-time mistake-proofing, not a standing boundary: every one of those can be granted the day after onboarding. It catches role pointed at something never meant for this, which is the mistake that actually happens. A statement that must be contained against a hostile author still needs the dedicated tier.

The tenant id is still bound as %(tenant)s when the statement references it, so a trusted statement can carry a predicate in addition to its container. That is convenience, not the boundary — referencing a placeholder proves reference, never scope. Set the schema up with the tenant provisioner, and see the tenancy matrix for where the plane sits against the rest.

Limits and errors

Caps ship on with real values, and exceeding the row cap raises rather than truncating — a truncated page reads as a complete one, and a dashboard rendered from it is confidently wrong. Per-call options clamp down and never up:

rows = await port.run(sql, params, options={"row_cap": 500, "timeout": timedelta(seconds=2)})

Every code below is caller-caused, because on this plane the statement is the caller's input — an undefined relation here is a malformed request, not a broken database, and none of it egresses as internal.

Condition Kind Code
A write or DDL inside the read-only transaction precondition dynamic_read_write_refused
Syntax error, unknown relation/column/function validation dynamic_read_statement_invalid
Multi-command string rejected by the protocol validation dynamic_read_multi_statement
Refused by the route's confinement (role grants) precondition dynamic_read_permission_denied
The route's role is missing, or the user is not a member configuration dynamic_read_role_unavailable
The route's statement timeout fired timeout dynamic_read_timeout
Result exceeded the effective row cap precondition dynamic_read_row_cap_exceeded
Statement above max_statement_bytes validation dynamic_read_statement_too_large
Rows do not fit select's return type validation dynamic_read_row_type_mismatch
No tenant bound on a tenant-aware route authentication tenant_required

The caps bound one statement. To bound the fleet of them — a dashboard that fans out fifty widgets, a catalog change that makes every one of them slow — bind a resilience policy to the dep key, which covers every route on the plane:

from forze.application.contracts.dynamic_read import DynamicReadDepKey
from forze.application.contracts.resilience import PortPolicy

ResilienceDepsModule(
    spec=my_policies,                                        # defines "catalog_reads"
    port_policies=(PortPolicy(key=DynamicReadDepKey, policy="catalog_reads"),),
)

A bulkhead is the one worth setting deliberately: each call holds its own pooled connection for the statement's lifetime, so an unbounded fan-out of slow widgets is how this plane starves the pool everything else shares.

Statements in traces

Simulation value capture masks the statement text by default:

WIDGETS = DynamicReadSpec(
    name="widgets",
    capture_statements=True,   # opt in to verbatim statements on captured traces
)

A compiled statement embeds the literals it was compiled with — filter values, identifiers, sometimes user input — so a captured trace shows "<redacted>" in its place unless you opt in. Masked rather than dropped, so a trace consumer can tell "a statement ran and its text was withheld" from "nothing was recorded". This affects runtime tracing and simulation only; production traces are id-only regardless.

The mock

MockDepsModule answers dynamic-read routes from a handler you register, which receives the fully-governed request — statement, bound params, effective caps and timeout, resolved tenant — and returns the rows the statement would have produced:

from forze_mock import MockDepsModule, MockDynamicReadRegistry

registry = MockDynamicReadRegistry().on(
    "widgets",
    lambda request, state: [{"revenue": 10}][: request.row_probe],
)
module = MockDepsModule(dynamic_reads=registry)

An unprogrammed route fails closed (code="mock.dynamic_read.unprogrammed"). Unusually for this package, the mock is not a capability superset here: it cannot see the statement as SQL, so a write, a second command, and a cross-schema read all come back as whatever the handler returns. That is deliberate. The only way to reproduce those refusals in memory would be to pattern-match SQL, and a mock that did would certify statements a real gadget walks straight past. A scenario that needs a refusal path raises the taxonomy from its handler instead, declaring the outcome rather than deriving it.

What this plane refuses to know

  • No field encryption. A dynamic statement's output shape is unknowable, so a sealed column comes back as ciphertext — and a statement could even ORDER BY one, where ciphertext order is a silently wrong answer. A wiring-time check is impossible (there is no statement to inspect), so the stance is explicit instead: point this plane at analytics-shaped relations that carry no sealed columns. See encryption.
  • No writes. Runtime DDL and bulk loads stay on the raw client under the documented escape-hatch policy. A governed dynamic-write surface is foreclosed, not deferred.
  • No HTTP route generator, and no MCP tool that takes raw statement text. Statements come from app code — catalog rows, compiler output — never from a request body. Generating an endpoint that forwards one would be an injection endpoint with a framework logo on it.

If your statements are known at wiring time, you want analytics for reads or procedures for commands — both give you typed shapes and an enforceable tenancy predicate, and neither asks you to reason about threat tiers.