Architectural Invariants
Overview
These invariants capture rules that are enforced by the code paths responsible for planning, validation, and execution. They exist to keep the system deterministic, secure, and stable under malformed input or misconfiguration.
Semantic-Only Subqueries and Post-Combine Ops
Definition
Sub-query intents and post-combine operations must not contain SQL. Each text field (intent, metric names, filter, group-by and order-by attributes, expected-schema names) is checked on its own and rejected if it opens with a SELECT ... FROM statement, or contains a statement terminator (;) or a comment marker (--, /*, */). English words that happen to be SQL keywords are allowed: "customers from Brazil" and "tracks where the genre is Rock" are valid intents.
Enforcement Points
SubQuery.validate_semantic_only()innl2sql.pipeline.nodes.decomposer.schemasPostCombineOp.validate_semantic_only()innl2sql.pipeline.nodes.decomposer.schemas
Failure Behavior
Raises ValueError ("contains SQL syntax") when a field matches; the decomposer response fails validation.
Why It Exists
Prevents physical SQL leakage into semantic planning stages and keeps decomposer output safe to interpret downstream.
Combine Groups Require Roles and Typed Join Keys
Definition
Combine groups with compare or join operations must include a role for every input, at most one left-hand input (left, base or primary) and one right-hand input (right, compare or secondary), and at least one join key pair; a group with only one side is refused by the decomposer node. Each key names a column in the expected_schema of its side's sub-query, that column declares a dtype, and the two sides' dtypes can be coerced to each other: a number or a boolean never joins a date (Wave A, D30). The aggregator casts both sides of each key to one type before the join, in polars; a value that does not convert joins nothing and is a JOIN_KEY_UNCONVERTED warning.
Enforcement Points
CombineGroup.validate_roles()andDecomposerResponse.validate_join_key_types()innl2sql.pipeline.nodes.decomposer.schemasLogicalValidatorNode(validator/join_keys.py): a join-key column whose database type cannot be coerced to its declareddtypeis a retryableJOIN_KEY_TYPE_MISMATCH. The check lives in the validator, not the aggregator, because the validator has the retry edge.
Failure Behavior
Raises ValueError when roles, join keys or their types are missing or cannot meet; parse-and-retry sends it back to the model.
Why It Exists
Ensures combine operations are well-defined and deterministic for aggregation. A text key from one database joined to an integer from another used to fail in the aggregator, terminally, or match nothing (B30).
Decomposer References Must Resolve
Definition
Combine-group inputs must reference existing subqueries, and post-combine ops must reference existing combine groups.
Enforcement Points
DecomposerResponse.validate_references()innl2sql.pipeline.nodes.decomposer.schemas
Failure Behavior
Raises ValueError for unknown subquery or group references.
Why It Exists
Prevents invalid execution graphs and dangling references.
Expression AST Must Match Its Kind
Definition
Expression nodes must satisfy kind-specific required fields (e.g., binary ops require left/right and an operator).
Enforcement Points
Expr.model_post_init()innl2sql.pipeline.nodes.ast_planner.schemas
Failure Behavior
Raises ValueError when required fields are missing or operators are invalid.
Why It Exists
Guarantees the AST is structurally complete before SQL generation.
Plan Model Is Strict and Read-Only
Definition
Plan fields are schema-validated with no unknown fields, and query type must be READ.
Enforcement Points
ConfigDict(extra="forbid")on plan models innl2sql.pipeline.nodes.ast_planner.schemasPlanModel.query_type: Literal["READ"]innl2sql.pipeline.nodes.ast_planner.schemas, which pydantic enforces before any node sees the plan (the plan cache re-validates on read, so a cached plan is covered too)
Failure Behavior
Pydantic validation errors for extra/invalid fields and for any query_type other than READ; the plan never reaches the validator. LogicalValidatorNode no longer re-checks it — the branch was unreachable.
Why It Exists
Prevents mutation or unknown plan constructs from entering execution.
Table Aliases Are Unique
Definition
Each table alias in the plan must be unique.
Enforcement Points
LogicalValidatorNode._alias_collision()innl2sql.pipeline.nodes.validator.node
Failure Behavior
Returns PipelineError with INVALID_PLAN_STRUCTURE.
Why It Exists
Prevents ambiguous column resolution.
Plan Functions Are Known Functions
Definition
Every func expression names a function from ALLOWED_FUNCTIONS in nl2sql.pipeline.nodes.ast_planner.functions, written as a plain identifier (any case).
The only date functions on the list are the ones the planner prompt offers: DATE_PART and DATE_TRUNC, plus YEAR, QUARTER, MONTH, DAY and EXTRACT, which are read as DATE_PART. STRFTIME is still accepted as a legacy name (LEGACY_FUNCTIONS): a recorded demo answer and three of the Chinook suite's hand-written plans call it. Nothing offers it to the model, and the validator's rejection message leaves it out of the functions it lists.
Enforcement Points
LogicalValidatorNode._unsupported_functions()innl2sql.pipeline.nodes.validator.nodepackages/nl2sql/tests/unit/test_date_functions_agree_with_prompt.pyreads the allow-list and the planner prompt together, so a date function cannot be allowed by one and forbidden by the other
Failure Behavior
Returns PipelineError with UNSUPPORTED_FUNCTION (severity ERROR, so the refiner can retry).
Why It Exists
func_name is model text that becomes the function's name in the SQL. Without the check, any text, including a second statement, reached the query.
Expected Schema Must Match Select List
Definition
When expected_schema is provided, the plan’s select_items count and aliases must match it.
Enforcement Points
LogicalValidatorNode._validate_static()innl2sql.pipeline.nodes.validator.node
Failure Behavior
Returns PipelineError with INVALID_PLAN_STRUCTURE.
Why It Exists
Keeps multi-stage subqueries contractually aligned.
Column References Must Resolve
Definition
All column references must map to declared table aliases and be unambiguous.
Enforcement Points
LogicalValidatorNode._validate_columns()innl2sql.pipeline.nodes.validator.node, which resolves the plan withsqlglot.optimizer.qualify.qualify()against the schema retrieved for the queryLogicalValidatorNode._validate_static()converts resolution failures toPipelineError
Failure Behavior
Returns PipelineError with COLUMN_NOT_FOUND: a retryable ERROR by default, a WARNING that does not block when logical_validator_strict_columns is turned off.
Why It Exists
Prevents queries from referencing non-existent or ambiguous columns.
Joins Must Be Valid and Schema-Backed
Definition
Join aliases must exist, join conditions must reference both sides, must include an equality pair, and must match allowed schema relationships.
Enforcement Points
LogicalValidatorNode._validate_static()join checks innl2sql.pipeline.nodes.validator.node
Failure Behavior
Returns PipelineError with INVALID_PLAN_STRUCTURE or JOIN_TABLE_NOT_IN_PLAN.
Why It Exists
Prevents invalid joins and enforces schema-authorized relationships.
Policy Enforcement Is Namespaced and Fail-Closed
Definition
A role allows and denies tables with patterns that are *, datasource or datasource.schema.table (each part a glob, matched regardless of case); a two-part pattern is rejected when the config loads. Policy checks fail if the datasource ID is missing. Access is decided on datasource, schema and table: a table the plan names without a schema is in the adapter's default schema. Deny beats allow within a role; across roles a table is readable when some role allows it and that same role does not deny it. Every place that decides table access asks one Scope (PolicyEngine.scope(user, datasource_id) in nl2sql.policies), engine.datasources.schema(..., user_context=) included, and nothing outside nl2sql/policies/ matches a policy pattern. A run builds each datasource's scope once and keeps it. An optional policy check (nl2sql_sdk.policy.PolicyCheck) is asked once per caller and datasource per run; its grant is intersected with the roles, so neither widens the other, and any exception from it denies the datasource for the run: it never fails open.
Enforcement Points
Roleinnl2sql.config.models(the pattern shape, throughnl2sql.policies.patterns.parse_pattern)LogicalValidatorNode._validate_policy()innl2sql.pipeline.nodes.validator.nodeScopeandPolicyEngineinnl2sql.policies.scope, asked with the same datasource, schema and table by the datasource resolver, the curated matcher, the schema retriever (which hides denied tables from the planner), the validator, the executor's value check andengine.policies.allows()test_every_access_decision_goes_through_scopeinpackages/nl2sql/tests/architecture/test_boundaries.py
Failure Behavior
ValueError for an invalid pattern; PipelineError with SECURITY_VIOLATION if datasource ID is missing or a table is not allowed. An unknown role, or no role, allows nothing and is refused the same way, never with VALIDATOR_CRASH. The user-facing refusal names no table unless policies.refusal_names_tables is set; the table, the roles and the rule are in the error's details.
Why It Exists
Ensures access control boundaries are explicit and enforced.
Datasource Access Is Policy-Gated
Definition
Only datasources the caller's scope reaches (Scope.reaches_datasource()) may be selected or resolved.
Enforcement Points
DatasourceResolverNode._get_allowed_datasource_ids()and__call__()innl2sql.pipeline.nodes.datasource_resolver.nodeCuratedMatcherNode._searchable()and_check_access()innl2sql.pipeline.nodes.curated_matcher.node: the verified-query search covers only datasources the role may read, and every table a verified query's SQL reads must passScope.allows()
Failure Behavior
Returns PipelineError with SECURITY_VIOLATION if no allowed datasource is available. The check runs before the resolver's answerability LLM call, so a denied caller triggers no model call and the model only ever sees datasources the role may read. It runs on every path, including the single-datasource shortcut that skips the vector search. The curated matcher refuses nothing itself: a verified query on a datasource or table the role may not read is never offered or never run, and the run falls back (policy_denied) to the resolver, which refuses as above.
Why It Exists
Prevents execution against unauthorized datasources.
Events Carry No Rows, and the Stream Carries No Operator Data
Definition
No event on the engine's bus carries result rows, sample values, keys or header values; the question and the answer text are carried and may hold values from the data. Every published name is in the event catalog, whose model for it forbids extra keys. A caller's own stream (stream_query) yields only its run's events on the caller-safe allow-list (STREAM_EVENTS, the catalog entries marked stream); the full QueryResult is only on the stream's copy of run.finished, never on the bus.
Enforcement Points
EventBus.publishand the catalog innl2sql.events(CATALOG,STREAM_EVENTS)QueryAPI.stream_queryinnl2sql.api.query_api(subscribes to its scope with the allow-list only)test_no_bus_event_carries_rowsinpackages/nl2sql/tests/e2e/test_run_events_fake_llm.py,test_stream_never_yields_policy_or_hook_or_llm_call_eventsinpackages/nl2sql/tests/unit/test_stream_query.py, andtest_every_published_event_is_in_the_cataloginpackages/nl2sql/tests/architecture/test_event_catalog.py
Failure Behavior
An uncatalogued name, or data its model refuses, is a ValueError or a validation error from publish: an engine bug a test sees. An event off the allow-list never reaches the stream; an operator reads it with engine.events.subscribe.
Why It Exists
Observers write events to logs, files and collectors outside the engine's access control, so rows must never reach them; and a stream is the caller's, so a policy denial (which would tell a denied user that a table exists), a hook's reasons or a model's cost must not reach it.
Observers Fail Open and Never Block
Definition
An observer (the observers section, or engine.events.subscribe) can never fail a run or an engine call: what it raises is logged once per observer and event name and skipped. It never blocks either: the bus delivers a run's events one at a time under the run's lock, so an observer that writes to disk or the network hands that work to a thread of its own (the audit observer queues its lines on a bounded queue, dropping and counting what does not fit; the otel observer records in memory and its SDK exports in the background). Hooks are the opposite: they fail closed (Hooks Modify Before the Validator, Deny After).
Enforcement Points
EventBus.publishinnl2sql.events.bus(subscribers made withfail="open", the default for observers)AuditObserverinnl2sql.observers.audit(a queue and a writer thread; a failed write is logged)test_observer_exception_is_logged_once_and_skippedandtest_audit_never_blocks_the_publisherinpackages/nl2sql/tests/unit/test_observers.py, andtest_a_raising_subscriber_never_fails_the_runinpackages/nl2sql/tests/unit/test_events_api.py
Failure Behavior
A WARNING on logger nl2sql.events, once per observer and event name, and EventBus.failures counts it. The run goes on.
Why It Exists
Observing must not change the answer. A full disk, a collector that is down or a plugin bug would otherwise fail every question, and a slow write would hold every run's lock.
SQL Execution Requires SQL, Datasource, and Capability
Definition
Execution only proceeds when SQL text exists, a datasource ID is present, and the datasource supports SQL.
Enforcement Points
ExecutorNode.__call__()innl2sql.pipeline.nodes.executor.nodeSqlExecutorService.validate_request()innl2sql.execution.executor.sql_executor
Failure Behavior
Returns PipelineError with MISSING_SQL, MISSING_DATASOURCE_ID, or INVALID_STATE.
Why It Exists
Prevents invalid execution requests and ensures capability compatibility.
Execution DAG Must Be Valid and Acyclic
Definition
Post-combine ops must target known combine groups, all edges must reference existing nodes, and the DAG must be acyclic.
Enforcement Points
build_execution_dag()innl2sql.pipeline.nodes.decomposer.dag, called byDecomposerNode.__call__()ExecutionDAG._layered_toposort()innl2sql.execution.dag
Failure Behavior
Raises ValueError, leading to PipelineError with PLANNER_FAILED.
Why It Exists
Ensures deterministic and well-ordered aggregation execution.
Aggregation Requires Scan Artifacts and Single-Input Post Nodes
Definition
Scan nodes must have corresponding artifacts, and post-combine nodes must have exactly one input. The one exception is a scan a hook dropped (a deny scoped to its sub-query): it has no artifact, a join or compare group it feeds is dropped whole with its post-combine nodes, and another group goes on without it.
Enforcement Points
AggregationService.execute()innl2sql.aggregation.aggregator(itsdroppedargument)EngineAggregatorNodeinnl2sql.pipeline.nodes.aggregator.node, which passes the sub-queries whose output isdropped
Failure Behavior
Raises ValueError when artifacts are missing or post nodes have invalid inputs. A dropped group is a HOOK_DENIED warning; when hooks dropped every part of the question, the aggregator ends the run with HOOK_DENIED.
Why It Exists
Guarantees aggregation operates on complete, correctly wired inputs.
An Engine Keeps Its Data Under storage.path
Definition
storage is a required section of the config. An engine keeps its vector
index in <storage.path>/index/, its schema snapshots, plan cache and
feedback in <storage.path>/state.db, its traces in <storage.path>/traces/,
its result files in <storage.path>/results/ and, with the audit observer,
its audit log in <storage.path>/audit/. The index collection name
is an internal constant.
Enforcement Points
Configinnl2sql.config(the section is required)Runtime.from_config()innl2sql.runtime(each slot's store, built withfolder=storage.path)
Failure Behavior
A config without storage is a ConfigError from load_config, or a
validation error building Config in code.
Why It Exists
There is no hidden default for where data lives, and two engines with
different storage.path values never share a store.
SQL Generation Enforces a Row Limit Cap
Definition
Generated SQL must not exceed the row limit: the datasource adapter’s row_limit, bounded by the RESULT_ROW_LIMIT ceiling (100,000), which is also the limit when the datasource sets none. A result that reaches the limit is flagged as truncated. Verified SQL is held to the same limit, but a person's SQL is never rewritten: the executor stops reading its result at the limit (ExecutorRequest.max_rows).
Enforcement Points
GeneratorNode.__call__()innl2sql.pipeline.nodes.generator.nodewrap_subgraph()innl2sql.pipeline.graph_utilssetsSubgraphOutput.truncated, whichQueryResultand the answer carryCuratedMatcherNode._run()setsmax_rowsandtruncatedfor a verified query;_Streaminnl2sql.execution.executor.sql_executorstops atmax_rowsand closes the adapter's stream
Failure Behavior
No error; the limit is clamped to the smaller of the two. A cut result is flagged (truncated), and the answer says it was cut at N rows.
Why It Exists
Protects execution resources and prevents runaway result sizes, without passing off a cut result as complete.
The Answer Model Sees a Preview, Never the Whole Result
Definition
The answer synthesizer's prompt carries at most ANSWER_PREVIEW_ROWS (20) rows of each result, with its total row count, its column names and a partial flag. The model is told to state only numbers from the preview or the count, and not to reproduce the result.
Enforcement Points
result_preview()andAnswerSynthesizerNode._serialize_result()innl2sql.pipeline.nodes.answer_synthesizer.node
Failure Behavior
None; rows past the preview are left out of the prompt.
Why It Exists
The prompt's size must not grow with the result: that is what kept the row limit at 1,000. The user sees the full result as a table, so the model has no need to copy it.
Pipeline Execution Is Time-Bounded
Definition
Pipeline execution must complete within settings.global_timeout_sec.
Enforcement Points
run_with_graph()innl2sql.pipeline.runtime
Failure Behavior
Returns PipelineError with PIPELINE_TIMEOUT and a timeout response message.
Why It Exists
Ensures latency bounds and prevents hung requests.
Package Boundaries Are One-Way
Definition
Dependencies point in one direction: nl2sql-sdk → nothing, nl2sql.adapters.* → the SDK, nl2sql (the engine) → the SDK, and every caller — the REST API, the CLI, the playground — → the top-level nl2sql facade.
Concretely:
- The SDK's core imports only pydantic and the standard library.
nl2sql_sdk.sqlalchemy, the SQLAlchemy base installed withnl2sql-sdk[sqlalchemy], may also import SQLAlchemy and sqlglot, and the core never imports it.nl2sql_sdk.testing, the conformance kit installed withnl2sql-sdk[testing], may import pytest and sqlglot, but not SQLAlchemy: it checks an adapter throughDatasourceAdapteralone. - An adapter imports only the SDK, its driver, SQLAlchemy, sqlglot, pydantic and the standard library. SQLAlchemy and sqlglot are adapter-layer libraries: the shared connection layer, and the expression vocabulary the engine hands over to be rendered.
- Nothing outside
nl2sql/adapters/(and the SDK's SQLAlchemy base) imports an adapter, SQLAlchemy or a database driver. The engine reaches an adapter through thenl2sql.adaptersentry points andDatasourceAdapter. - Only
nl2sql/storage/importschromadb(orlangchain_chroma). The engine embeds, ranks and filters through theIndexStoreinterface (nl2sql_sdk.stores), so a plugin index store works the same way as the built-inchromaone. The one exception is the local embedder,nl2sql/embeddings/local.py, whose model is the ONNX all-MiniLM-L6-v2 that ships inside chromadb. - Only
nl2sql/storage/importssqlite3. Schema snapshots, the plan cache, feedback and the index lock are reached through theStateStoreinterface, so a plugin state store works the same way as the built-insqliteone. The SQLite adapter (its driver) reads a datasource, not the engine's state, and is exempt. An eval suite's expected rows come through the datasource's adapter, so nothing innl2sql/evaluation/importssqlite3(test_gold_does_not_import_sqlite3). - Nothing outside
nl2sql/cli/imports the CLI, andnl2sql.cli.demonever importsnl2sql.cli.commands. nl2sql_apiimports only the top-levelnl2sqlnamespace, and so do the playground andnl2sql demo, apart from a named list of exceptions (STILL_IMPORTED_FROM_INSIDE: the provider catalogue inllm/providers.py, the hosted demo's per-request keys, and the demo's replay machinery and questions). In particular they never importnl2sql.feedback,nl2sql.tracing,nl2sql.pipeline,nl2sql.indexingorPROVIDER_PRESETS:engine.feedback,engine.traces.get,engine.about(),engine.index.inspect_defaults()andengine.llm.providers()serve them. The CLI, the playground andnl2sql_apireach the engine only throughNL2SQL's public methods and namespaces, never the runtime behind them (engine._runtime, a namespace's_ctx). The MCP server (nl2sql/mcp/) imports only the top-levelnl2sqland, under the same rule, never the runtime; it is the only code that importsmcp, themcpextra (test_mcp_uses_only_the_public_api). The SDK'sFastMCPsettings are pydantic-settings that readFASTMCP_*variables and the working directory's.env; the server passes every setting explicitly, so neither changes it, and nothing is written.- The runtime (
pipeline,execution,indexing,datasources,llm,policies,aggregation,schema,services,context,public_api) never importsnl2sql.evaluationornl2sql.feedbackat module scope. A deferred import inside a function is fine. - A provider's API-key environment variable is read in
nl2sql/llm/only.PROVIDER_PRESETS(llm/registry.py) is the one table; everything the CLI, the playground, evaluation and the REST API know about providers derives from it throughllm/providers.py.
Enforcement Points
packages/nl2sql/tests/architecture/test_boundaries.py— an AST import scan ofpackages/*/src, one test per rulepackages/api/tests/test_architecture.py— the REST API's own imports, and that its response model isnl2sql.QueryResult
Failure Behavior
A failing test naming the rule and the file, line and import that broke it. Each documented exception is a named constant in the test module with the reason beside it.
Why It Exists
Each rule keeps one thing replaceable. The SDK is what a third-party adapter compiles against, so a dependency there is a dependency every adapter author inherits; an adapter that imported the engine would close the circle and make the split meaningless. Engine code that imports an adapter, or SQLAlchemy, is a second way to reach a database that no new adapter can plug into. And import nl2sql loading nl2sql.evaluation cost every SDK user the YAML loaders, the eval suites and the fake LLM before anyone asked a question.
Example
The playground once read engine.context.ds_registry, engine.context.schema_store and engine.context.vector_store through getattr, so the schema view, the retrieval inspector and index health existed only in the playground; nl2sql-api had a placeholder where index status should be. The fix was facade methods that both callers use. Later the CLI's run, index and trace replay built on the runtime too; they now use stream_query's events, engine.index.build's IndexProgress callback and engine.traces.replay. The playground then still imported the feedback store, the trace reader, the pipeline's step list and the provider presets; those are engine.feedback, engine.traces.get, engine.about() and engine.llm.providers() now, which nl2sql feedback uses too. The rule stops the shortcut being taken again, which is what keeps a second client cheap.
Importing nl2sql Is Light
Definition
import nl2sql loads none of chromadb, onnxruntime, sqlalchemy, psycopg2, psycopg, pymysql, pyodbc, pymssql, duckdb, duckdb_engine, textual, opentelemetry or mcp, and its imports stay under 500 ms.
Concretely:
nl2sql/__init__.pyimports only theNL2SQLclass. Every other public name (QueryResult,Event,CancellationToken, the API classes, the eval shapes) is imported the first time it is asked for, through the module's__getattr__.from nl2sql import QueryResultworks as it always did.nl2sql/public_api.pyimports the runtime (Runtimeand the API classes) insideNL2SQL.__init__, not at the top of the module.- Runtime modules import their heavy libraries where they are used, as the adapters already do.
- Only
nl2sql/observers/otel.pyimportsopentelemetry, and only inside the observer: OpenTelemetry is theotelextra, and an engine without anotelobserver never loads that module.
Enforcement Points
test_only_the_otel_observer_imports_opentelemetry,test_the_otel_observer_imports_opentelemetry_only_when_it_is_builtandtest_building_an_engine_loads_no_otel_observerinpackages/nl2sql/tests/architecture/test_boundaries.py. A built engine may still haveopentelemetryinsys.modules: chromadb, a core dependency, installs its SDK, and langsmith imports it whenever it is installed. The rule holds the engine's own code.test_importing_nl2sql_loads_no_heavy_libraryinpackages/nl2sql/tests/architecture/test_boundaries.py— importsnl2sqlin a fresh interpreter and checkssys.modulestest_import_nl2sql_stays_within_its_time_budgetin the same module — the cumulative figurepython -X importtimereports fornl2sql, best of five, under 500 ms. It measures imports only, not wall-clock time, so a busy runner does not fail it.
Failure Behavior
A failing test naming the heavy libraries that were loaded, or the import time against the budget.
Why It Exists
Code that only needs a name — a type hint, nl2sql --help, a config check in CI, a REST client building a request — should not pay for a vector store, a database driver and a tracing stack. import nl2sql used to load chromadb, duckdb, opentelemetry, langgraph, langchain_openai and polars and took about 2.2 seconds; it now takes about 15 ms. The cost moves to NL2SQL(...), which needs the runtime anyway.
An Engine Owns Its Config: No Process Globals
Definition
Everything an engine reads comes from its own Config, handed down through the runtime (ctx.settings, ctx.config) or passed as a parameter. Nothing is process-global, so two engines in one process share nothing.
Concretely:
- There is no
settingssingleton. A module with no runtime takes the value it needs as a parameter (run_date(today),PlanCache(store, enabled=..., today=...)). - No module writes the process environment: no
os.environassignment,poporupdate, noos.putenv. Keys and options travel in theConfig.nl2sql demo --api-keyand a key saved in the playground's settings panel are written to the demo's.envand given to the engine as a value in its LLM config, never put inos.environ;nl2sql demo --hostedrefuses to start when a provider key variable is set rather than drop it. - The environment is read from an allow-list only:
NL2SQL_CONFIG(nl2sql/config/),NL2SQL_LOG_LEVELandNL2SQL_LOG_FORMAT(configure_logging), the provider key variables (nl2sql/llm/, throughPROVIDER_PRESETS),${env:VAR}references (nl2sql/config/and theenvsecret provider), and the trace redactor, which masks every value the environment holds. Anything else is configuration and is written innl2sql.yaml, as${env:VAR}where it should come from the environment. - Only the entry points load a
.envfile: the CLI,nl2sql demoandnl2sql-apicallnl2sql.config.load_env_file(), which loads the.envnext tonl2sql.yamland never overrides a variable already set. It is the one place that callsload_dotenv. The library never reads a.env; a Python caller who wants one loads it beforeNL2SQL.from_config(). - Runs that need different settings (evaluation, trace replay) take a run-scoped runtime with a derived config (
ctx.derive(...)) instead of editing shared state.
Enforcement Points
test_no_module_writes_the_environment,test_environment_reads_only_from_the_allow_listandtest_only_entry_points_load_dotenvinpackages/nl2sql/tests/architecture/test_boundaries.py, an AST scan ofpackages/*/src(READS_THE_ENVIRONMENTnames each allowed reader and why); the first also scansscripts/, so a maintenance script passes keys as arguments instead of writing the environmentpackages/nl2sql/tests/e2e/test_two_engines_fake_llm.py— two engines in one process, each with its own row limit, plan cache, LLM, embedder and storage, andos.environunchanged afterwards
Failure Behavior
A failing test naming the file, line and the write or read that broke the rule.
Why It Exists
0.4 kept its settings in a process-global object that every module imported, and carried keys and options through os.environ. Two engines in one process (a test, a server with two configs, an evaluation run beside live traffic) then read each other's values, an evaluation that pinned a setting raced every other query, and a key set for one engine leaked into every other engine and child process.
Dialect Knowledge Lives in the Adapter
Definition
No engine module names a database dialect or writes one database's SQL. The plan says what; the adapter says how. An adapter declares its dialect with get_dialect(), which must return a name sqlglot.Dialect.get_or_raise accepts, and may override render_sql() for what sqlglot cannot express.
Enforcement Points
test_no_dialect_name_outside_the_adaptersandtest_no_dialect_specific_sql_outside_the_adaptersinpackages/nl2sql/tests/architecture/test_boundaries.py— a scan of every non-docstring string literal, f-string chunks includedtest_get_dialect_is_a_sqlglot_dialect, over everynl2sql.adaptersentry point- The plan's one function vocabulary:
ALLOWED_FUNCTIONSinnl2sql.pipeline.nodes.ast_planner.functions(see Plan Functions Are Known Functions)
Failure Behavior
A failing test naming the literal and where it was written. The documented exceptions are the engine's own state store, a SQLite file by default (storage/sqlite.py), the in-process polars/DuckDB compute engine (aggregation/engines/polars_duckdb.py), and the plan's function allow-list.
Why It Exists
SQL that only one database understands, written in the engine, is wrong for every other database — and it is wrong silently, because it parses. A dialect name in engine code is worse: it is a switch statement that every future adapter has to be added to, in a repo whose point is that adapters are pluggable.
Example
The planner prompt asked for STRFTIME('%Y', o.order_date) to get a year. On SQLite that works; on Postgres and SQL Server it is a call to a function that does not exist, and on MySQL it means something else. The fix was not to teach the prompt about dialects but to move the decision: the plan asks for DATE_PART('year', x), the generator builds a typed sqlglot node, and SQLiteAdapter.render_sql() rewrites it to STRFTIME for SQLite only. Related: get_dialect() returned SQLAlchemy's postgresql and mssql, which sqlglot rejects, so no SQL could be generated for either datasource — a one-word bug that the entry-point contract test now catches.
The Validator Rejects Everything the Generator Can
Definition
Anything the generator can refuse, the logical validator must refuse first. The validator is the last node with a retry edge back to the planner; the generator runs after it.
Enforcement Points
packages/nl2sql/tests/unit/test_logical_validator_generator_parity.py— each case asserts both halves: the generator rejects the plan, and so does the validator, with a retryable codeLogicalValidatorNode._validate_static()innl2sql.pipeline.nodes.validator.node
Failure Behavior
A structural mistake is returned as a retryable PipelineError from the validator, so check_logical_validation sends the plan back to the refiner and the planner sees the message. The same mistake reaching the generator is a terminal SQL_GEN_FAILED.
Why It Exists
A check that lives only in the generator is a dead end rather than a retry. The model produced a fixable plan and got a hard failure, one node past the only gate that could have asked it to try again.
Example
The validator resolves columns against a throw-away query in which every table is CROSS JOINed — correct for column resolution, and deliberately blind to join topology. So a plan declaring three tables and joining only two validated CLEAN, then failed in the generator with "Table alias(es) t3 are declared in the plan but never joined to the FROM table." Three more join-shape mistakes behaved the same way. Every one is a pure function of plan.tables and plan.joins — no schema, no dialect — so each belongs in the validator, where the message reaches the planner.
Verified SQL Is Human-Authored, Read-Only and Parameter-Bound
Definition
A verified query's SQL is written, reviewed and committed by a person in curated.yaml. The model picks a verified query by id and supplies parameter values; it never writes or edits the SQL. The SQL must be exactly one read-only query whose :name placeholders are exactly its declared params. Values are typed and checked by code (type, min/max, choices, required) and bound by the adapter's driver, never interpolated into the SQL text. A query with params runs only on an adapter that declares SUPPORTS_BOUND_PARAMETERS.
Enforcement Points
VerifiedQueryandParamSpecinnl2sql.config.curated, andinspect_verified_sql()andcheck_curated_queries()innl2sql.configs.curated, at startup, innl2sql curated validate, and innl2sql curated test(CuratedAPI.test()), which runs only queries that pass them, with their values boundresolve_params()innl2sql.pipeline.nodes.curated_matcher.paramsCuratedMatcherNode._match()innl2sql.pipeline.nodes.curated_matcher.node: parses the SQL read-only again before running, and checks the capabilitySQLAlchemyAdapter._run()innl2sql_sdk.sqlalchemy.adapter:conn.execute(text(sql), parameters); without parameters the call is exactlyconn.execute(text(sql))packages/nl2sql/tests/sqlalchemy_base/test_bound_parameters.py: a value1; DROP TABLE xis compared as a value and the table survives
Failure Behavior
A broken file fails startup. At question time a bad value, a denied table, a missing capability or a failed run falls back to the generated pipeline with a reason code in the matcher's trace record; it is never an error of the run. A hook's deny at execute.before or execute.after is different: it ends the run with HOOK_DENIED and does not fall back, because a deny means no.
Why It Exists
The model emits a typed plan, never SQL holds on this path too: no model wrote the SQL, and a value cannot change what the query does.
Hooks Modify Before the Validator, Deny After
Definition
A hook may change what a run carries only through a Modify, and only at the three points before the logical validator: the question at run.before, the datasources at datasources.after (a subset only, checked against the caller's scope again) and the plan at plan.after. A plan a hook changed is validated like any other. At execute.before, execute.after and answer.after a hook may only allow or stop the run (or, at execute.before, ask for approval). Hooks fail closed: an exception, a value that is not a decision, or a decision the point does not allow denies with HOOK_FAILED; on_error: open lets an exception through as an allow, never a disallowed decision. The plan cache stores the planner's plan, never a hook's change of it.
Enforcement Points
HOOK_POINTSinnl2sql.hooks.points(what each point allows) andHookRunner.call()innl2sql.hooks.runnerASTPlannerNode.after_plan()innl2sql.pipeline.nodes.ast_planner.node: every plan the validator gets (the planner's, a cached one, a supplied one) passesplan.aftertherewrap_subgraph()innl2sql.pipeline.graph_utilscachesASTPlannerResponse.planner_planHookRunner.call()hands hooks deep copies of the plan, the caller, the sub-query and the values, so a hook that changes one in place changes nothing:test_a_hook_changing_the_callers_roles_in_place_changes_nothing,test_a_hook_changing_the_plan_in_place_changes_nothing,test_a_shallow_copy_modify_leaves_the_planners_plan_aloneandtest_a_hook_changing_the_params_in_place_changes_nothinginpackages/nl2sql/tests/unit/test_hook_points.pytest_hooks_modify_only_before_the_validatorinpackages/nl2sql/tests/architecture/test_hooks_boundary.py: the point table, an AST scan of every runner call (each names its point as a literal, and no other call passes a point name along), and the SQL agent graph, in which every path from the planner to the generator passes the validator
Failure Behavior
A deny is HOOK_DENIED, a hook error HOOK_FAILED (its message names the hook and the point only; the exception goes to the log and hook.decision), an approval request NEEDS_APPROVAL; none is retried. A run-scoped one ends the run once the step in flight is done, and its result carries no rows, artifact references or result manifest. A deny scoped to a sub-query drops it (and a join or compare group it feeds), and the answer says what went unanswered.
Why It Exists
The validator rejects everything the generator can would mean nothing if code could change the plan or the SQL after the validator: a hook could otherwise route around the policies, including a table the role may not read.
Categories
- State: Plan Model Is Strict and Read-Only; Context Requires Vector Store and Schema Store Configuration
- Execution: SQL Execution Requires SQL, Datasource, and Capability; Execution DAG Must Be Valid and Acyclic; Aggregation Requires Scan Artifacts and Single-Input Post Nodes; Pipeline Execution Is Time-Bounded
- Security: Policy Enforcement Is Namespaced and Fail-Closed; Datasource Access Is Policy-Gated; Plan Model Is Strict and Read-Only; Verified SQL Is Human-Authored, Read-Only and Parameter-Bound; Hooks Modify Before the Validator, Deny After
- Determinism: Expected Schema Must Match Select List; Joins Must Be Valid and Schema-Backed
- Resource bounds: SQL Generation Enforces a Row Limit Cap; The Answer Model Sees a Preview, Never the Whole Result; Context Requires Vector Store and Schema Store Configuration
- Boundaries: Package Boundaries Are One-Way; Importing nl2sql Is Light; An Engine Owns Its Config: No Process Globals; Dialect Knowledge Lives in the Adapter
- Retryability: The Validator Rejects Everything the Generator Can; Plan Functions Are Known Functions
- Observability: Events Carry No Rows, and the Stream Carries No Operator Data; Observers Fail Open and Never Block
Gaps
- Column existence enforcement degrades to warnings when
settings.pipeline.strict_columnsis turned off (it is on by default), so missing columns then do not always block execution. - Semantic-only checks are pattern based: they block a field that opens with
SELECT ... FROM, a statement terminator and comment markers, rather than parsing for every possible SQL construct. An intent that literally reads "select ... from ..." is rejected as SQL.
Related Code
packages/nl2sql/src/nl2sql/pipeline/nodes/decomposer/schemas.pypackages/nl2sql/src/nl2sql/pipeline/nodes/ast_planner/schemas.pypackages/nl2sql/src/nl2sql/pipeline/nodes/validator/node.pypackages/nl2sql/src/nl2sql/pipeline/nodes/datasource_resolver/node.pypackages/nl2sql/src/nl2sql/pipeline/nodes/curated_matcher/node.pypackages/nl2sql/src/nl2sql/config/curated.pypackages/nl2sql/src/nl2sql/hooks/packages/nl2sql/src/nl2sql/observers/packages/nl2sql/src/nl2sql/pipeline/nodes/generator/node.pypackages/nl2sql/src/nl2sql/pipeline/nodes/executor/node.pypackages/nl2sql/src/nl2sql/execution/executor/sql_executor.pypackages/nl2sql/src/nl2sql/pipeline/nodes/decomposer/dag.pypackages/nl2sql/src/nl2sql/execution/dag.pypackages/nl2sql/src/nl2sql/aggregation/aggregator.pypackages/nl2sql/src/nl2sql/runtime.pypackages/nl2sql/src/nl2sql/storage/sqlite.pypackages/nl2sql/src/nl2sql/pipeline/runtime.pypackages/nl2sql/src/nl2sql/policies/scope.pypackages/nl2sql/src/nl2sql/pipeline/nodes/ast_planner/functions.pypackages/nl2sql/src/nl2sql/llm/registry.pypackages/sdk/src/nl2sql_sdk/protocols.pypackages/nl2sql/tests/architecture/test_boundaries.py