Skip to main content
Preview capabilities are server-provided features exposed to extensions before their APIs are finalized. An extension that declares a preview capability requires vsql_allow_preview_extensions = ON to install (see Enabling the Preview Tier) — extensions that don’t use preview capabilities install normally regardless of this setting.
Preview capability APIs are not stable. An extension built against a preview capability may fail to load after a server update. When a capability stabilizes, its header moves to a versioned stable C++ SDK path.

Enabling the Preview Tier

Set vsql_allow_preview_extensions = ON with SET PERSIST before installing any extension that uses a preview capability:
SET GLOBAL is rejected for this variable — the server requires SET PERSIST so the setting survives restart. Extensions with preview capabilities are loaded at startup, so the variable must be ON when the server starts. If you’re launching mysqld directly (for example, from an install script that starts the server for the first time), pass the flag on the command line instead — mysqld-auto.cnf won’t exist yet to carry the persisted value:
To disable:
This fails if any extension using a preview capability is currently installed. Uninstall those extensions first, then turn the setting off.

Capability Index

Registration Pattern

To use a preview capability, declare a capability object by value at file scope and pass it by reference to .with() inside make_extension(). The server populates the object’s abi pointer during registration:
.with(capability) tells the server which capabilities the extension requires. If vsql_allow_preview_extensions is OFF when the extension is installed, the server rejects the install with an error naming the extension: ERROR 3219 (HY000): Failed to load VEF extension 'name': extension requires preview capabilities but vsql_allow_preview_extensions is OFF. The message does not say which capability was responsible.
Every capability object declared in an extension must be passed to .with() exactly once. At load time, the server cross-checks every declared capability instance against what .with() received and fails INSTALL EXTENSION if the rule is violated:
  • Declared but never passed to .with(): capability '<Type>' was declared but never passed to .with(); every CapabilityBase-derived static must be registered via .with(cap) in the extension builder
  • Same instance passed to .with() more than once: capability '<Type>' passed to .with() more than once
  • Object passed to .with() is not a capability: .with() received an object that does not inherit vsql::detail::CapabilityBase; not a registered capability
The full error surfaces as: Failed to load VEF extension '<name>': vef_register returned an error: <message above>.

Keyring Access

The keyring capability (vsql::preview::keyring) lets extensions read and write secrets stored in the MySQL keyring component. Extensions use it for things like API keys, encryption keys, or other secrets that shouldn’t live in SQL tables. The capability name VEF_PREVIEW_KEYRING_NAME is "vsql::preview::keyring". A keyring component must be installed on the MySQL server for reads and writes to succeed. Without one, operations return KeyringCapability::Status::UNAVAILABLE.

Status Values

KeyringCapability::Status is a scoped enum returned by read() (inside ReadResult) and write():

Declaring the Capability

Include the header, declare a capability object at file scope, and pass it to .with():
The g_keyring object is populated by the server at load time. The read() and write() methods return Status::UNAVAILABLE at runtime when no keyring component is installed — check that status on each call rather than gating on a separate availability probe.

Reading and Writing

data_id is the key identifier. auth_id is the owning user — pass an empty string (or omit it on read, which defaults to {}) to read or write internal keys not associated with a specific user. read returns a ReadResult by value. Bind it with structured bindings:
On any status other than Status::OK, value is empty. write returns Status directly and stores data under data_id / auth_id.

Complete Example

This is a simplified version of the vsql_keyring_reader test extension, in the server’s villagesql/test-extensions/ tree. It registers 2 VDFs: keyring_read and keyring_store.

MySQL Services

The mysql_services capability (vsql::preview::mysql_services) lets an extension consume MySQL registry services — the same services a MySQL component consumes, provided either by an installed component or by the server core. The extension declares every service it needs in one place; the server acquires each one when the extension loads and releases it when the extension unloads. The capability name VEF_PREVIEW_MYSQL_SERVICES_NAME is "vsql::preview::mysql_services". Reach for it when a server facility has no VEF capability of its own. Session attributes and the keyring’s own component services are both reachable this way. Only consuming is supported: registering the extension’s own implementation into the registry is a planned follow-up and is not part of this capability.

Declaring the Capability

Declare one MysqlServices object at file scope, name each service you consume with VSQL_REQUIRE_SERVICE, and pass the object to .with(). Include MySQL’s own header for each service — that header is where the service’s type and methods are declared:
VSQL_REQUIRE_SERVICE(services, name, var) declares var, the reference the server writes the acquired service into, and registers name on services. It declares var static for you. The MysqlServices object needs to be static too, and so does any reference you declare by hand: the server writes through them at load, and they must outlive the extension.

Pinning a Specific Implementation

VSQL_REQUIRE_SERVICE uses name twice — as the C++ SERVICE_TYPE(name) and as the string the server looks up in the registry. Under that bare name the server acquires the service’s default implementation. To name one implementation instead, use its qualified registry name — service.component, the form MySQL’s PROVIDES_SERVICE(component, service) generates. This asks for the keyring reader from the component_keyring_file component rather than for the default:
A qualified name is acquired the same way as a bare one, so the usual rule applies: if that exact implementation is not registered, the extension fails to install rather than falling back to another one.

Building Against MySQL’s Headers

Service definitions belong to MySQL’s component framework rather than to VEF, and the server does not install them. mysql/components/services/*.h is therefore absent from the extension SDK and from anything built by make install, which includes the release tarball and the Docker image. An extension that consumes services builds against a VillageSQL server source tree: The in-tree test extensions get both from the MYSQL_HEADERS flag on vsql_add_test_extension(), which passes them as MYSQL_INCLUDE_DIR and MYSQL_GENERATED_INCLUDE_DIR. An out-of-tree build sets its own include paths. Two build failures land somewhere other than the line that caused them. Omitting a service’s MySQL header leaves VSQL_REQUIRE_SERVICE with a name that resolves to nothing, so the error appears on the macro rather than on the missing include (clang 17):
Some service definitions use size_t without including <cstddef>, so putting one of those headers ahead of every villagesql header fails inside MySQL’s own header:
Include <cstddef> first, as the examples on this page do.

Calling a Service

A service reference exposes valid() of its own, and -> forwards to the service. Use . for the reference and -> for the service:
Check valid() before every -> call. -> returns the acquired pointer, which is null when the service was not acquired. A service that fails to acquire fails the install, so inside a function that is running, a required service is valid. The check still matters, because a ServiceRef declared by hand and never passed to require() is never written to: it compiles, the extension installs, and valid() is false for the life of the extension. What a service is — its methods, their parameters, and what they return — is documented by MySQL rather than here. For a service named NAME, read include/mysql/components/services/NAME.h in the server tree: its BEGIN_SERVICE_DEFINITION(NAME) block declares every method with its own documentation. Call them exactly as that header specifies, including MySQL’s convention that a bool return of false means success and true means failure.

Acquisition Failure

Every declared service is acquired when the extension loads, before any of its functions can be called, so a service that is not registered fails the load rather than surfacing later. INSTALL EXTENSION fails and names the service. vsql_mysql_services_missing_test below is an in-tree test extension that requires a service the registry does not have. It is not something you can install — it is how the failure was captured, and what your own extension produces if it requires a service this server does not provide:
Two other install failures reach the same capability from outside it: leaving the MysqlServices object out of .with(), and installing on a server with vsql_allow_preview_extensions OFF. Both are covered under Registration Pattern.

Complete Example

A simplified version of vsql_mysql_services_session_test, in the server’s villagesql/test-extensions/ tree. It reads the SQL command running on the calling session by composing two services: one hands back the current THD, the other reads a named attribute off it. Both are server-core services registered on every server, so nothing has to be installed first:
Install it and call the function:

Status Variables

The status_var capability (vsql::status_var) lets an extension expose long long and double counters as MySQL status variables. The extension owns the storage and writes to it; the server reads through the pointers each time the status variable is queried. Build the capability with vsql::preview_status_var::make_capability(), passing a braced list of descriptors from make_int(name, value_ptr) or make_double(name, value_ptr). The template deduces the count from the braced list, so no explicit size is required.

Complete Example

make_int requires a long long *; make_double requires a double *. Those are the only two types supported.

Accessing from SQL

After INSTALL EXTENSION my_ext, the variable is visible with the extension name as a prefix:
Concurrent increments from multiple query threads using a non-atomic ++ may occasionally be lost; this is acceptable for approximate call counters exposed via SHOW STATUS.

System Variables

The sys_var capability (vsql::sys_var) lets an extension register MySQL system variables backed by extension-owned storage. Four types are supported: BOOL (bool *), INT (long long *), DOUBLE (double *), and STR (char **). INT and DOUBLE descriptors also carry min_val and max_val bounds; all descriptors carry a default value and a comment. Build the capability with vsql::preview_sys_var::make_capability() and the matching factory functions make_bool, make_int, make_double, and make_str. The capability object also exposes get() and set() for programmatic access from extension code. Both return false on success. To react to value changes, chain .on_change<&fn>() on a descriptor. The callback receives a sv::SysVarChange with var_name() and typed accessors (as_int(), as_real(), as_str()). The server calls that callback while it holds its global system-variable lock. Reading or writing another of this extension’s variables through its storage pointer is safe there, and other sessions see the new value immediately, because the server reads those variables under the same lock.
Calling the capability’s get() or set(), running SQL, or waiting on a thread that does either deadlocks on that lock. Keep the callback short and non-blocking, hand work that needs SQL to a thread worker, or release LOCK_global_system_variables around the blocking part and retake it before returning, as event_scheduler_update() does in sql/sys_vars.cc.
The capability object must have static storage duration. MySQL writes directly to the storage pointers when the user sets a variable.

Complete Example

Accessing from SQL

After INSTALL EXTENSION my_ext, variables are accessible using the extension name as a component prefix:

Reading and Writing from Extension Code

For INT and BOOL variables, read the global storage pointer directly — MySQL updates those atomically. To update a variable through MySQL (so locking, range validation, and persistence are handled by the server), call SYS_VARS.set(extension_name, var_name, scope, value). Both set and get return false on success. Neither can be called from an on_change callback: both deadlock on the system-variable lock.
The scope argument controls persistence:

Thread Worker

The thread worker capability (vsql::preview::thread_worker) lets an extension run a background thread driven by the server. The thread is started and stopped via a control system variable that the server registers at extension load; the server invokes the extension’s work function on a periodic timer, on file-descriptor readiness, or in response to enable/disable events. The capability name VEF_PREVIEW_THREAD_WORKER_NAME is "vsql::preview::thread_worker".

Declaring the Capability

Include the header, declare a ThreadWorkerCapability instantiated on your work function at file scope, and pass it to .with():
The work function is supplied as a non-type template argument (ThreadWorkerCapability<&my_work>), so it must be a function with the signature shown below. The first constructor argument is the thread-name suffix; the optional second argument overrides the control sys var name.

Work Function Signature

reason indicates why the server called the function. thread is the server-owned handle for this worker (NULL on the initial VEF_WAKEUP_ENABLE call — see below). arg is the opaque pointer registered on the descriptor; it is passed through unchanged.

Wakeup Lifecycle

The server calls the work function with one of four reasons: The thread parameter is NULL when the reason is VEF_WAKEUP_ENABLE, because the thread handle does not exist yet at that point. For the other three reasons, thread is non-null.

Wakeup Return Value

The work function returns a vef_next_wakeup_t to update the next wakeup configuration. A zero value in either field means “keep the current setting” — return a value-initialized struct (return {};) to leave both unchanged. To set a new poll file descriptor, return its value (must be greater than zero). To clear an existing poll file descriptor, return -1 in poll_fd. The return value is ignored when the reason is VEF_WAKEUP_DISABLE.

Thread Name and Control Variable

Two fields on the descriptor control naming:
  • suffix — the thread-name suffix. The server prepends the extension name, producing thread names like my_ext/monitor.
  • var_name — optional. When non-null, the server registers this exact name as the control system variable. When null, the server uses the default pattern {suffix}_enabled.
The control variable is a server-registered system variable, so it takes the extension name as a component prefix. For extension my_ext with suffix monitor, the variable is my_ext.monitor_enabled. Setting it ON starts the worker: the server calls the work function with VEF_WAKEUP_ENABLE, then creates the thread, so the statement does not return until that first call has finished. Setting it ON again while the worker is already running does nothing. Setting it OFF returns after the thread has exited. The server releases its global system-variable lock around both, so the work function may read system variables and run SQL.

Complete Example

A minimal extension with a single periodic worker that increments a heartbeat counter on each timer tick.
With this extension installed (and vsql_allow_preview_extensions = ON), the server registers a heartbeat_enabled system variable under the extension’s name. For an extension named my_ext, enable the worker with:

SQL Query

The sql_query capability (vsql::preview::sql_query) lets an extension execute SQL statements from a background thread. Queries run inside the server through the capability vtable — extensions do not link against any MySQL client library. The capability name VEF_PREVIEW_SQL_QUERY_NAME is "vsql::preview::sql_query".
A SQL session must be opened from a thread-worker callback using that callback’s vef_thread_handle_t *. open() is not valid from VDFs or from arbitrary extension-created threads — it requires the worker session context.

Declaring the Capability

Include the header, declare a SqlQueryCapability at file scope, and pass it to .with(). It is typically registered alongside a ThreadWorkerCapability, since sessions are opened from the worker callback:
g_sql.open(handle) returns a Session. Check it with operator bool before use; an invalid Session indicates the capability vtable was not bound or the server could not allocate a session. The Session is move-only and closes itself on destruction.

Executing Queries

A Session produces a SqlQuery via session.sql(sv). The query can be run in two modes:
  • execute() — runs the statement and buffers the full result set in a Result. Iterate rows by calling next() at the caller’s pace.
  • for_each(fn) — runs the statement and invokes fn once per row as rows are produced, without buffering. The returned Result carries diagnostics only (no rows).
Both return a Result. A non-null Result does not mean the statement succeeded — call has_error() to find out. Buffered (execute):
column_str() returns a string_view that is valid only until the next next() call or until Result is destroyed. Copy it if a longer lifetime is needed. A string_view with data() == nullptr indicates SQL NULL. Streaming (for_each):
The Row passed to the callback is valid only for the duration of the call — do not store references to it across rows. The Result returned by for_each holds no buffered rows; next() on it will not yield data. Use it only for has_error(), error(), warning_count(), and warning(i).

Diagnostics

Both execute() and for_each() surface diagnostics through the returned Result. A diagnostic is one Diag:
Result exposes:
error() returns a default-constructed Diag (errno_ == 0) when the statement succeeded. warning(i) returns a default-constructed Diag when i >= warning_count(). The sqlstate and message views point into storage owned by the Result and become invalid when the Result is destroyed — copy them if they need to outlive it.

Complete Example

A worker that runs one buffered query and one streaming query on each tick, logging diagnostics from both:

Column Storage

Column storage lets an extension register a custom binary on-disk layout directly with InnoDB for one of its custom types, instead of routing the type’s bytes through the row’s VARBINARY payload. Use it when your type needs an on-disk shape VARBINARY cannot express — for example, a packed array of floats that must live in dedicated pages. This is a capability feature: it enables new storage layouts, not a tuning knob for existing ones.
Column storage is a preview ABI — under active development and may change between releases. It currently covers row-level persistence only; indexing over custom-stored columns is not yet available.

Declaring the Capabilities

Two preview capabilities work together:
  • vsql::preview::storage — opens access to InnoDB storage infrastructure (mini-transactions, segments, pages). Declare a StorageCapability at file scope.
  • vsql::preview::column_store — binds a per-type storage implementation to one of the extension’s custom types. Declare a ColumnStoreCapability at file scope using make_column_store<Ctx>(TYPE).…build().
Both must be passed to .with() on make_extension():
make_column_store<MyCtx>(MY_TYPE) ties the implementation to one custom type registered on the same extension. All seven slots are required at build() time because each maps to a distinct point in the column lifecycle that InnoDB will reach during normal operation.

The Seven Storage Functions

Every function takes storage::Column::StorageCtx<MyCtx>*, whose user() accessor returns the extension’s per-column state and whose arena() provides server-managed allocation for auxiliary objects. Every function returns false on success and true on error, writing a message into error_msg (capacity error_msg_len) so failures surface to the SQL client.
mark_delete and purge are distinct because InnoDB MVCC requires deleted rows to remain readable by older snapshots until purge runs.

Per-Column Context and the Arena

The C++ SDK default-constructs MyCtx before calling either create or loadctx->user() is already populated when your function is entered. MyCtx must be default-constructible; the C++ SDK calls T() with no arguments. Use ctx->user() directly to initialize state. Do not call ctx->arena().construct<MyCtx>() — that allocates a second, unused instance and ctx->user() does not point to it.
load follows the same pattern — ctx->user() is pre-populated and storage_ref carries the packed value stored by ctx->set_ref() in create:
Use ctx->arena() only to allocate auxiliary objects that are too large or dynamic to embed directly in MyCtx. The C++ SDK destroys the arena — and calls ~MyCtx() — automatically after drop returns, regardless of whether drop succeeds.

InnoDB Access Utilities

Include <villagesql/preview/storage_api.h> for the InnoDB primitives. All page reads and writes must occur inside a mini-transaction:
Committing the mini-transaction releases page latches and writes the redo log records that make changes durable. Segments are reserved at create time — see the create and load examples in Per-Column Context above for the complete setup pattern. During DML operations, get a segment reference from the root page to allocate new pages:
Pages are read with a shared latch and written with an exclusive one. Pass mtr_ref to write calls so InnoDB logs the change:
Page layout constants: Reading or writing inside the header or trailer regions corrupts the page — InnoDB uses those byte ranges for its own bookkeeping and checksum.

Statement Events

The statement event capability (vsql::preview::statement_event) runs an extension-provided handler after each query finishes executing. The server invokes the handler synchronously on the query’s own thread and passes execution metadata — the query text, timing, row counts, connection identity, and optimizer quality indicators. Use it for slow-query logging, auditing, or metrics collection. The capability name VEF_PREVIEW_STATEMENT_EVENT_NAME is "vsql::preview::statement_event".

Declaring the Capability

Declare a StatementEventCapability, instantiated on the firing phase and your handler function, at file scope and pass it to .with():
The first template argument is the firing phase, a vef_statement_event_phase_t value. VEF_STATEMENT_EVENT_POSTEXECUTE fires after a query finishes executing, on success or failure, and is the only phase implemented in this version. The other vef_statement_event_phase_t values are reserved; declaring a handler for one of them causes the server to reject INSTALL EXTENSION.

Handler Arguments

StatementEventArgs is a read-only view of the completed query; at the POSTEXECUTE phase every field is populated. Selected accessors: Because query() returns the server’s rewritten form when one exists, credential-bearing statements arrive with the secret obfuscated rather than in cleartext, matching how the general, slow, and binary logs already redact them: SET PASSWORD, CREATE/ALTER USER ... IDENTIFIED BY, CHANGE REPLICATION SOURCE ... SOURCE_PASSWORD, and CREATE SERVER ... OPTIONS(PASSWORD ...). Statements with no rewrite rule are delivered verbatim. String accessors such as query(), sqlstate(), and error_message() point into storage that is valid only for the duration of the handler call — copy the bytes if you need them after the handler returns. StatementEventResult::error_msg(fmt, ...) writes a printf-formatted message. At the POSTEXECUTE phase the message is advisory: the server logs it but does not propagate it to the client.

Complete Example

A condensed form of the vsql_slow_query_log test extension. It logs each query whose execution time exceeds a threshold, combining the statement event capability with system variables for runtime configuration:

Enabling from SQL

With the preview tier enabled (see Enabling the Preview Tier), install the extension and configure it through its system variables:
Each query slower than the threshold is appended to the configured log file:

Authentication Methods

The auth capability (vsql::preview::auth) lets an extension provide a server authentication method. An account opts in with CREATE USER ... IDENTIFIED WITH <method-name>; at connection time, when that name is not a loaded MySQL auth plugin, the server consults the VEF auth registry and invokes the extension’s handler over the handshake. Use it to authenticate accounts against a credential source the server does not know about — a bearer token, an external identity provider, or a custom challenge — without writing a MySQL authentication plugin. The capability name VEF_PREVIEW_AUTH_NAME is "vsql::preview::auth". The handler is a typed function that receives an AuthContext: it talks to the client by reading and writing handshake packets through that server-owned context, and it never sees MySQL’s internal auth structures.
The auth result is fail-closed. The server treats anything other than AuthResult::kOk as a denied connection — there is deliberately no “maybe” or fail-open result. A handler that returns AuthResult::kReject, returns AuthResult::kError, or never sets the effective account denies the login.

Declaring the Capability

Include the header, write a typed handler, build a descriptor with the fluent make_auth<> builder, and hand the descriptor to an AuthCapability token that you pass to .with(). Preview capability headers are not part of the <villagesql/vsql.h> umbrella, so include <villagesql/preview/auth.h> explicitly:
The builder has six pieces: AuthCapability g_auth{descriptor} is the self-registering token consumed by .with(). Declare it static so it outlives registration. client_plugin is optional. make_auth defaults the advertised plugin to "mysql_clear_password" — the lowest common denominator every MySQL client ships — so a method that never calls .client_plugin() still installs and a naive client still connects. Call .client_plugin(name) to request a different plugin; mysql_clear_password receives a bearer token verbatim in the password slot. A client that offers a plugin other than the one the method requests is switched to the requested plugin and resends its credential verbatim, which costs a round trip and needs a client willing to make that switch. .accepts_client_plugin(&callback) lets the method keep the offered plugin instead: the server passes each offered name to the callback, including the requested plugin, which is accepted whatever the callback returns. A method that sets no callback accepts no other offer, so every other offer switches to the requested plugin. Accepting is final — the server does not switch back to the requested plugin afterwards — so accept only a plugin whose framing the handler really parses. The server queries the callback during handshake negotiation, before the handler’s first read, so it must be a pure predicate: no packet I/O, no blocking, no side effects.

The Handler Contract

The handler matches the AuthHandler type — it takes an AuthContext & and returns an AuthResult:
It is invoked synchronously on the connecting thread during the handshake. The AuthContext wraps the server-owned per-attempt context; hold it only for the duration of the call and do not retain it. Call its methods instead of threading a context pointer through a function table. The methods a token-based handler uses: The handler returns one of three results: Both AuthResult::kReject and AuthResult::kError deny the connection. Only AuthResult::kOk succeeds. When the handler maps the connecting account to a different effective account — as the example below maps the connecting account to vsql_auth_test_user — that is proxying, and it requires a GRANT PROXY, exactly as on the MySQL plugin auth path.

Staging Active Roles

c.set_active_roles(roles, n_roles) stages the roles that should be active on the session, replacing the account’s default-role activation for this login. roles is an array of n_roles NUL-terminated names; the strings are copied, so the caller need not keep them. The server applies them after account resolution, using the same grant-checked activation as SET ROLE: only roles actually granted to the authenticated account activate, and names that are not granted are silently skipped — so a token can never grant or escalate privileges beyond what the DBA provisioned. Passing n_roles == 0 activates no roles (equivalent to SET ROLE NONE).

Complete Example

A minimal authenticator, condensed from the vsql_auth_test extension in the server source tree at villagesql/test-extensions/vsql-auth-test/, which no release includes. It accepts one fixed token, maps the connection to vsql_auth_test_user, and requests mysql_clear_password so the token arrives verbatim in the password slot. (The in-tree extension adds extra token paths, an .accepts_client_plugin() callback, and both opt-ins described below to drive its test suite.)

Binding an Account and Connecting

With the preview tier enabled (see Enabling the Preview Tier), install the extension and bind an account to the method. Because the handler maps to a second account, create that account too and grant it the PROXY privilege that lets the connecting account assume its identity:
CREATE USER ... IDENTIFIED WITH vsql_auth_test is accepted because vsql_auth_test is a registered VEF auth method — the same way an installed plugin name is accepted. Only the IDENTIFIED WITH <method> form is accepted, optionally with AS '...'. Adding BY '...' asks the method to turn a password into a stored credential — the job a MySQL plugin does through generate_authentication_string() — and no VEF auth method declares that hook today, so the server rejects it:
The bound method name is written to the account’s plugin column rather than the table default, which is what the account’s next login reads:
The method requests mysql_clear_password, so the client must pass --enable-cleartext-plugin to send the token in cleartext. On a correct token, the session runs as the mapped account and exposes the connecting account through @@external_user:
Uninstalling the extension removes the method; accounts bound to it can no longer authenticate:

Auto-Creating Accounts

A method can also handle logins for accounts that do not exist yet, and have the server create the account as part of the successful login. Without this, an unknown account is rejected before any method runs. Opt in with .auto_create(&callback). The callback takes no arguments and returns bool; the server calls it on each unknown-account login rather than reading it once at registration, so the method can follow a runtime setting of its own instead of freezing the choice when the extension loads:
Leaving .auto_create() off, or returning false from the callback, keeps the standard behavior: an unknown account is denied. Only one installed method may opt in at a time — if two return true, the server declines to guess, logs a warning to the error log, and rejects unknown accounts as if none had opted in. In the handler, c.account_unknown() distinguishes the two cases. Validate the credential first, then describe what to create and authenticate as it:
request_provision(account, roles, n_roles) records intent and returns nothing. The server runs the DDL itself, after the handler returns AuthResult::kOk, and only for a login that was routed in as an unknown account — so a login the handler goes on to deny creates nothing, and a request naming an account that already exists is ignored. What the server runs is CREATE USER IF NOT EXISTS <account>@'%' IDENTIFIED WITH <method>, followed by one GRANT per named role: the account is always created for host % and bound to the method that authenticated it, and account need not be the connecting user name. If the creation cannot be done — on a super_read_only server, for example — the login fails rather than proceeding without an account. Roles behave as they do for Staging Active Roles: the DBA owns them. Each name must already exist as a grantable role, and one that cannot be granted is logged and skipped rather than failing the login, so a token can name a role but never create or escalate one. The account name comes from the client, so the server quotes it as an identifier — a crafted name becomes one oddly-named account, never a second statement. The vsql_auth_test extension provisions the connecting user with the role vsql_role_granted, and gates the opt-in behind vsql_auth_test.auto_create, which starts OFF. Turn it on and create the role first, then connect as an account that does not exist:
The account now exists, bound to the method, holding the granted role:
A wrong token still fails closed, and provisions nothing:
Opting in makes the difference between an unknown and an existing account observable to anyone holding a valid credential, which the standard unknown-account rejection deliberately hides. That is the trade this feature makes; weigh it before enabling the opt-in on a method whose credentials are widely held.

Auto-Granting Roles

By default a role a token names takes effect only if the account already holds it, and one it does not hold is logged and skipped. .auto_grant(&callback) changes that: the server grants the staged roles to the account, so the token decides which roles the session gets rather than only which of the account’s existing roles to switch on. The callback matches .auto_create() in shape — no arguments, returns bool, and the server calls it on each login, so it can follow a runtime setting:
The two opt-ins are independent. .auto_create() governs logins for accounts that do not exist; .auto_grant() governs granting to the account a login resolves to, whether or not that account was just created. Leaving .auto_grant() off, or returning false, keeps the activate-only default. The grant persists — it is an ordinary GRANT, not a session-only activation — and it is additive: the server never revokes a role the token stopped naming. vsql_auth_test exposes this as vsql_auth_test.auto_grant, also OFF to start. Its -token-roles token stages vsql_role_granted and vsql_role_denied, and the account below holds neither. With the setting off, the login leaves the account’s roles alone:
Connecting as auth_user with that token, in the same way as above, and asking what is active:
Turn the setting on and repeat the same login:
Both roles are now active, and SHOW GRANTS shows the grant the server added:
With .auto_grant() on, a valid token is enough to gain any role it names. The role must already exist, so a token still cannot invent privileges, but the DBA no longer decides which existing roles an account may reach — the method does.