Skip to content

Wave A: events, observers, hooks, evals and routing (design)

Status: design

This page is the design for Wave A of the product redesign, which ships as release 0.7. Each pull request in the PR plan adds a "Built" note to the sections it builds, as Phase 1 and Phase 2 did; A0 (the foundation), E1, H1, H2, V1, E2, V2, E4, S1 and D1 are built. Phase 1 is Configuration v2 and the extension registry; Phase 2 is Phase 2: test bed, storage, providers, policies and adapter SDK v2. Wave A is what those pages called "Phase 3", plus the cross-database routing work ("Phase R") and three smaller tracks.

Principles

The owner restated them for Wave A, and they rank above everything below:

  • The package is the product; the playground only demonstrates it. Every capability is a public facade method or an extension point first. The CLI, nl2sql-api, the playground and the MCP server use the public API only, and nothing in this design exists only in one of them.
  • No active users: design it correctly, not compatibly. No shims, no aliases, no deprecation paths. Where a 0.6 shape is wrong, 0.7 replaces it.
  • Never over-engineer. Ship what 0.7 needs; the cut lists below are part of the design.
  • Everything is configurable in nl2sql.yaml (and its section files) or in code with dependency injection, extensible through the registry, and events and observability apply everywhere: a question on the generated or the verified-query path, an index build, a purge, an LLM swap, from the CLI or the server. Hooks apply to every run, on both paths; there is no hook on an index build in 0.7.

Two older rules shape this wave in particular: the model emits a typed plan, never SQL (it decides how evals are seeded), and anything the generator can reject, the validator rejects first (it decides where the join-key check and the hook boundary live).

Decisions

The owner was asleep when this design was written and asked that its recommendations be used, each marked for confirmation. D9 to D13 were already on record from the final redesign; D14 onwards are new. Each decision is written into the section it affects. An independent review of the first draft (PR #308) changed several of them; those are marked amended after review and are still the owner's to confirm.

# Decision Status Where
D9 Hooks fail closed; observers fail open. A hook that raises denies, unless that hook is configured on_error: open. An observer that raises is logged and skipped; it can never fail a run. decided overnight, owner to confirm Hooks, Observers
D10 Approval ends the run, and the re-run pins what was approved. A hook at execute.before may answer needs_approval; the run ends with status needs_approval and an ApprovalRequest that carries the decomposition, the sub-query, its validated plan and the SQL's hash, bound to the asker. An approver re-runs the question with an Approval; the re-run uses the pinned decomposition and plan, validates and generates again, and compares the SQL's hash. Nothing is queued or stored. decided overnight, amended after review, owner to confirm Approval
D11 A dated price table ships in llm/; a model it does not list has a null cost. llm.prices in the config adds or overrides rows. decided overnight, owner to confirm Cost and caps
D12 Evals use curated answers by default and record each answer's source; every case a verified query answered is also run with curated answers off, so the report has a generated-only accuracy over every case. Modes plans and retrieval always run with curated answers off, and mode full with the plan cache off, as tier 1 and tier 2 do today. decided overnight, amended after review, owner to confirm Evals
D13 No masking at execute.after on day one. After the validator a hook may only allow, deny or ask for approval. decided overnight, owner to confirm Hooks
D14 Event and the Observer interface live in the SDK (nl2sql_sdk.events, pydantic only), so an observer plugin depends on the SDK alone. Hook and Scorer live in the engine, because they see engine types (a plan, a QueryResult). decided overnight, owner to confirm The event bus
D15 Events never carry result rows, sample values, keys or header values. They carry the question, SQL, table names and counts; the question and the answer text (answer.delta) are carried and may contain values from the data. The full QueryResult is never published on the bus: only the stream's own terminal event holds it. decided overnight, amended after review, owner to confirm The catalog
D16 policy.decision is published for denials, at the enforcement points only: the validator's table refusal, the curated matcher's refusal of a verified query, and the resolver's datasource refusal. Scope itself publishes nothing, because it is also asked to filter (the retriever, the value check), which would flood the bus with tables nobody asked for. decided overnight, amended after review, owner to confirm The catalog
D17 stream_query yields bus Events, from a caller-safe allow-list: run.*, node.*, answer.delta, result.manifest, sanity.flagged and run.limit_reached. QueryEvent and its six type names are deleted, with no alias. Operator events (policy.decision, hook.decision, llm.call and the rest) are reached only through engine.events.subscribe. decided overnight, amended after review, owner to confirm The run's stream
D18 OpenTelemetry becomes the otel extra and an observer. settings.observability is replaced by an observers: section; with no section the engine runs log and audit. decided overnight, owner to confirm Observers
D19 The audit log moves under storage.path (audit/, JSON lines, 90 days, purged by engine.storage.purge()), written by the audit observer: one summary line per run (who, which datasources and tables, status, source) plus the denials, approvals, limits and changes. common/event_logger.py is deleted (bug B27). decided overnight, amended after review, owner to confirm Built-in observers
D20 Caps: max_llm_calls defaults to 60, max_cost_usd to none. A cost cap with a configured model the price table cannot price is a ConfigError when the engine is built. decided overnight, owner to confirm Per-question caps
D21 The six hook points are run.before, datasources.after, plan.after, execute.before, execute.after and answer.after. The first three may modify; the last three may not. decided overnight, owner to confirm The six points
D22 No built-in hooks in 0.7. The docs carry a 30-line example; the tests use fakes. decided overnight, owner to confirm Hooks
D23 Hooks run in config order. The first deny ends the point; modifications chain, each hook seeing the previous one's output. decided overnight, owner to confirm Hook decisions
D24 An approval is bound to the asker and checked by the engine. The request's id hashes the question, the asker's user_id and tenant_id, the hook, the datasource, the plan and the SQL's hash; the engine recomputes it, and an approval for another asker or another request is refused. Approvers are the hook's approver_roles, by default policies.admin_roles. An approved decision counts as Allow, and the next hook at the point still runs. An Approval is not signed and does not expire: it is trusted exactly as far as the UserContext a caller passes is trusted today. decided overnight, amended after review, owner to confirm Approval
D25 engine.evals only. No top-level engine.evaluate (the top level stays questions only). engine.benchmark, BenchmarkAPI, BenchmarkConfig and nl2sql benchmark are deleted. decided overnight, owner to confirm The facade
D26 Seeding asks the model for questions and typed plans, never SQL, five per call, under a cost cap (max_cost_usd, by default evals.yaml's). The engine's validator and generator turn each plan into SQL, the adapter runs it read-only, and the case is written with status: draft. decided overnight, amended after review, owner to confirm Seeding
D27 regenerate runs gold SQL through the datasource's adapter. evaluation/gold.py's direct sqlite3 use and its boundary exemption go. decided overnight, owner to confirm Regenerating expected rows
D28 Routing fuses five signals with reciprocal rank fusion (k = 60), keeps the top 3 (candidates defaults to 3), and drops a datasource whose best entry similarity is under half the most similar datasource's: the floor is on similarity, not on the fused score, because the fused scores of a handful of datasources are always within a few percent of each other. A datasource a signal did not rank gets the rank after the last one it did, so having entries of a type (only one datasource with verified queries, say) is not a vote. Only entries the caller's scope allows vote: a hidden table or column, or a verified query that reads a hidden table, never votes or reaches the trace. candidates, max_datasources, max_sub_queries and floor_ratio are settings; the vote depth and the fusion constant stay constants until a measurement asks for a knob. decided overnight, amended after review (twice: the floor on similarity, the absent rank, table scope, after the review of PR #316), owner to confirm Phase R
D29 A decomposition over max_sub_queries ends the run with QUESTION_TOO_BROAD, whose message asks for a narrower question. It is not retried. decided overnight, owner to confirm Limits
D30 Join keys are coerced, not just checked. The engine's aggregator casts both sides of a join key to the declared dtype (in polars, so no dialect is involved), and a value that does not convert is a warning. The validator rejects only categories that cannot be coerced (temporal against number or boolean); an unknown type is not checked. The plan has no CAST, so the validator never asks the model for one. decided overnight, amended after review, owner to confirm The join-key type check
D31 Structural diffs are stored with the new version in the state store (store interface version 2). A rename is reported as a drop and an add. index.check() never re-indexes. decided overnight, owner to confirm Schema drift
D32 Sanity checks: a missing filter value with a close match, and a confirmed fan-out under COUNT (repairable as COUNT(DISTINCT ...)), are retried once. A fan-out under SUM or AVG (the plan has no derived tables to repair it), an all-null column, a missing value with no close match, and any check with no retry left are warnings. Many-to-many sums are one case of JOIN_FAN_OUT, not a code of their own. decided overnight, amended after review, owner to confirm Answer sanity checks
D33 MCP is stdio only in 0.7, behind the mcp extra; the caller's role comes from nl2sql mcp --role, by default policies.default_role, and with neither the server refuses to start, except under policies.allow_all, where it starts and logs a warning that every table is readable; an unknown --role is refused, allow_all included. Over MCP a refusal is always the generic one, whatever refusal_names_tables says. engine.datasources.schema() requires a user_context and hides denied tables (bug B29); there is no default admin view, and the playground's /api/schema requires its role. decided overnight, amended after review (twice: the second after the attack review of PR #325), owner to confirm MCP server
D34 QueryResult.warnings is typed (RunWarning: code, node, message, sub_query_id, detail), replacing the list of dicts. It lands with the sanity checks (S1), the PR that adds warnings, not in the foundation. decided overnight, amended after review, owner to confirm Answer sanity checks
D35 The first PR adds the shared shapes, and only those: the SDK's Event and Observer, the bus, the catalog, RunScope with its publish slots, the registry kinds, the config shapes, the error codes, the new status, stop and approvals_needed result fields, and stub engine.events and engine.evals namespaces. The parallel PRs then never edit config/models.py, common/errors.py, events/run.py or public_api.py's namespace list; the overlaps that remain are listed in the PR plan. decided overnight, amended after review, owner to confirm PR plan

Where 0.6 leaves us

Area What 0.6 has What is still wrong
Events stream_query yields six QueryEvent types from a LangChain callback (pipeline/events.py). Phase 2 returns, but does not publish, the data for six more (phase2.md, section 9). No bus. Nothing outside a streamed question can observe the engine: an index build, a purge, an LLM swap.
Logs TraceContextFilter and trace_context() in common/logger.py trace_context() is never called, so every log line's trace_id is null (the rest of bug B14).
OpenTelemetry Three instruments in common/metrics.py; configure_metrics Configured only in cli/commands/run.py, so nl2sql-api and the playground never export. opentelemetry is a hard dependency, loaded by the runtime through pipeline/plan_cache.py and services/callbacks/token_handler.py (B14).
Audit common/event_logger.py: one llm.changed record per LLM swap A module-global logger that creates logs/audit_events.log in the process's working directory (B27).
Cost TokenUsageCallback prices calls from llm.prices, which is empty by default; evaluation/prices.py holds a dated table that only tier 2 reads A run's usage.cost is null unless the user writes prices (B28). No cap on a question's cost or calls.
Hooks None Nothing can veto or rewrite a step, or ask for a person's approval.
Evals engine.benchmark: tier 1 (gold plans, fake LLM), tier 2 (real model, dollar cap, baseline gate), retrieval recall; chinook_gold.yaml; nl2sql benchmark Chinook only, one fixed file shape, no scorer interface, gold rows produced with sqlite3 outside the adapter, and no way to build a gold set for your own database. Curated answers are off by default in tier 2.
Routing One schema.datasource index entry per datasource (description, domains and every sample question in one text); retrieve_datasource_candidates(k=5); the answerability verdict already returns a list One phrasing dilutes the others; tables and columns never vote; no fusion, no floor, no limit on datasources or sub-queries; TABLE_K, PLANNING_K and k=5 are constants; join keys across datasources are not type-checked.
Drift Immutable snapshots with a structural fingerprint (schema/fingerprint.py), the last max_versions kept, the plan cache keyed on schema_version Health is stale only without a build or after a curated.yaml change. Nothing compares the live database with the build, and no diff between versions exists.
Sanity VALUE_NOT_FOUND: an empty result filtered on a value its column lacks (executor/value_check.py), retried once with the closest values Nothing catches join fan-out, sums over many-to-many joins, or all-null columns. Warnings are untyped dicts.
MCP None

1. Goals and non-goals

What ships in 0.7

Goal What it means
An event bus Synchronous and in process. Typed events for a run and its steps, model calls, hook and policy decisions, index builds, drift, purges, LLM swaps and evals. engine.events.subscribe(); stream_query yields the run's events, each ready to send as a Server-Sent Event.
Observers A registry kind (nl2sql.observers). Built-in log (correlated: every line carries its trace_id), audit and otel (the otel extra). import nl2sql loads no opentelemetry. Observers fail open.
Cost and caps A dated price table in llm/. A cap on each question's cost and on its model calls, ending the run as stopped with a typed reason.
Hooks Six points through a run, a registry kind (nl2sql.hooks), fail closed with a per-hook on_error: open. Modify before the validator, deny only after it, held by an architecture test.
Approval A run that ends needs_approval, and a re-run that carries the approval.
Evals as a product engine.evals, evals.yaml and suite files, five scorers behind a Scorer interface (nl2sql.scorers), regenerate through the adapter, the regression gate, nl2sql eval, and seeding a draft gold set from your own database. The Chinook gold set moves over unchanged.
Phase R Routing on one entry per example phrasing, table and column votes and reciprocal rank fusion; up to 3 datasources and 6 sub-queries per question; retrieval limits as settings; join keys coerced in the aggregator and checked in the validator; routing evals beyond Chinook as the gate.
Schema drift, measured engine.index.check() and nl2sql index --check, a stored structural diff per version, its impact on verified queries and policies, and a schema.drifted event.
Answer sanity checks Join fan-out, many-to-many sums, all-null columns and missing filter values, as typed warnings or one retry.
An MCP server nl2sql mcp over stdio with ask, list_datasources and describe_table, on the public facade only.

Not in Wave A

Later Why it waits
Hooks on every event; an async bus; a stored event log; webhooks; /metrics Cut from the final design. Observers cover what an event is for; a stored log and webhooks are a product of their own.
Masking result values at execute.after D13: deny first, masking once deny is proven
A hook on an index build No use for one yet; events cover observing a build
Repairing a SUM or AVG fan-out The plan has no derived tables; splitting the question in the decomposer is a Wave B idea (D32)
Approval expiry D24: nothing is stored, so there is nothing to expire
An approval queue, signed approvals D10, D24: the run ends and is asked again; nothing is stored
Pluggable result comparators, a built-in LLM judge, an eval scheduler, a leaderboard UI Cut from the final design. A scorer plugin can do the first two.
Automatic re-indexing on drift, scheduled checks Drift is measured first (owner)
MCP over HTTP with auth Wave C
REST routes for evals, approval and drift Wave D (API parity)
Glossary, knowledge sources, clarification results Wave B; see the preview at the end of this page

2. Order of work

One foundation PR, then four tracks that touch different files, then three smaller tracks for whichever agent frees up first.

flowchart LR
  A0["A0. Foundation<br/>event types, bus, run scope, config shapes,<br/>error codes, result fields, registry kinds"]
  A0 --> E["Track 1: events and observers<br/>E1 to E4"]
  A0 --> H["Track 2: hooks and approval<br/>H1, H2"]
  A0 --> V["Track 3: evals<br/>V1 to V3"]
  A0 --> R["Track 4: Phase R routing<br/>R1 to R3"]
  E --> S["Smaller tracks<br/>D1 drift, S1 sanity, M1 MCP"]
  H --> S
  V -->|"R3 needs V1's scorers"| R
  S --> REL["Release 0.7"]
  E --> REL
  H --> REL
  V --> REL
  R --> REL

3. The event bus

The event

# nl2sql_sdk.events (pydantic and the standard library only, D14)
class Event(BaseModel):
    model_config = ConfigDict(frozen=True)
    name: str                    # "node.finished", from the catalog
    at: datetime                 # UTC
    trace_id: str | None = None  # the run's id; None for an engine event (an index build)
    tenant_id: str | None = None
    data: dict[str, Any] = {}    # JSON-ready; the catalog's model for this name, dumped

    def sse(self) -> str:        # "event: <name>\ndata: <json>\n\n"
        ...

class Observer(Protocol):
    interface_version: ClassVar[int]           # 1
    config_model: ClassVar[type[BaseModel]]
    events: ClassVar[frozenset[str] | None]    # the names it wants; None means every event
    @classmethod
    def from_config(cls, cfg: BaseModel, *, folder: Path) -> "Observer": ...   # folder: storage.path
    def on_event(self, event: Event) -> None: ...
    def close(self) -> None: ...

trace_id is the name the facade already uses for a run (QueryResult.trace_id, engine.traces.get, engine.results), so an event, a log line, a trace and a result page all join on one value.

The bus

# nl2sql/events/bus.py
class EventBus:
    def subscribe(self, fn: Callable[[Event], None], names: Iterable[str] | None = None,
                  *, fail: Literal["open", "raise"] = "open") -> Subscription: ...
    def publish(self, name: str, data: BaseModel | Mapping[str, Any] = {}, *,
                trace_id: str | None = None) -> Event: ...
    def close(self) -> None: ...               # closes every observer it built
Rule Detail
Synchronous, in process publish calls each subscriber in subscription order, on the publishing thread, and returns. There is no queue and no worker. A slow observer slows the run; the docs say so, and otel exports in its SDK's own background thread.
One bus per engine Runtime.bus, built with the runtime. Two engines in one process share nothing (the invariant An Engine Owns Its Config).
Observers fail open (D9) An exception from a subscriber is caught, logged once per subscriber and event name at WARNING, and counted (EventBus.failures). The bus never raises into the engine. The internal run collectors subscribe with fail="raise", because a broken collector is an engine bug a test must see.
Thread safety Sub-queries run on LangGraph's worker threads, so publish is called concurrently. The bus holds its subscriber list immutably (copy on subscribe); an observer that keeps state locks it.
Checked names publish refuses a name the catalog does not list (a ValueError: an engine bug), and validates data against the name's model. A test scans every publish("...") call site for catalogued names.

Engine events go through ctx.bus. Code that runs outside a question (an index build, storage.purge, replace_llm, index.check, a datasource check, an eval run) publishes with ctx.bus.publish(...) explicitly; it always holds the runtime.

Run events go through the run's scope. A RunScope (nl2sql/events/run.py) is the life of one question on the bus. Where it sits follows from the code as it is:

  1. It is opened by the caller that builds the result: QueryAPI.run_query and stream_query, and the eval runner. run_with_graph returns graph state; the QueryResult is built afterwards by result_from_state in QueryAPI (api/query_api.py), so only there can run.finished be published with the result's summary. The scope makes the run's trace_id and hands it to run_with_graph, which puts it in GraphState.
  2. Its context is entered inside the worker. run_with_graph submits the graph to its own ThreadPoolExecutor with the caller's copy_context() (pipeline/runtime.py; 0.6 submitted it without one), so the worker inherits the caller's context variables (a request's keys, the run's pinned LLM set), and policy_engine.run() is entered inside _invoke. The scope's context variables (the trace_id, the tenant_id and the scope itself) are entered there too, beside it; LangGraph's own worker threads then inherit them, as they already inherit current_datasource_id. Code deep in a run (the structured-output retry in llm/wires/, the hook runner, the budget, the enforcement points) publishes with the module function nl2sql.events.publish(name, data), which reads the scope from the context and is for code inside a RunScope only: outside one it does nothing. The same context variable is what TraceContextFilter reads, which fixes the null trace_id on every log line (B14).
  3. It closes exactly once. run.finished (with the summary) when QueryAPI has the result, or run.failed when run_with_graph raised or the result could not be built. Both are published on the bus, so the audit observer and the otel run span see a crashed run too.
  4. A closed scope drops what comes after. A timed-out run is answered while its graph winds down in the background (run_with_graph's TimeoutError branch); the llm.call and node.* events it publishes after run.finished are dropped and counted (RunScope.late_events), so no observer sees a run continue after it ended. The usage in run.finished is what was spent until the answer; the docs say a timed-out run may spend a little more.

RunScope is also where the run-scoped pieces attach and detach (RunScope.attach): the stream's queue, the usage collector, the budget, and the trace's event list. It is the one place later PRs plug a run-scoped piece into, so they do not each edit run_with_graph or QueryAPI.

The catalog

nl2sql/events/catalog.py lists every event the engine publishes, with a pydantic model for its data. engine.events.catalog() returns it (name, description, the data's JSON Schema), so an observer author and the docs page read the same list. Events never carry result rows, sample values, keys or header values (D15); the question and the answer text are carried and may contain values from the data. test_no_bus_event_carries_rows runs a demo question with a capturing subscriber and checks that no rows key appears at any depth of any event.

Run events (they have a trace_id):

Name Data Published by
run.started question, user_id, tenant_id, roles, datasource_id, execute RunScope, when it opens
run.finished A summary, never rows: status (success, plan_only, error, needs_approval, stopped), duration_s, usage totals with cost, error_codes, source (verified, generated), datasources used, tables read (datasource.schema.table), result_manifest, user_id, roles RunScope, once, last
run.failed message, error_code RunScope: the run could not start, or crashed outside the pipeline (today's stream error event)
node.started node, sub_query_id The LangChain callback in pipeline/events.py
node.finished node, sub_query_id, seconds, ok The same
answer.delta field (summary, content), text The same
result.manifest results (ResultSummary list: columns, row counts, truncated) The same, after the aggregator
llm.call node, agent, model, the six token counts, latency_s, cost, usage_reported, error (redacted) TokenUsageCallback, per finished call
llm.parse_retry agent, schema, attempt, errors (validation messages only) structured() in llm/wires/__init__.py (phase2.md, section 9)
plan_cache.lookup datasource_id, hit pipeline/plan_cache.py (replaces its OpenTelemetry counter)
policy.decision datasource_id, schema, table (None for a datasource), rule, step; denials only (D16) The enforcement points only: LogicalValidatorNode._validate_policy, CuratedMatcherNode._check_access and the resolver's datasource refusal (phase2.md, section 9)
hook.decision hook, point, decision (allow, modify, deny, needs_approval, approved, error), reason, sub_query_id, approver (for approved), seconds The hook runner
run.limit_reached limit (max_cost_usd, max_llm_calls), value, spent The budget
sanity.flagged code, sub_query_id, retry The sanity checks

Engine events (no trace_id, except where a run caused them):

Name Data Published by
datasource.failed datasource_id, stage, message, fix Runtime.check_datasource and try_datasource (phase2.md, section 9)
index.started datasource_id, namespace indexing/rebuild.py
index.column_failed datasource_id, table, column, stage, message the orchestrator, one per IndexIssue (phase2.md, section 9)
index.swapped datasource_id, namespace, build_id, schema_version, replaced_build_id, replaced_schema_version, counts IndexingOrchestrator._commit (phase2.md, section 9)
index.failed datasource_id, reason indexing/rebuild.py
schema.drifted datasource_id, from_version, to_version (None for the live database), detected_by (check, build), the diff's counts index.check() and the orchestrator
retention.purged counts per slot engine.storage.purge() (phase2.md, section 9)
llm.changed by, changes (old and new provider and model per agent; never a key) Runtime.replace_llm (configuration_v2.md, section 7)
eval.case suite, case_id, role, pass, source, scores, cost engine.evals.run
eval.finished suite, cases, accuracy, accuracy_generated, cost, gate engine.evals.run

The run's stream

engine.stream_query yields Events (D17). QueryEvent, its type literal and the names node_started, node_finished, result_manifest, answer_delta, final and error are deleted. event.sse() is the event as one Server-Sent Event, which is all nl2sql-api's POST /query/stream sends.

The stream is the caller's, so it carries only what the caller may see. It yields its own run's events from an allow-list, in publish order: run.started, node.started, node.finished, answer.delta, result.manifest, sanity.flagged, run.limit_reached, and then exactly one of run.finished or run.failed. policy.decision would tell a denied user which table exists (the validator names no table unless policies.refusal_names_tables is set, for exactly that reason), and hook.decision and llm.call are operator data (hook names and reasons, models and cost). Those reach an operator through engine.events.subscribe only. test_stream_never_yields_policy_or_hook_or_llm_call_events holds it.

The terminal event holds the result, on the stream only. The stream's run.finished is the bus event's summary plus data.result, the QueryResult as JSON (as final holds it today). QueryAPI adds the result to the stream's copy; the event published on the bus never has it (D15).

0.6 0.7
QueryEvent(type="node_started", data={"node"}) Event(name="node.started", data={"node", "sub_query_id"})
answer_delta answer.delta
result_manifest result.manifest
final (the QueryResult as JSON) run.finished, whose data.result is the QueryResult as JSON (stream only)
error run.failed
(none) run.started, sanity.flagged, run.limit_reached

The CLI's nl2sql run, the playground and nl2sql-api move to the new names in the same PR.

The facade

Method What it does
engine.events.subscribe(fn, names=None) -> Subscription A function (or an Observer) called for every event, or for names: the operator's view of every run and of the engine. Subscription.close() detaches it. Fails open.
engine.events.catalog() -> list[EventInfo] Every event name, its description and its data's JSON Schema
engine.stream_query(...) -> Iterator[Event] As above

Event is exported from nl2sql (from nl2sql_sdk.events, lazily).

Built: the event, the bus, the catalog and the run scope (A0)

nl2sql_sdk/events.py holds Event (with sse(), whose data line is the whole event as JSON) and the Observer protocol. nl2sql/events/ holds EventBus (bus.py; publish also takes tenant_id), the catalog with a strict model per event and catalog() returning EventInfo (catalog.py, which also defines RunStatus), and RunScope, current_scope() and the module publish (run.py). Runtime.bus is built with the runtime and closed with it. QueryAPI.open_scope(question, datasource_id=, execute=, user_context=) makes the scope, with the run's usage collector, and calls start() (which publishes run.started); QueryAPI.close_scope(scope, result) closes it with finish(result, tables=...), or fail(message, code) when the summary cannot be built, and a result built without a graph run is accepted. run_query and stream_query open their own scope, or take an open one as scope= (internal; Wave C's turn opens it before its rewrite call) and close it. RunScope.callbacks() is the usage collector and the LangChain handlers attached (the budget, from E3): a model call made in the run but outside the graph passes them, inside entered(), so it counts in the run's usage and carries its trace_id. run_with_graph takes scope=, uses its trace_id and its usage collector, enters it beside policy_engine.run() in _invoke (submitted with the caller's copy_context()) and adds its callbacks() to the graph's. That is all of Wave C's A2 need (wave_c.md); E1 keeps scope= when it moves stream_query to Events. The scope sets the context TraceContextFilter reads, so log lines written during a run carry its trace_id and tenant_id. attach takes a function or an object with on_event (subscribed to this run's events with fail="raise"), a LangChain callback handler (added to the graph's callbacks) and anything with close (closed with the scope); a closed scope refuses attach with RuntimeError. Closing detaches every subscription and calls every close even when an attached piece raised on the terminal event. run_query and stream_query close the scope with run.failed on any BaseException (a KeyboardInterrupt too) and re-raise it. The scope holds its lock while the bus delivers each of its events, so run.finished is always last; the cost is that a run's events reach subscribers one at a time and finish (a timed-out run's too) waits for the delivery in flight, so an observer must never block (the Observer and RunScope docstrings say so; E2's audit and otel hand slow work to a queue). run.finished's usage and result_manifest copy only the catalog models' own fields, so a field added to UsageTotals or ResultSummary never fails a run. run.finished's tables come from each sub-query's plan, or a verified query's SQL, as datasource.schema.table. engine.events and engine.evals are empty namespaces; E1 adds subscribe and catalog.

Built: run events, the stream and engine.events (E1)

pipeline/events.py's RunEvents(publish) is a LangChain callback that publishes node.started, node.finished (each with its sub_query_id, from the sub-query subgraph's subgraph_id and inherited by the runs inside it), result.manifest (results only; the event's trace_id is the request_id) and answer.delta. QueryAPI.open_scope attaches one to every scope (scope.attach(RunEvents(scope.publish))), so every run publishes its steps, streamed or not, and scope.callbacks() is now the usage collector, the RunEvents, then whatever was attached. QueryEvent is deleted, with its export. The allow-list is one definition: each catalog entry (EventSpec) has stream and audit flags, and nl2sql.events.STREAM_EVENTS (D17) and AUDIT_EVENTS (D19, which E2's audit observer reads; of hook.decision it keeps the decisions the entry's description names) are derived from them; EventInfo reports both. A later event joins a list by its own catalog entry (Wave B's events stay off the stream). The stream: QueryAPI.stream_query(..., scope=None, names=STREAM_EVENTS) yields Events. RunScope.follow(fn, names) replays what the scope already published (RunScope.events, a new list of every event the scope published, in order) and attaches fn, both under the scope's lock, so an event published meanwhile is neither lost nor doubled; the stream follows its scope with names minus run.finished, and puts its own run.finished: the summary close_scope (which now returns the published event) returned, plus data.result. So a scope opened before the stream (Wave C's turn, need A2) still gives the stream its run.started and anything the turn published in it, and names (internal) is where Wave C's conversations.stream adds conversation.rewritten (need A4); it appends conversation.turn after the stream's run.finished itself. The stream ends with exactly one terminal event whatever breaks: when no run.failed reached it (the scope could not open, was already closed, or a collector raised on its terminal event), it ends with a run.failed built in place, with the scope's trace_id when there is a scope. A KeyboardInterrupt ends it the same way. scope= is kept on run_query and stream_query; a closed scope= is a ValueError at the call. engine.events.subscribe(fn, names=None) subscribes to Runtime.bus (fail open) and catalog() returns catalog(). The trace document has events (the scope's events but answer.delta); since run_with_graph writes the trace before run.finished is published, the list ends before it. That reads scope.events in pipeline/runtime.py (one argument to write_run_trace), not tracing/recorder.py, a small difference from the file list above. nl2sql run, the playground (app.py and web/playground/src/stream.js, rebuilt) and nl2sql-api's stream route read the new names; each SSE data line is the whole event. The invariant Events Carry No Rows, and the Stream Carries No Operator Data is in invariants.md.

Built: the Phase 2 events, published (E4)

Engine events go through ctx.bus explicitly, never a context variable, because an index build and a purge run outside any RunScope; each carries the engine's tenant_id. rebuild_index wraps its on_datasource callback, so every IndexProgress it reports started publishes index.started (with ctx.namespace) and every failed one index.failed (a datasource that is unavailable, unknown, locked out by another build, or whose build raised). IndexingOrchestrator keeps ctx.bus and ctx.tenant_id: index_datasource publishes one index.column_failed per IndexIssue before the commit (table, column, stage, message: the issue, never the column's statistics or sample values, D15), and _commit publishes index.swapped after the pointer switch, with counts by entry type and the replaced pointer's build_id and schema_version (None on a first build), so a curated-only refresh publishes it too. Runtime._published(status) publishes datasource.failed for a failed DatasourceStatus; check_datasource returns through it, and try_datasource is now _published around _try_datasource (the old body), which is the whole runtime.py change. StorageAPI.purge publishes retention.purged with the counts it returns (audited, so the audit observer writes it to the current day's file after deleting the old ones). Run events use the module publish: structured()'s retry loop publishes llm.parse_retry before each retry (attempt 1 is the first retry; errors are pydantic's path: message lines, or the exception's type name, never the model's answer). policy.decision is published at the three enforcement points only (D16 amended): LogicalValidatorNode._validate_policy per refused table (step: logical_validator, schema the table's or the scope's default), CuratedMatcherNode._check_access on the refused table (step: curated_matcher; it now asks scope.rule_for, which allows wraps), and the resolver when the role check refuses every datasource it was asked about: every registered one before the routing search (R1's scope filter), or the explicit or only one (_publish_refusals, one per datasource, schema and table None, step: datasource_resolver, rule naming the roles or the policy check). The resolver's re-check after datasources.after hooks publishes nothing; the hook's own hook.decision covers it. After review: datasource.failed (message, fix), index.failed and index.column_failed run their text through nl2sql.tracing.trace.redact_for(ctx, text) (the trace's Redactor with collect_secrets(ctx); its patterns now also catch a bare user:password@host). For the values and profile stages, index.column_failed's message is a fixed sentence plus the new IndexIssue.error_type (SDK, optional, set by the SQLAlchemy base), so a cast error quoting a cell value never reaches the bus. The rule comes from the new Scope.datasource_rule() (reaches_datasource is datasource_rule() is None), which names a failed policy check, and the validator publishes once per (schema, table, rule). Test fakes that build an orchestrator or call rebuild_index need bus and tenant_id (tests/unit/index_helpers.py's TestIndex has them). Tests: tests/unit/test_phase2_events.py.

4. Observers

The registry kind and the config

Kind Entry point group Interface Version Built-ins
observer nl2sql.observers nl2sql_sdk.events.Observer 1 log, audit, otel

registry.add_observer(name, cls), @registry.observer(name). An observer is "attached, many" (configuration_v2.md, section 6): the config lists any number.

# nl2sql.yaml
observers: observers.yaml

# observers.yaml
- type: log
  level: INFO                     # node.started and answer.delta are logged only at DEBUG
- type: audit
  max_age_days: 90
- type: otel                      # needs nl2sql-engine[otel]
  endpoint: http://collector:4317
  metrics: true
  traces: true
- type: corp_siem                 # a plugin; its config_model checks the rest
  url: "${env:SIEM_URL}"
from nl2sql import Config, ObserverConfig
Config(..., observers=[ObserverConfig(type="log"), MyObserver()])   # a {type: ...} block or an object
Rule Detail
No section observers absent means [{type: log}, {type: audit}] (D18). observers: [] means none.
Objects skip the registry As every other kind: an Observer object is used as given.
Validation type is resolved and the block validated by the observer's config_model at NL2SQL(...), with the usual "did you mean". otel without the extra installed is a ConfigError naming pip install "nl2sql-engine[otel]".
Built when the engine is Subscribed to Runtime.bus in config order, and closed by engine.close().
settings.observability Deleted. exporter: otlp and otlp_endpoint become an otel block. Its two readers go with it in A0: cli/commands/run.py (configure_metrics) and cli/common/engine.py (JSON logs under otlp). Between A0 and E2 nothing exports to OpenTelemetry; no release falls in between.

Built: the observer kind and the section (A0)

The observer registry kind (add_observer, @registry.observer) and the observers section (ObserverConfig, or an object with on_event) are accepted and validated; nothing builds an observer yet (E2). Config.observers is None when the section is absent, which E2 reads as log and audit. settings.observability and its readers (nl2sql run's configure_metrics, the CLI's and nl2sql-api's switch to JSON logs) are gone. nl2sql --log-format text|json and NL2SQL_LOG_FORMAT choose the log format; configure_logging(json_format=None) reads the variable.

Built-in observers

Observer What it does Keys
log One log line per event on logger nl2sql.events: the name and a short summary of data. node.started and answer.delta are logged only when level is DEBUG, so the default adds about one line per step, not one per token. The trace_id reaches the line through the context, so with configure_logging(json_format=True) every line, the engine's own included, carries it. level (INFO), events (a list; by default all but the two above)
audit JSON lines in <storage.path>/audit/<date>.jsonl. One summary line per run.finished and run.failed (trace_id, user, roles, datasources, tables read, status, source; never rows), so the log answers "who read payroll.salaries" without a line per allowed table. And one line per llm.changed, hook.decision with deny, needs_approval, approved or error, policy.decision, run.limit_reached, index.swapped, retention.purged and schema.drifted. Replaces common/event_logger.py (B27, D19). engine.storage.purge() deletes files older than max_age_days. max_age_days (90, at least 1)
otel Metrics: nl2sql.node.duration, nl2sql.token.usage and nl2sql.plan_cache.lookups (as today, from node.finished, llm.call and plan_cache.lookup), and nl2sql.run.cost. Traces: one span per run (run.started to run.finished) with a child span per node, keyed by trace_id and sub_query_id. Sets its own MeterProvider and TracerProvider for its exporter rather than the process-global ones, so two engines can export to two collectors. endpoint (http://localhost:4317), exporter (otlp, console), metrics, traces, service_name (nl2sql)

opentelemetry-api, -sdk and -exporter-otlp move from the engine's dependencies to the otel extra. Only nl2sql/observers/otel.py imports them, and only when the observer is built. common/metrics.py is deleted: plan_cache.py and token_handler.py publish events instead of adding to counters.

Built: the observers (E2)

nl2sql/observers/ holds build_observers (__init__.py, with DEFAULT_OBSERVERS, log then audit), log.py (LogObserver, LogConfig), audit.py (AuditObserver, AuditConfig) and otel.py (OtelObserver, OtelConfig), declared in the nl2sql.observers entry point group. Runtime.components builds them after every other component (a problem is one more line of the one ConfigError, and a built observer is closed again), keeps them as Runtime.observers and subscribes them to Runtime.bus in config order; engine.close() closes them through the bus. The registry checks an observer's requires like an LLM provider's, so otel without the extra is a ConfigError ending in pip install "nl2sql-engine[otel]". What is audited is the catalog's: EventSpec.audit, and a new EventSpec.audit_only (a field name to the values written; hook.decision's is decision: deny, needs_approval, approved, error), applied by EventSpec.audited(data) and reported in EventInfo.audit_only. The observer reads the audited names from the catalog when it is built, so Wave C's datasource.saved, api_key.created, api_key.revoked and mcp.denied join the log with audit=True on their own entries. run.failed gained user_id and roles (two lines in RunScope.fail), so a failed run's line names its user too. The audit observer queues each line and writes on its own daemon thread, started by the first audited event, opening and closing the day file per batch. Its queue is bounded (MAX_QUEUE, 10,000): a line that does not fit is dropped, counted in dropped and warned about once. close writes what is queued, and what arrived while it closed; an atexit hook (through a weak reference, unregistered by close) does the same for a process that never closes its engine. RunScope.fail passes message through redact_keys (D15). engine.storage.purge() adds audit (day files older than max_age_days) when the engine runs an audit observer. otel records in memory on its own MeterProvider and TracerProvider (a BatchSpanProcessor, a PeriodicExportingMetricReader) and never sets the global ones, and close bounds the providers' flush at 5 seconds; nl2sql.token.usage lost its datasource_id attribute, which llm.call does not carry. TokenUsageCallback publishes llm.call per finished call through nl2sql.events.publish (with the client's nl2sql_agent metadata as agent), PlanCache.get publishes plan_cache.lookup, and Runtime.replace_llm publishes llm.changed on ctx.bus with the engine's tenant_id; common/metrics.py and common/event_logger.py are deleted, and opentelemetry is the otel extra (CI's unit and integration jobs install it). One test differs from the plan: test_building_an_engine_loads_no_opentelemetry cannot hold, because chromadb (a core dependency) installs the OpenTelemetry SDK and langsmith, under langchain, imports it whenever it is installed; test_building_an_engine_loads_no_otel_observer checks what the engine controls, beside the two AST tests on nl2sql/observers/otel.py. The page is Observers.

The run collectors

Three things the engine already builds for every run become run-scoped bus subscribers attached by RunScope, not configurable observers, since the result depends on them:

Collector Fills Today
Usage QueryResult.usage (tokens, calls, cost) and the budget TokenUsageCallback keeps its LangChain hooks (the only place usage is visible) and publishes llm.call (E2, which deletes the OpenTelemetry counter it fed); the roll-up reads the events (E3)
Timings QueryResult.timings NodeTimingCallback, from node.finished
Trace The trace document's new events list (every run event but answer.delta) TraceRecorder keeps the per-node records; the events are added beside them

Logs with correlation (B14)

  • RunScope sets the run's trace_id and tenant_id in the context variables TraceContextFilter reads; stream_query's thread already copies the caller's context, and so does every LangGraph worker.
  • configure_logging stays an entry point's call (nl2sql, nl2sql demo, nl2sql-api, nl2sql mcp), as the invariant on process globals says: logging handlers are global by nature. It installs the filter on its handler, as today. A library caller who configures logging their own way adds nl2sql.TraceContextFilter to their handler; the docs say so.
  • cli/common/engine.py stops switching to JSON logs when OpenTelemetry is configured; --log-format json (and NL2SQL_LOG_FORMAT) chooses it for any command. NL2SQL_LOG_FORMAT joins READS_THE_ENVIRONMENT.

Built: log correlation (E1)

run_query and stream_query's thread run inside scope.entered() as well as the graph's worker, so every log line written during a run, on the caller's thread or a LangGraph worker thread, carries its trace_id and tenant_id (test_every_log_line_in_a_run_has_its_trace_id, test_log_lines_on_worker_threads_have_the_trace_id). docs/observability/events.md shows a library caller adding TraceContextFilter.

5. Cost, the price table and per-question caps

The price table

evaluation/prices.py moves to nl2sql/llm/prices.py (D11). It keeps its shape: PRICES_CHECKED_ON, and per model input, cached_input, cache_write, output (USD per million tokens), verified and source.

# nl2sql/llm/prices.py
PRICES_CHECKED_ON = "2026-09-21"
PRICES: dict[str, ModelPrice] = {...}            # dated and sourced; updated by hand

def price_for(model: str, overrides: Mapping[str, Price]) -> ModelPrice | None:
    """The config's llm.prices row, else the table's, by exact name; None when neither has it."""
Rule Detail
Overrides llm.prices (Phase 1's field) adds a model or replaces a row. A negotiated rate or a fine-tune goes there.
Exact names No prefix matching (gpt-5.4 must not price gpt-5.4-mini). A call is priced by the model name the provider reported, then by the name configured, as token_handler._cost does today, so a dated variant the provider reports (gpt-5.4-2026-03-05) is priced by its configured name.
Unknown model Cost None, for the call and for any total that includes it. Never zero.
Facade engine.llm.prices() returns the effective table: checked_on, each row with its source (table or config), so a UI can show "prices as of 2026-09-21".

Evals keep tier 2's rule: a run with max_cost_usd refuses to start if a model it would call has no price.

Per-question caps

# settings.yaml
pipeline:
  timeout_s: 60
  max_llm_calls: 60       # model calls per question, parse retries included; null: no cap
  max_cost_usd: 0.50      # dollars per question; null (the default): no cap
Rule Detail
Defaults (D20) max_llm_calls: 60, max_cost_usd: null. 60 covers the largest legitimate run: resolver, decomposer, six sub-queries each with a planner call and three refiner and planner retries, the answer, and parse retries.
Enforced before each call, through the token The budget is a run-scoped callback (nl2sql/limits.py, attached by RunScope). On on_chat_model_start it compares the usage collector's totals with the caps. When the next call would exceed the call cap, or the spent cost has reached the cost cap, it cancels the run's CancellationToken with a reason, RunStop(limit, value, spent), and then raises, as _StopWhenCancelled does. Raising alone is not enough: the nodes wrap their model calls in except Exception (the planner turns any failure into a retryable PLANNING_FAILURE, and the decomposer and answer synthesizer catch too), so the raise would be retried and end as a planner error. The cancelled token is what ends it: the SQL agent's routers check it before every retry and end the sub-query, and _StopWhenCancelled refuses every later step and call. CancellationToken.cancel(reason=None) gains the optional reason.
Overshoot The call in flight finishes, so a run can overshoot the cost cap by the calls in flight: at most one per running sub-query. The docs say so.
A cap needs prices With max_cost_usd set, every model the config names must have a price; otherwise NL2SQL(...) raises a ConfigError listing them, with the fix "add llm.prices.". Since a call falls back to its configured name, every call of such an engine is priced.
How the run ends run_with_graph already maps a cancelled token to its cancelled outcome; a token cancelled with a RunStop reason maps instead to QueryResult.status = "stopped", QueryResult.stop = RunStop(limit, value, spent), an error RUN_LIMIT_REACHED (not retryable) whose message names the limit and the setting, and the answer "This question reached its limit of 60 model calls (settings.pipeline.max_llm_calls)." run.limit_reached is published, and the audit observer records it. test_cap_hit_inside_the_planner_ends_stopped_not_planning_failure holds it.
Evals An eval run's own max_cost_usd is across the run, as tier 2's is; the per-question caps apply inside it too.

Built: the price table, cost and caps (E3)

nl2sql/llm/prices.py holds PRICES_CHECKED_ON, PRICES (the five rows moved unchanged), ModelPrice (a NamedTuple: input, cached_input, cache_write, output, verified, source; a config row has source="config" and may leave the two cache rates None), price_for(model, overrides) (the llm.prices row, else the table's, by exact name), call_cost(price, *, input_tokens, output_tokens, cached_input_tokens=None, cache_write_input_tokens=None, cache_write_multiplier=1.0) (None when the input or output count is unknown; a cache count not reported is not split out of input; a config row's missing cache_write is input times the wire's multiplier, so Anthropic's 1.25x from #193 holds) and effective_prices(overrides). evaluation/prices.py is deleted with no alias; evaluation/run.py's check_prices(configs, prices=None) and question_cost(usage, cfg, prices=None) take llm.prices as overrides (run_evals passes the engine's), and the UnknownPriceError fix names llm.prices. TokenUsageCallback(prices=llm.prices) prices every call with price_for by the served name, then the configured one, and gained spent() (the dollars of the calls with a known cost). Unknown counts are None, never 0 (an owner requirement after the design): the wires' usage() takes Optional counts (opt_int, sum_known in wires/base.py; as_int is gone), so an unreported cache read, cache write (OpenAI reports none) or reasoning count is None, and a call with no usage (a failed call) has every count None and a null cost. LLMCallUsage, UsageTotals and the catalog's LLMCall and UsageTotalsData have Optional[int] token fields; a total is the sum of what was reported, None when nothing was; the otel observer skips a None count. engine.llm.prices() returns {"checked_on", "models": {name: {input, cached_input, cache_write, output, verified, source, citation}}} (source is table or config; citation the table row's source). nl2sql/limits.py holds Budget, LimitReached, describe and stop_answer beside RunStop. Budget(max_llm_calls=None, max_cost_usd=None, usage=None, token=None, setting="settings.pipeline") is a LangChain callback (raise_error = True): on on_chat_model_start and on_llm_start it refuses the call when the calls it let start have reached max_llm_calls or usage.spent() has reached max_cost_usd; on the first refusal it sets budget.stop, cancels every bound token with the RunStop (CancellationToken.cancel(reason=None) keeps the first reason in token.reason), publishes run.limit_reached through the module publish (nothing outside a run), and raises LimitReached(stop, setting) (not a ValueError, so the parse-retry loop never swallows it); every later call is refused with the same stop. Work that is not a question (Wave B's profiler, V3's seeding) opens its own Budget(..., setting="evals"), makes its calls with budget.callbacks() (the budget, then its usage collector), and reads budget.stop or catches LimitReached. Budget.for_question(pipeline, usage) is None when both caps are off. The budget reads the usage collector directly, not the llm.call events, because a budget outside a question has no scope to read events from (a difference from the file list's "usage collector on llm.call"). QueryAPI.open_scope attaches the question's budget after the RunEvents, so scope.callbacks() is the usage collector, the RunEvents, the budget, then anything attached later; run_with_graph binds every attached Budget to the run's token (budget.bind(token)), and a token cancelled with a RunStop maps to _stopped: stop, the one error RUN_LIMIT_REACHED ("The question reached its limit of 60 model calls (settings.pipeline.max_llm_calls)."), the answer "This question reached its limit of 60 model calls (settings.pipeline.max_llm_calls)." (or "its cost limit of $0.50 (settings.pipeline.max_cost_usd)"), and the trace outcome stopped. Runtime.components and Runtime.replace_llm raise a ConfigError per unpriced model (llm.model, llm.agents.<agent>.model; a model object by its model_name) when max_cost_usd is set, with the fix add llm.prices.<model> .... Tests: tests/unit/test_cost_and_caps.py. After review (PR #326): a call is priced by price_for_call(served, configured, overrides), every llm.prices override (served name, then configured name) before the table for either name; Price's four rates are NonNegativeFloat; a timeout that fires while a budget stop unwinds reports stopped; mode full counts a call with no token counts in EvalReport.unknown_cost_calls (new unknown_cost_calls(usage), one warning per run) instead of adding 0; the OpenAI rows are marked for the owner to re-verify.

6. Hooks

A hook is code the operator registers that sees a run at fixed points and may let it continue, change what it carries, stop it, or ask for approval. No hook ships built in (D22).

The six points

flowchart TD
  Q["question"] --> P1(["run.before"])
  P1 --> CM["curated matcher"]
  CM -->|"no match"| RES["datasource resolver"]
  RES --> P2(["datasources.after"])
  P2 --> DEC["decomposer"]
  DEC --> PL["planner, or the plan cache"]
  PL --> P3(["plan.after"])
  P3 --> VAL["logical validator"]
  VAL --> GEN["generator"]
  GEN --> P4(["execute.before"])
  CM -->|"verified query"| P4
  P4 --> EX["executor"]
  EX --> P5(["execute.after"])
  P5 --> AGG["aggregator"]
  AGG --> ANS["answer synthesizer"]
  ANS --> P6(["answer.after"])
  P6 --> R["QueryResult"]
Point When The hook sees It may answer Called from
run.before Once, before the first step question, user, datasource_id, execute allow, modify (question), deny run_with_graph's _invoke in pipeline/runtime.py, before graph.invoke (the events package never depends on hooks)
datasources.after The resolver chose its datasources, before the decomposer + datasource_ids allow, modify (datasource_ids: a subset only), deny DatasourceResolverNode
plan.after Per sub-query and attempt: a plan from the planner or the plan cache, before the validator + sub_query, plan allow, modify (plan), deny ASTPlannerNode, on both paths
execute.before Per sub-query: validated, generated SQL, or a verified query's SQL with its bound values, before the adapter runs it + datasource_id, sql, params, tables allow, deny, needs_approval ExecutorNode and CuratedMatcherNode
execute.after Per sub-query, after it ran + result: the ResultSummary (columns, row count, truncated); never rows allow, deny ExecutorNode and CuratedMatcherNode
answer.after The answer is written, before it is returned + final_answer, the sub-query summaries allow, deny AnswerSynthesizerNode
  • Modify before the validator, deny only after (D21, D13). A plan a hook changed at plan.after is validated like any other, so a hook can never smuggle in what the validator forbids, including a table the role may not read. After the validator nothing may change the SQL, the plan or the result; a hook may only stop it. datasources.after may only remove datasources: the resolver re-applies the scope check to what the hook returns, and an added id is a hook error.
  • The verified path has no routing and no plan, so only run.before, execute.before, execute.after and answer.after apply. A deny there ends the run; it does not fall back to the generated path, because a deny means no.
  • A plan-only run (execute=False) never reaches the execute.* points.
  • Deny scope. A deny at a per-sub-query point (plan.after, execute.before, execute.after) may name scope="sub_query", which drops that sub-query (the answer says so) and lets the others continue; the default is scope="run". This is the only narrowing after the validator. A sub-query that feeds a join or compare combine group cannot be dropped alone, since the group would have one side: the whole group is dropped, with its post-combine operations, and the answer says which part of the question went unanswered and why.
  • The plan cache keeps the planner's plan. graph_utils caches the planner's response after a clean execution. It stores the plan the planner (or the cache) produced, before any plan.after hook changed it, and plan.after runs on every cache hit. So a hook's change is never cached, is applied fresh on each run, and stops applying the moment the hook config changes. test_cached_plan_is_the_planners_not_the_hooks holds it.

The interface

# nl2sql/hooks/__init__.py (engine: the context holds engine types, D14)
HookPoint = Literal["run.before", "datasources.after", "plan.after",
                    "execute.before", "execute.after", "answer.after"]

class HookContext(BaseModel):          # frozen; fields beyond the point's are None
    point: HookPoint
    trace_id: str
    question: str
    user: UserContext
    datasource_id: str | None
    datasource_ids: list[str] | None
    sub_query: SubQuery | None
    plan: PlanModel | None
    sql: str | None
    params: dict[str, Any] | None
    tables: list[str] | None           # datasource.schema.table, read by the SQL
    result: ResultSummary | None
    final_answer: dict | None
    approvals: list[Approval]          # what the run was re-run with (section 7)

class Allow(BaseModel): ...
class Modify(BaseModel):  question: str | None = None; datasource_ids: list[str] | None = None; plan: PlanModel | None = None
class Deny(BaseModel):    reason: str; scope: Literal["run", "sub_query"] = "run"
class NeedsApproval(BaseModel): reason: str
Decision = Allow | Modify | Deny | NeedsApproval

class Hook(Protocol):
    interface_version: ClassVar[int]           # 1
    config_model: ClassVar[type[BaseModel]]
    points: ClassVar[frozenset[HookPoint]]     # where it may be attached
    @classmethod
    def from_config(cls, cfg: BaseModel) -> "Hook": ...
    def __call__(self, ctx: HookContext) -> Decision: ...

Decisions and the runner

nl2sql/hooks/runner.py holds HookRunner.call(point, ctx) -> Decision, built once per engine from the config, and the table of points (HOOK_POINTS: each point's allowed decisions and the fields Modify may set there).

Rule Detail
Order (D23) Hooks run in config order. A Modify replaces the fields it sets, and the next hook sees the result. The first Deny, or NeedsApproval without a matching approval, ends the point; later hooks are not asked. A NeedsApproval the run's approvals cover counts as Allow, and the next hook still runs (D24).
Fails closed (D9) An exception, a return value that is not a Decision, or a decision the point does not allow (a Modify at execute.before, a NeedsApproval at plan.after) is a hook error, which denies with HOOK_FAILED and the hook's name. With on_error: open on that hook, an exception is logged and treated as Allow; a disallowed decision still denies, because it is a bug in the hook, not an outage.
Timeouts The hook owns them, as a PolicyCheck does; the pipeline's timeout_s bounds the run anyway.
Events Every call publishes hook.decision; the audit observer keeps the denials, approvals and errors.
Errors Deny becomes a PipelineError HOOK_DENIED (CRITICAL, not retryable) whose message is the hook's reason, and whose details name the hook and the point. A run-scoped deny ends the run with status: "error".

The config

# nl2sql.yaml
hooks: hooks.yaml

# hooks.yaml (a list; order matters)
- type: corp_sql_review           # a registered hook
  points: [execute.before]        # a subset of the hook's own points; all of them by default
  on_error: closed                # closed (the default) | open
  approver_roles: [dba]           # who may approve its needs_approval; default policies.admin_roles
  url: "${env:REVIEW_URL}"        # the hook's own keys, checked by its config_model
- type: question_rewriter
  points: [run.before]
from nl2sql import Config, HookConfig
Config(..., hooks=[HookConfig(type="corp_sql_review", points=["execute.before"]), MyHook()])
Kind Entry point group Interface Version Built-ins
hook nl2sql.hooks nl2sql.hooks.Hook 1 none

registry.add_hook(name, cls), @registry.hook(name). A point the hook does not declare is a ConfigError at build.

Built: the hook kind and the section (A0)

The hook registry kind and the hooks section are accepted and validated: HookConfig (points, on_error, approver_roles, and the hook's own keys) or an object with __call__. HookPoint, the six point names, is defined in nl2sql/config/models.py, where the config and the hook.decision event both read it, and re-exported from nl2sql.hooks, where H1 imports it.

The architecture test

test_hooks_modify_only_before_the_validator holds the boundary three ways:

  1. HOOK_POINTS allows Modify at run.before, datasources.after and plan.after only.
  2. An AST scan finds every hooks.call("<point>", ...) call site: a point that allows Modify may be called only from pipeline/runtime.py, datasource_resolver/node.py and ast_planner/node.py, and the generator, executor, curated matcher, aggregator and answer synthesizer may call only points that do not.
  3. The SQL agent graph, built with fakes, has exactly one path from ast_planner to generator, and it passes logical_validator.

Built: the runner, the six points and the boundary test (H1)

nl2sql/hooks/: the interface in __init__.py (HookContext, Allow, Modify, Deny, NeedsApproval, Decision, Hook; HookPoint is re-exported from nl2sql.config.models), points.py (HOOK_POINTS, a PointRule per point: decisions, modifies, per_sub_query) and runner.py. The shapes differ from the sketch above in four places:

  • HookRunner(bindings) takes a list of HookBinding(name, hook, points, on_error, approver_roles) from the runtime (Runtime._build_hooks, build_hook per entry, in config order) and reads no config, so a built-in hook (Wave B's policy.cost) goes first with its own approver_roles. ctx.hooks is the engine's runner; hooks_of(ctx) gives NO_HOOKS for a stand-in runtime.
  • HookRunner.call(point, **fields) builds the context from deep copies of fields (a hook changing the plan or the caller in place changes nothing) and returns a HookVerdict: ctx after every Modify, modified, and the decision and hook that stopped the point (allowed, scope, error(node), the PipelineError; a hook error's message names only the hook and the point). With no hook at a point, nothing is validated. HookRunner.attached(point) says whether any hook runs there: while one runs at answer.after the answer is not streamed (no answer.delta), RunEvents(..., hold_manifest=True) holds result.manifest until the answer is allowed (dropped on a deny), and result_from_state drops the rows, artifact_refs and result_manifest of a run a hook ended.
  • HookContext adds conversation_id: str | None (Wave C's A3), execute (run.before) and results (the run's ResultSummarys at answer.after, the "sub-query summaries" of the table).
  • NeedsApproval at execute.before stops the SQL with NEEDS_APPROVAL (status error) until H2 adds the request and the re-run; so NEEDS_APPROVAL left DECLARED_AHEAD here with HOOK_DENIED and HOOK_FAILED.

The call sites: run.before in run_with_graph's worker; datasources.after in DatasourceResolverNode._after_hooks (the one addition to the resolver; R1 rebases on it); plan.after in ASTPlannerNode.after_plan(state, plan, plan_source), which the planner's and the cache's plan go through and which a supplied plan (H2's approved, Wave B's chosen, Wave C's supplied) calls the same way; ASTPlannerNode.check_plan(state, plan) is one plan's verdict, for Wave B's alternatives. ASTPlannerResponse.planner_plan is the plan before the hooks, and wrap_subgraph caches it. execute.before and execute.after in ExecutorNode and CuratedMatcherNode (each a self.hooks.call("<point>", **self._hook_fields(...)); a deny on the verified path ends the run, as _ended, never a fallback); answer.after in AnswerSynthesizerNode. A sub-query a hook dropped has SubgraphOutput.status == "dropped" and a HOOK_DENIED warning; build_scan_layer_router (in pipeline/routes.py, a small addition the file list missed) ends the run on a hook's error and still aggregates when a sub-query was dropped; AggregationService.execute(..., dropped=) drops a join or compare group whole; the answer's warnings name what was dropped. SubgraphExecutionState.user_query (in pipeline/state.py, also not in the file list) carries the question to the sub-query's hooks. The architecture test is tests/architecture/test_hooks_boundary.py: every runner call names its point as a literal; its graph check asks that every path from ast_planner to generator pass logical_validator, so Wave B's clarifier may sit between the validator and the generator.

7. Approval

A hook at execute.before answers NeedsApproval("Reads payroll.salaries"). The minimal shape (D10, D24):

class ApprovalRequest(BaseModel):          # on QueryResult.approvals_needed
    id: str                                # sha256 over every field below except reason and approver_roles, hex
    question: str
    asker_user_id: str | None              # the run's UserContext
    asker_tenant_id: str | None
    hook: str
    point: Literal["execute.before"]
    datasource_id: str
    decomposition: DecomposerResponse | None   # generated path: the run's decomposition
    sub_query: SubQuery | None                 # generated path: the sub-query the SQL answers
    plan: PlanModel | None                     # generated path: its validated plan
    verified: VerifiedRef | None               # verified path: the query id and its bound params
    sql: str
    sql_sha256: str
    reason: str
    approver_roles: list[str]              # for display; the engine checks the hook's configured roles

class Approval(BaseModel):                 # passed back on the re-run
    request: ApprovalRequest
    by: UserContext                        # the approver
  1. The run ends. The sub-query does not run, the others finish the step they are on, nothing is aggregated or synthesized, and the result has status: "needs_approval", approvals_needed (one ApprovalRequest per pending sub-query), an error NEEDS_APPROVAL and the answer "This question needs approval before it runs: Reads payroll.salaries." hook.decision is published and audited.
  2. The approver re-runs it. An app shows the request to someone with an approver role, who calls engine.run_query(question, user_context=<the asker>, approvals=[Approval(request=..., by=<the approver>)]). run_query and stream_query gain the approvals argument.
  3. The re-run pins what was approved. The plan cache cannot do it: it holds only plans that executed cleanly (pipeline/graph_utils.py), which a sub-query stopped at execute.before never did, and its key is the decomposer's intent text, which is model output and can change. So:
  4. Generated path. The run takes the request's decomposition instead of calling the resolver and decomposer. Each datasource in it is checked with scope.reaches_datasource() as the resolver would, and datasources.after is not called (there is no routing, as on the verified path). The approved sub-query's planner step returns the pinned plan with plan_source="approved", as a cache hit returns a cached one; other sub-queries plan as usual. The pinned plan still goes through plan.after, the validator and the generator, so a plan the policies or the code no longer accept is refused like any other. An approved plan is never written to the plan cache.
  5. Verified path. The curated matcher runs the request's verified query with its bound params, with no matching call.
  6. The approval is checked by the engine, not the hook. When a hook answers NeedsApproval at execute.before, the runner looks for an Approval whose request has the same hook and datasource and whose sql_sha256 equals the hash of the SQL just generated, and accepts it only if: the engine's recomputation of the request's id matches (the request was not edited); the request's question, asker_user_id and asker_tenant_id are this run's; and by holds one of the roles the hook is configured with (approver_roles, by default policies.admin_roles), not the roles the request lists. An accepted approval counts as Allow: hook.decision is published with decision: "approved" and the approver, and the next hook at the point still runs. The hook also sees ctx.approvals, so a hook that wants a stricter rule can apply it.
  7. Different SQL asks again. If the regenerated SQL's hash differs (the generator changed, or a plan.after hook changed the plan), the run ends needs_approval again with the new SQL: an approval never covers SQL nobody saw.

Built: the shapes on the result (A0)

ApprovalRequest, Approval and VerifiedRef (id, params) are in nl2sql/hooks/approval.py, and RunStop in nl2sql/limits.py; all are exported from nl2sql. QueryResult.status is a RunStatus (success, plan_only, error, needs_approval, stopped, and needs_clarification, reserved for Wave B), or None when nothing ran and nothing failed (0.6's ""). result_from_state reads the state's approvals_needed and stop, and sets the status from them before looking at errors, so H2 and E3 put those keys in the state and touch no result code. The error codes HOOK_DENIED, HOOK_FAILED, NEEDS_APPROVAL, RUN_LIMIT_REACHED and QUESTION_TOO_BROAD (never retried), JOIN_KEY_TYPE_MISMATCH and JOIN_FAN_OUT (retried) and JOIN_KEY_UNCONVERTED and ALL_NULL_COLUMN (warnings) are in ErrorCode; DECLARED_AHEAD in test_boundary_tidy_ups.py lists each with the PR that raises it, which takes it off the list.

Nothing is stored: no queue, no expiry, no table. The asking app holds the request until someone acts on it. An Approval is not signed and does not expire (D24): it is trusted as far as the user_context a caller passes is trusted today, which is the application's responsibility (nl2sql-api resolves both from its auth). The REST shape is Wave D.

Addendum: the pinned-plan entry is public

Decided after this design merged, with the Wave B to D reviews: the entry the approved re-run uses is built once and is public. run_query(question, plan=SuppliedPlan(datasource_id, sub_query, plan)) (and stream_query) runs a plan the caller holds. It skips the curated matcher, the resolver, the decomposer and the planner's model call; checks the datasource against the caller's scope; sets plan_source="supplied"; runs plan.after, the validator, the generator and the executor; is never cached; and never asks the refiner on a validator failure (the sub-query fails with the validator's error). The run's state carries the pin as a PinnedRun whose source is a PinnedSource: supplied, approved (this section's re-run) or chosen (Wave B's clarification re-ask, which may not be combined with approvals). SuppliedPlan is exported from nl2sql.

Built: approval and the pinned-plan entry (H2)

  • The entry. nl2sql/pipeline/pinned.py: PinnedSource, SuppliedPlan, PinnedRun (source, decomposition, plans keyed by sub-query id, or verified with verified_datasource_id), PinnedRun.supplied(plan), pinned_from_approvals(approvals, question=, user=) and PinnedEntryNode. build_graph(ctx, execute, pinned=True) builds a pinned run's graph, whose entry is pinned_entry (with no curated matcher); every other run's graph is unchanged and has no such node. run_with_graph builds the pinned graph for a supplied pin, or, for an approval's pin (known only after run.before), inside the worker. The pinned_entry node scope-checks every datasource of the decomposition (SECURITY_VIOLATION otherwise, routes.pinned_route ends the run) and goes to the layer router. build_scan_payload hands each sub-query its pinned_plan and pinned_source; the planner returns it through after_plan(state, plan, source). The SQL agent reads a pinned sub-query's retries as spent, so neither a validator nor a database error is retried, and the executor's value check never asks for a retry on it. The schema retriever gives a pinned plan every table the caller may read, with no search, for the validator. check_pinned(pinned, approvals, datasource_id) refuses, with a ValueError at the call (QueryAPI.run_query and stream_query, before the run's scope opens), a chosen pin with approvals and a datasource_id that is not the pinned plan's datasource. QueryAPI.run_query(..., pinned=) (internal) takes a pin of another source; Wave B passes its chosen plan there.
  • The request. ApprovalRequest.build(...) computes sql_sha256 and id; expected_id() recomputes it: SHA-256 of the canonical JSON of the fields in REQUEST_ID_FIELDS only, each dumped without the values it holds at their defaults. The rule for Wave D's store: a field added later, to the request or with a default to a nested model, keeps the ids of stored requests; changing a default or the meaning of a listed field changes them, and a test pins one id (GOLDEN_ID) so that never happens by accident. wrap_subgraph builds the request for a sub-query that ended NEEDS_APPROVAL (the error's details now carry the reason) into GraphState.approvals_needed; the curated matcher does the same with a VerifiedRef. One difference from the sketch above: the request pins the plan as plan.after received it (planner_plan), not the plan the hooks let through, because the re-run passes it through plan.after again; pinning the hooked plan would apply a hook's Modify twice and the SQL would never match the hash. What the approver approves is the SQL, and it is the same.
  • The check. HookRunner(bindings, admin_roles=) (the runtime passes policies.admin_roles), HookRunner.approver_roles(hook). On a NeedsApproval the runner looks for an approval in ctx.approvals with no approval_problem(...): unedited, this run's question (after run.before) and asker, this hook and datasource, the hash of the SQL just generated, the same verified values, and an approver holding one of the hook's configured roles. A match publishes hook.decision with decision: "approved" and the approver, and the next hook runs. request_problem refuses an edited request, a request whose sql is not the SQL of its sql_sha256 (so the SQL an approver reads is the SQL whose hash is checked), and another question or asker. pinned_from_approvals(approvals, question=, user=, hooks=) pins only an approval with no request_problem, for a configured hook, whose by holds one of that hook's approver roles; so a forged approval, an approver without the role, or a request the asker built with no approval hook configured, pins nothing and covers nothing: the run plans as usual and asks again, and plan_source is never "approved" for it.
  • The verified path. A pinned verified request takes the default entry; the curated matcher runs that query with its values (_run_pinned), with no search and no model call, through the same access checks and hooks. If it is no longer available, the matcher falls back as usual.
  • Not done: the result's final_answer stays None on a needs_approval run (the error's message is the answer text), as on every run a hook ended; nothing is stored (Wave D).

8. Evals as a product

engine.benchmark becomes engine.evals (D25). What 0.6 calls tier 1, tier 2 and retrieval recall become three modes of one run over one case format, scored by registered scorers.

The files

# nl2sql.yaml
evals: evals.yaml

# evals.yaml: the section
suites: [evals/chinook.yaml, evals/routing.yaml]
scorers: [result_match, outcome, faithfulness, retrieval_recall, routing]   # the default: all five
curated: true                       # D12
max_cost_usd: 5.00                  # across a run in mode full; required for it
gate:
  max_regressions: 2
  alpha: 0.05
  max_cost_increase: 0.20
# evals/chinook.yaml: a suite
version: 1
cases:
- id: chinook_001
  question: How many customers do we have, by country?
  datasources: [chinook]
  status: approved                  # draft | approved | retired; only approved cases run
  origin: hand                      # hand | seeded
  difficulty: easy
  tags: [aggregation, rbac-denial]
  paraphrase_group: null
  outcome: {admin: allowed, analyst: allowed, viewer: refused}
  tables: [Customer]
  columns: [Customer.Country, Customer.CustomerId]
  order_matters: false
  sql: |
    SELECT Country, COUNT(CustomerId) AS customer_count
    FROM Customer GROUP BY Country ORDER BY customer_count DESC, Country
  rows: [...]                       # written by regenerate, never by hand
  alt_sql: []                       # reviewed alternatives
  alt_rows: []                      # written by regenerate
  plan: {...}                       # optional: the hand-written gold plan, for mode plans
Field Was (chinook_gold.yaml) Notes
datasources datasource (one) A list, so a cross-database case can say where it should go. The routing scorer reads it.
status, origin none New. Every moved Chinook case is approved, hand. origin is hand, seeded or feedback.
outcome expected The same map of role to allowed, refused or unanswerable
tables, columns needed_tables, needed_columns
sql, rows, alt_sql, alt_rows gold_sql, gold_result, alt_gold_sql, alt_gold_result Same values
plan chinook_gold_plans.yaml, by id Folded into the case

Built: the section and the settings (A0)

EvalsConfig (with EvalGate) is Config.evals, from evals.yaml; suites are paths relative to nl2sql.yaml, resolved by V1. The settings the other tracks read are in place with their defaults: settings.pipeline.max_llm_calls, max_cost_usd and sanity_checks, settings.routing, and settings.retrieval.table_k, planning_k and decomposer_tables; eval_seeder is in AgentName.

evals.yaml is a config section file like the others: strict keys, loaded with the config. A suite file is data: it is read when a run asks for it, not when the engine starts, so a thousand cases cost an engine start nothing. The suites the package ships (Chinook, routing) live in nl2sql/evaluation/suites/; nl2sql init --demo copies them next to the demo's nl2sql.yaml.

The Chinook gold set moves unchanged. The PR that moves it computes a SHA-256 over every case's question, SQL, alternatives, rows, outcomes and needed tables and columns, read from chinook_gold.yaml and chinook_gold_plans.yaml with the old loader, and pins it in test_chinook_suite_holds_the_gold_data_unchanged, which computes the same digest from the new suite. Then the old files are deleted. No question, SQL or row changes; only the keys around them do. docs/testing/evaluation-dataset.md and the rule in CLAUDE.md move to the new names (alt_sql).

Feedback drafts move to the case format too. engine.feedback.export writes rated runs as gold drafts today, through GoldQuestion and the gold file's dumper (feedback/drafts.py, api/feedback_api.py). They become cases with status: draft and origin: feedback, so a person reviews them the same way as seeded ones.

The facade

Method What it does
engine.evals.load(path=None) -> list[EvalSuite] The suites named in evals.yaml, or one file
engine.evals.run(suites=None, *, mode="full", llm=None, cases=None, roles=None, scorers=None, curated=None, passes=1, max_cost_usd=None, on_case=None) -> EvalReport Runs every approved case, once per role in its outcome map, passes times. cases narrows by id or tag. llm is None (the engine's own llm section), one LLM, or a mapping of names to LLMs, which tier 2 compares today: each config runs on its own derived runtime over the same cases, and the report compares them. None arguments take evals.yaml's values.
engine.evals.gate(report, baseline) -> GateResult The regression gate below
engine.evals.regenerate(path) -> RegenerateReport Runs each case's sql and alt_sql through its datasource's adapter and writes rows and alt_rows
engine.evals.seed(datasource_id, *, count=20, out, user_context, max_cost_usd=None) -> SeedReport Draft cases from a datasource's schema, as the caller sees it
engine.evals.scorers() -> list[str] The registered scorer names
Mode What runs Model calls Was
full The whole pipeline, on the configured LLM yes; needs max_cost_usd tier 2
plans Each case's gold plan through the validator, generator and executor, with the gold-plan fake LLM none tier 1
retrieval Datasource routing and schema retrieval only none (the local or configured embedder) retrieval recall

full runs on a derived runtime (ctx.derive, as tier 2 does), so the engine serving traffic is untouched, and that runtime has the plan cache off, as tier 2's does, so a second pass asks the planner again rather than measuring the cache. Modes plans and retrieval run with curated answers off, whatever curated says: the curated matcher makes a model call the gold-plan fake cannot answer, and a verified hit would skip the gold plan or the retrieval being measured. The named LLM configs in evaluation/presets/ stay, and nl2sql eval run --llm takes a preset name or a file holding an llm section. Each case runs under its own RunScope. Every finished case publishes eval.case; the end publishes eval.finished.

Curated answers (D12). curated: true is the default in mode full. Each case result records its source (verified or generated). A case a verified query answered is run a second time with curated answers off, and that result is scored as its generated answer. The report carries accuracy (what users see), accuracy_generated (every case on the generated path: the model's own skill, comparable across configs) and both split by source. Only the curated hits pay the second run.

Scorers

# nl2sql/evaluation/scorers/__init__.py (engine: it sees QueryResult, D14)
class Score(BaseModel):
    value: float | None        # 0..1; None when the scorer does not apply to this case
    passed: bool | None
    detail: dict[str, Any] = {}

class CaseRun(BaseModel):      # what a scorer is given
    case: EvalCase
    role: str
    result: QueryResult        # with the run's trace when one was kept
    rows: list[list[Any]]      # the first terminal result, read from the results store

class Scorer(Protocol):
    interface_version: ClassVar[int]     # 1
    config_model: ClassVar[type[BaseModel]]
    modes: ClassVar[frozenset[str]]      # the modes it can score
    @classmethod
    def from_config(cls, cfg: BaseModel) -> "Scorer": ...
    def score(self, run: CaseRun) -> Score: ...
Scorer Pass means Modes From
result_match The rows match rows or any alt_rows (order when order_matters, the numeric tolerance and the period rules of today's ModelEvaluator) full, plans evaluation/evaluator.py
outcome The role's outcome is the case's: allowed ran and answered, refused ended SECURITY_VIOLATION, unanswerable ended QUESTION_NOT_ANSWERABLE full, plans the same
retrieval_recall Value: the share of tables and columns the schema retriever picked; passes at 1.0 full, retrieval evaluation/retrieval_recall.py
faithfulness Every number the answer states is in the result or its count full evaluation/faithfulness.py
routing The datasources the run used are exactly datasources; in retrieval mode, all of them are among the fused top max_datasources full, retrieval new (Phase R)
Kind Entry point group Interface Version Built-ins
scorer nl2sql.scorers nl2sql.evaluation.scorers.Scorer 1 the five above

A case passes when every scorer that applies to it passes. A scorer that raises scores the case as an error (passed: None, the message in detail) and the run continues; the report counts errors apart from fails.

Regenerating expected rows

regenerate replaces python -m nl2sql.evaluation.gold (D27). For each approved case it asks the case's datasource adapter, through the engine's DatasourceRegistry, to run sql and every alt_sql (read-only, with the datasource's limits), and writes the rows back into the suite in the order the SQL returns them, keeping the file's formatting rules from gold.py's dumper. A case whose SQL fails is reported and left unchanged. evaluation/gold.py stops importing sqlite3, and its entry in STATE_LIBRARY_EXCEPTIONS is removed: the Chinook gold rows now come from the same adapter questions run on. Gold data is never edited by hand: a new accepted answer is a reviewed alt_sql, then regenerate.

Built: evals core (V1)

nl2sql/evaluation/ is reorganised as the section says:

  • The case format. suite.py holds EvalCase, EvalSuite, load_suite, select_cases and write_suite. outcome also takes clarify, which runs only with engine.evals.run(clarify=True); Wave B's clarification produces it.
  • The Chinook suite moved. suites/chinook.yaml holds the gold data unchanged, pinned by test_chinook_suite_holds_the_gold_data_unchanged. Rows stay {column: value} maps, as the old file wrote them.
  • The run and the report. run.py (run_evals) runs the three modes. report.py holds EvalReport and the boards, from tier 2's scoreboard.
  • The scorers. scorers/ holds Score, CaseRun, Scorer and the five built-ins, declared as nl2sql.scorers entry points.
  • Rows through the adapter. regenerate.py returns a RegenerateReport.
  • The gold-plan fake. gold_llm.py answers from one table, RESPONSES, keyed by structured-output schema name. Wave B's PlannerAnswer is one more entry there.
  • The gate's statistics. gate.py holds tier 2's question_flips, mcnemar_exact, compare_with_baseline and check_baseline, unchanged except that the deprecated max_accuracy_drop is gone. V2 builds engine.evals.gate on them.

engine.evals has load, run, regenerate and scorers. run takes clarify: bool = False. With curated on in mode full, a curated hit's record carries generated, and each board carries accuracy.generated. CaseRun also carries mode, columns, retrieval (what the retriever sent) and routed (mode retrieval's ranking). In mode full, retrieval is read from the run's schema retriever node.

Deleted: engine.benchmark, BenchmarkAPI, BenchmarkConfig, benchmark_runner.py, tier1.py, tier2.py, types.py, gold.py, retrieval_recall.py (now scorers/retrieval_recall.py, with sent, routed and retrieval_settings), datasets/ and nl2sql benchmark. records.py keeps its page texts, which still name nl2sql benchmark, so the committed pages stay byte-identical until V2 rewires publishing.

After review:

  • Each board also reports accuracy.execution. It counts only result_match and outcome, the 0.6 tier 2 measure, so the published series stays comparable.
  • Mode retrieval cases open a run scope too.
  • regenerate refuses a packaged suite unless allow_packaged=True.
  • feedback export refuses a configured suite and any file holding approved cases.

The regression gate

nl2sql/evaluation/gate.py takes tier 2's gate as it is (compare_with_baseline, check_baseline): at most max_regressions cases that passed in the baseline and fail now, a one-sided test at alpha on the accuracy difference, and at most max_cost_increase more cost per case. It compares accuracy_generated and, separately, accuracy, and only compares reports of the same suite digest and mode (a changed suite is a series break, as records.py already reports). GateResult(ok, regressions, series_break).

Where Gate
CI, every pull request mode: plans on Chinook (today's tier 1 test) and mode: retrieval on Chinook and routing, against benchmarks/ baselines. Free.
Before a release, by the owner mode: full with a key, against the last published record. Asked for explicitly: it spends the owner's key.

Built: the gate (V2)

engine.evals.gate(report, baseline) -> GateResult(ok, regressions, series_break, compared), with evals.yaml's gate settings, built on V1's statistics in nl2sql/evaluation/gate.py (gate, load_baseline). Where the code differs from the section above, after the owner's decision of 2026-10-15:

  • It reads execution accuracy, not overall. A case passes the gate on result_match and outcome only (nl2sql.evaluation.report.execution_passed), the measure 0.6's tier 2 published, so a faithfulness miss is never a regression and a 0.7 run compares with the 0.6 history. "Both accuracies" are that measure as users see it and on the generated path (each case's generated run), the second only when the run had curated answers on. Mode retrieval has no execution and is read by status.
  • A baseline is a report, a record or a benchmarks/ folder (the newest record of the run's mode, database and config). A record 0.6 wrote has no scores; its status was execution accuracy and is read so.
  • A series break is not ok. The gate uses the history's own rule, records.series_key (mode, suite digest, schema fingerprint and, in mode full, roles and curated answers), plus the roles each run actually ran. A series break compares nothing for that config, and nl2sql eval run exits 2 after printing the other configs' regressions.
  • The allowance is per mode. max_regressions and McNemar (two-sided, counted only when more cases were lost than gained) apply to mode full. Modes plans and retrieval run no model and allow no flip.
  • A missing or unreadable baseline exits 2 with a one-line error.
  • Routing has no suite yet (Phase R adds it), so CI gates mode retrieval on Chinook only. R3 added it: see the routing gate.

The CLI

Command Calls
nl2sql eval run [--suite PATH] [--mode full\|plans\|retrieval] [--llm NAME=PRESET_OR_FILE ...] [--cases ID/TAG ...] [--max-cost USD] [--baseline FILE] [--out FILE] engine.evals.run, then gate with --baseline; exit 1 on a regression, 3 when stopped at the cost cap
nl2sql eval regenerate PATH engine.evals.regenerate
nl2sql eval seed --datasource ID [--count N] [--role R] [--max-cost USD] --out PATH engine.evals.seed, as the caller --role names
nl2sql eval publish Writes the report as a record under benchmarks/ and refreshes the README block (today's benchmark publish)

nl2sql benchmark and all its subcommands are deleted.

Built: the CLI and the records (V2)

nl2sql eval run, regenerate and publish are in cli/commands/eval.py. Where they differ from the table:

  • run also takes --passes. --llm takes PRESET, FILE.yaml, NAME=PRESET or NAME=FILE. The report goes to --out, by default eval_<mode>.json next to nl2sql.yaml, with a run stamp (when, and which engine version and commit) for publish. Exit codes: 0, 1 on a regression, 2 on a usage error or a baseline that cannot be compared, 3 at the cost cap. Mode full refuses to start with CI set (the one CI read, in READS_THE_ENVIRONMENT).
  • publish [REPORT ...] [--note] [--from DIR ...] writes one record per config of each report, then rebuilds docs/benchmarks.md and the README block from every record. With no report it only rebuilds the pages, as benchmark publish did.
  • Records are keyed by mode and suite digest: benchmarks/<mode>/<database>/...json, kind the mode and dataset the suite's name and digest (several suites: their digests joined and hashed, report.suite_identity). Every mode is recorded; a record keeps the config's whole board.
  • The headline accuracy is execution accuracy (metrics.accuracy is accuracy.execution), so the published series reads the same as 0.6's; accuracy_all_scorers and accuracy_generated sit beside it.
  • 0.6's records stay readable. Kind tier2 is read as mode full (records.mode_of), so the history shows those runs in the mode full table, as a new series (the suite changed).
  • Records are slim: one compact JSON line, each case's verdicts and cost only (records.slim_result). The full report is CI's eval-reports artifact. A retrieval record names its embedder.
  • CI's baselines are recorded on CI (ubuntu-latest, Python 3.12): the integration job uploads eval_plans.json and eval_retrieval.json, and nl2sql eval publish records them from that artifact, the run's URL in the note. The steps are in benchmarks/README.md; a suite, schema or routing change re-records both the same way.

Seeding: benchmark your own database

engine.evals.seed("sales", count=20, out="evals/sales.yaml") (D26):

  1. A new agent, eval_seeder (added to AgentName), is shown the datasource's live snapshot, filtered by the caller's scope: tables, columns, keys, descriptions, and sample values only where the datasource has sample_values on. It is asked for questions of mixed difficulty, each with a typed PlanModel, never SQL, five per call, until count are written or the budget is spent: twenty plans in one structured answer would be fragile to parse, and one bad plan would cost the other nineteen. Each call goes through parse-and-retry as every structured call does.
  2. Seeding has its own cost cap. It runs outside a question, so no per-question cap applies; it runs under the same budget (nl2sql/limits.py) with max_cost_usd, by default evals.yaml's, and a model with no price refuses to start, as an eval in mode full does. When the budget is spent, the cases so far are written and the report says it stopped.
  3. Each plan goes through the engine's LogicalValidatorNode and GeneratorNode, so the SQL is the engine's own rendering in the adapter's dialect. A plan the validator rejects is dropped and counted.
  4. The SQL runs through the adapter, read-only and within the datasource's limits, to confirm it executes and returns rows. A failure or an empty result drops the case and is counted.
  5. Survivors are appended to out with status: draft, origin: seeded, sql, plan, tables, columns (from the plan) and outcome set to the caller's role allowed. A question already in the file is skipped. rows are left empty.
  6. A person reads each draft, fixes the question or the SQL, and sets status: approved; regenerate then writes the rows. Drafts never run in an eval.

SeedReport(written, dropped_invalid, dropped_failed, dropped_empty, dropped_duplicate, stopped_at_cost_cap, usage).

Built: seeding (V3)

engine.evals.seed(datasource_id, *, count=20, out, user_context, max_cost_usd=None) -> SeedReport and nl2sql eval seed, in evaluation/seed.py and evaluation/prompts.py (EVAL_SEEDER_PROMPT). The user guide is Benchmark your own database. Where the code differs from the steps above:

  • The caller is required and checked first. No user_context, no role, or a role whose scope does not reach the datasource (or none of its tables) is a PermissionError before any call. The model is shown the tables through SchemaRetrieverNode.visible_tables(user_context, datasource_id), new and public: the planner's own rendering for that caller. outcome marks allowed each of the caller's roles whose own scope reads every table of the plan (after review of PR #328); other roles get no entry.
  • Plans go through the pinned-plan entry. Each proposed plan runs as run_query(question, plan=SuppliedPlan(...), execute=False, user_context=...): plan.after, the validator and the generator under the caller's scope, with no model call, in a run scope of its own (run.started and run.finished). The draft keeps the plan that produced the SQL (result.sub_queries[0].plan), so a plan.after change wins. A question that reads as SQL (the decomposer's SubQuery check) is dropped as invalid.
  • The SQL passes execute.before and execute.after (the session lead's decision on the review of PR #328): it is model-proposed, so an approval hook or Wave B's cost guard must see it. A deny, a hook failure or needs_approval drops the case (dropped_hooked). execute.after gets a summary of the first row (truncated: true). regenerate runs human-reviewed gold SQL and stays unhooked; both are documented in configuration/hooks.md. The rows are checked with execute_batches(request, 1) where the adapter streams; no row is kept.
  • The structured output is SeedBatch (cases: SeedCase with question, difficulty and a PlanModel), the name a fake LLM rule answers. A call whose answer never parses counts its batch as invalid.
  • The budget is Budget(max_cost_usd, max_llm_calls, setting="evals") with at most three calls per batch of five (CALLS_PER_BATCH), so a model answering nothing usable stops; the report says so (stopped_at_call_limit), and the CLI exits 3 as at the cost cap. Each call is shown the count of questions already written and the newest ten (RECENT_QUESTIONS), not all of them. max_cost_usd is required (the argument, else evals.yaml's), and an unpriced eval_seeder model is an UnknownPriceError before any call.
  • Drafts never go into reviewed cases: out may not be a packaged suite, a suite evals.yaml names, or a file holding approved cases. The guard is nl2sql.evaluation.suite.check_target(path, configured), which feedback export calls too. SeedReport also carries path, dropped_hooked and stopped_at_call_limit.
  • The CLI asks as --role, else policies.default_role, and exits 2 without one; it exits 2 on any refusal and 3 at the cost cap or the call limit, and refuses to run with CI set, as mode full does. It takes no --llm: llm.agents.eval_seeder picks the model.

9. Phase R: cross-database routing

Phase R was never built: 0.6 has domains on a datasource and nothing else of it. Everything below is in indexing/ and the resolver, decomposer and validator nodes.

One entry per phrasing

Today SchemaChunkBuilder._build_datasource_chunks writes one schema.datasource entry per datasource whose text holds its description, domains and every sample question, so the fifth sample question barely moves the vector.

Entry type Text Count
schema.datasource description and domains one per datasource
schema.datasource_example (new) one sample question (from sample_questions, or enrichment's) one per question
curated.verified_query (exists) one phrasing of a verified query one per phrasing (already)

ENTRY_TYPES in api/indexing_api.py and the inspector gain the new type.

Votes and fusion

Retriever.route(question, datasource_ids, explain) -> list[RouteCandidate] replaces retrieve_datasource_candidates. It runs five searches over the live builds of the datasources the caller's scope reaches (the scope filter moves before the search, so a denied datasource never even ranks), and each search ranks datasources by their best entry:

Signal Search
description schema.datasource, exact over every datasource (as today)
examples schema.datasource_example, top VOTE_K
curated curated.verified_query, top VOTE_K
tables schema.table across every datasource, top VOTE_K
columns schema.column across every datasource, top VOTE_K

The ranks are fused with reciprocal rank fusion (D28):

score(ds) = sum over signals s:  1 / (RRF_K + rank_s(ds))

where rank_s(ds) is len(ranked_s) + 1 for a datasource signal s did not rank (amended after review: otherwise merely having entries of a type is a vote).

VOTE_K = 20 and RRF_K = 60 (the value the RRF literature uses) are constants on Retriever, beside FETCH_MULTIPLIER and LAMBDA_MULT, until the routing suite shows a reason to tune them.

Then the candidates are the top candidates by score, dropping any whose best entry similarity is under floor_ratio times the most similar candidate's (amended after review: a floor on the fused score never fires). The searches see only the entries the caller's scope allows: a table or column of a hidden table, or a verified query that reads one, is dropped before it can vote or be recorded. The trace records each signal's ranking and the fused scores (retrieval.searches, with a new routing record), so the playground's inspector and an eval can show why a datasource was or was not chosen.

The answerability verdict is unchanged in shape (it already returns a list): it may pick several of the candidates, and the resolver forwards at most max_datasources of them, in fused order.

Built: entries, votes, fusion and the retrieval settings (R1)

  • Entries. DatasourceExampleChunk (schema.datasource_example) in indexing/models.py; build_example_chunks(datasource_id, questions, schema_version) in indexing/chunk_builder.py takes the question list from its caller (SchemaChunkBuilder passes the orchestrator's sample_questions, or enrichment's), so Wave B adds the glossary's examples to that list without changing it. Blank and repeated questions are skipped; ids hash the question. DatasourceChunk lost examples. ENTRY_TYPES lists the new type.
  • Routing. Retriever.route(question, datasource_ids, explain=None, *, limit=3, floor_ratio=0.5, allows=None) -> list[RouteCandidate] replaces retrieve_datasource_candidates, which is deleted. limit, floor_ratio and allows (an entry check) are keyword arguments the resolver fills from settings.routing.candidates, floor_ratio and the caller's scope (the retriever reads no settings and no policy). RouteCandidate (datasource_id, score, ranks, similarity) is in indexing/models.py; ROUTE_SIGNALS, fuse(rankings, rrf_k, datasource_ids, similarity=None) (an unranked datasource gets rank len(ranked) + 1) and shortlist(fused, limit, floor_ratio) (the floor on best similarity) are module functions in indexing/retrieval.py; VOTE_K = 20 and RRF_K = 60 are on Retriever. The trace gets one route.<signal> record per signal and a routing record (signals, fused with each similarity, a pool of the fused datasources, picks, dropped with floor or limit, rrf_k, vote_k, candidates, floor_ratio).
  • Table scope (after the review of PR #316). The resolver's _route_filter passes allows: a table or column entry votes only when its (datasource, table) is visible in the live snapshot, a verified query only when every table its SQL reads is. Refused entries are dropped before ranking or recording, over-fetching so VOTE_K counts allowed entries. routing.candidates now defaults to 3, as D28 says.
  • The resolver. The scope filter runs before routing: no reachable datasource is a SECURITY_VIOLATION with no search. It forwards at most max_datasources of the accepted candidates, best by fused score; the forwarded list stays sorted by id, so the decomposer's prompt does not depend on the fused order. ResolvedDatasource.metadata is empty on every path now (it held the datasource entry's metadata, which nothing read). MAX_DECOMPOSER_TABLES is settings.retrieval.decomposer_tables.
  • The schema retriever reads settings.retrieval.table_k and planning_k; TABLE_K and PLANNING_K are deleted.
  • The retrieval scorers (evaluation/scorers/retrieval_recall.py, after V1): routed() ranks with route under settings.routing (then cut at the caller's limit, max_datasources in mode retrieval), and retrieval_settings() reads settings.retrieval.
  • Baseline. The 0.6 retrieval baseline, recorded on main before any of this changed, is benchmarks/retrieval/chinook/2026-10-15_653fa73_retrieval.json: table recall 0.9767, column recall 0.7185 and datasource_top1_accuracy 0.9535 (41 of 43 Chinook questions; the two misses went to support), over the demo's three datasources with the local embeddings.
  • Measured after the review (key-free, local embeddings). Routing top-1 over 79 questions across the demo's three datasources (the 43 Chinook gold questions, the 20 sample questions and 16 paraphrases written for this check): 0.6 75/79; R1 before the review 62/79 (the curated free vote sent 17 of the 20 support and webanalytics questions to chinook); R1 after it 77/79. The expected datasource was among the shortlisted candidates for all 79 in every version.

Limits

# settings.yaml
routing:
  candidates: 3          # datasources shown to the answerability check
  max_datasources: 3     # datasources one question may use
  max_sub_queries: 6     # sub-queries one question may have
  floor_ratio: 0.5       # drop a candidate less similar than this share of the most similar
retrieval:
  full_snapshot_max_tables: 15     # exists
  table_k: 8                       # was SchemaRetrieverNode.TABLE_K
  planning_k: 12                   # was SchemaRetrieverNode.PLANNING_K
  decomposer_tables: 30            # was MAX_DECOMPOSER_TABLES in the resolver
  • max_datasources bounds what the resolver forwards.
  • max_sub_queries is stated in the decomposer's prompt. A decomposition over it ends the run with QUESTION_TOO_BROAD (D29): "This question needs more than 6 separate queries. Ask about fewer things at once." It is not retried: a model that ignored the limit once will usually do so again, and the question is the thing to change.

Built: the sub-query limit (R2)

DecomposerNode reads settings.routing.max_sub_queries once, in its constructor (self.max_sub_queries), and the system prompt states it as rule 10 ("Use at most 6 sub_queries"), a {max_sub_queries} template variable, so the system message stays the same for one engine. The count is of the model's own sub-queries, before any is dropped as unmapped. Over it, the node returns an empty DecomposerResponse, no DAG and one QUESTION_TOO_BROAD error (ERROR, in FATAL_ERRORS), with the message above; the model is called once.

The join-key type check

A join or compare combine group joins two sub-queries' results in the aggregator on its join_keys (aggregation/engines/polars_duckdb.py, a polars join). Today nothing checks that both sides of a key have the same kind of type, so a text customer_id from one database joined to an integer from another fails in the aggregator (AGGREGATOR_FAILED, terminal) or matches nothing (B30).

A validator check alone cannot fix that case. The plan has no cast (there is no CAST in ALLOWED_FUNCTIONS, and nothing in ast_planner/schemas.py), and when one database stores the key as text there is no numeric column for the model to pick instead: a "type mismatch, try again" would turn one terminal failure into three wasted retries and then another. So the fix is in two parts (D30):

  1. The decomposer declares the type. DecomposerResponse's validator requires that each JoinKeyPair.left and .right names a column in the expected_schema of its side's sub-query, with a dtype, and that the two dtypes can be coerced to each other (the table below). A violation is a ValueError, which parse-and-retry feeds back to the model. The decomposer node then marks those columns as join keys on its own copy of each SubQuery: ExpectedColumn.join_key is excluded from the model's tool schema (SkipJsonSchema), so the model can never set it.
  2. The aggregator coerces. Before the join, the engine's aggregator casts both sides of each key to the declared dtype in polars, non-strictly. A value that does not convert becomes null, does not match, and is counted in a JOIN_KEY_UNCONVERTED warning ("3 of 412 support.customer_ref values are not numbers and joined nothing"). This is engine-side compute on result frames, so no dialect is involved.
  3. The validator rejects only what cannot be coerced. In LogicalValidatorNode._validate_static, for each select item that is a join key and a plain column reference, the column's data_type from the snapshot is parsed with sqlglot.exp.DataType.build(data_type, dialect=<the adapter's>) and put in a category: number, text, temporal, boolean, or other. Against the declared dtype:
Column \ declared number text temporal boolean
number ok ok (formatted) reject ok (0 and 1)
text ok (parsed) ok ok (parsed) ok (parsed)
temporal reject ok (formatted) ok reject
boolean ok ok reject ok

A rejected pair is JOIN_KEY_TYPE_MISMATCH, a retryable ERROR that asks for another column, never a cast: "The join key order_date is declared a date, but Orders.OrderId is a number. Select the date column for this key." A type sqlglot cannot place is other and is not checked; a computed select item is not checked.

No dialect name is written in the engine: the dialect comes from the adapter, as the generator already does. test_text_and_integer_keys_join_after_coercion is B30's test.

Built: join-key coercion and the check (R2)

  • One set of rules. nl2sql/aggregation/join_keys.py (plain Python) holds the categories, DTYPE_CATEGORY, coercible(a, b) and join_dtype(left, right), the type both sides are cast to: the shared dtype; text parsed as the other side's type; int and float meet as int (not float: a non-whole value cannot equal a whole one, and a float cannot tell ids above 2^53 apart), date and datetime as datetime, a number and bool as bool. The decomposer's schema, the aggregator and the validator all read it.
  • The decomposer. DecomposerResponse.validate_join_key_types() resolves each key against its side's expected_schema with resolve_key (so right.customer_id is customer_id); the side is the input's place in engine order (input_order). ExpectedColumn.join_key, JoinKeyPair.left_dtype and JoinKeyPair.right_dtype are SkipJsonSchema, set only by mark_join_keys() in decomposer/node.py, which replaces whatever the model sent and leaves a key it cannot find untyped rather than crash. join_key is excluded from the stable sub-query id and from the planner's expected_schema. CombineGroup.validate_roles() refuses two inputs on one side, or a third input, in a join/compare. The DAG carries the pair's dtypes in the combine node's join_keys attributes (dag.py dumps the pair, so it is unchanged).
  • The aggregator. PolarsDuckdbEngine.combine casts each side first to its own declared dtype and then to the joined one, so a value is read the way its sub-query declared it (a number goes straight to the joined number type, and two float keys over integer frames join as int). A cast nulls, never changes, a value: a float or decimal that is not whole is not an int. Timestamps of any unit or zone become naive UTC microseconds (exact dtypes are compared, not the bare pl.Datetime class); text is parsed only by the ISO formats in _DATE_FORMATS, _NAIVE_FORMATS and _OFFSET_FORMATS, never by inference; a Time key declared a date or timestamp is null. Every one of those nulls is counted, so none ends the run as AGGREGATOR_FAILED. Text is stripped on both sides, of a text key too; a text boolean is true/false/t/f/yes/no/1/0; a number cast to a boolean is 1 or 0, anything else null. PolarsDuckdbEngine.unconverted_join_keys(inputs, join_keys) counts the values that became null (it collects the key columns only), and AggregationService.execute(..., warnings=[...]) gathers them; the node turns each into a JOIN_KEY_UNCONVERTED warning on the state's warnings, naming the datasource: "1 of 3 support.customer_id values are not whole numbers and joined nothing." S1 made it a RunWarning whose detail holds column, failed, total and dtype.
  • The validator. pipeline/nodes/validator/join_keys.py, join_key_type_errors(plan, sub_query, relevant_tables, dialect), called from _validate_static before the buildability check. The column type is the schema snapshot's Column.type. sqlglot's BOOLEAN is a boolean, its numeric types a number (BIT included), its text types text, its temporal types temporal; anything else, an unparsable type or no type is other and not checked. The message is "The join key order_key is declared a date, but Orders.OrderKey is a number. Select a column that holds a date for this key."
  • The demo. test_a_key_declared_text_on_one_side_joins_after_coercion (tests/e2e/test_multi_sub_query_fake_llm.py) joins Chinook's integer CustomerId to support's customer_id declared as text and gets the SQLite answer. The demo databases store both keys as integers, so the text-against-integer case itself is the unit test above. The design lists no realdb test for R2.

The routing gate

nl2sql/evaluation/suites/routing.yaml holds approved cases over the demo's three databases (chinook, support, webanalytics), single and cross-database, each with its datasources, plus cases over the test bed's 1,200-table wide schema that run in the realdb job. The routing scorer in retrieval mode is the CI gate (free); the same suite in full mode is the release check. R1 records the 0.6 baseline first, so the gate measures Phase R's change rather than asserting a number.

Built: the routing suite and gate (R3)

  • The suite. suites/routing.yaml (ROUTING_SUITE_PATH in evaluation/suite.py): 78 approved, hand-written cases asked as admin, tagged by origin and datasource. They are R1's 79-question measurement (the 43 Chinook gold questions, the demo's 20 sample questions, 12 of which are gold questions too, and 16 paraphrases) plus 8 more support and webanalytics questions and 3 cross-database ones: 47 name chinook, 14 support, 14 webanalytics, 3 two of them. Every expected datasource was read against the schema. No case names tables, so the routing scorer alone decides a case and a recall miss cannot hide a routing one (the Chinook suite's retrieval status needs both). Ten support and webanalytics cases carry reviewed SQL, with rows written by regenerate; the others are routing only, with no sql.
  • The scorer, after the owner's extra requirement. In mode retrieval routing passes a case only when routing ranks one of its datasources first (detail.top1) and shortlists all of them (detail.shortlist, as the section above said). Mode retrieval allows no flip, so the gate fails when either drops. The board gains routing (cases, top1, shortlist), and a retrieval record's metrics gain routing_top1, routing_shortlist and routing_cases, shown on the history page and the README beside recall. result_match no longer scores a case without sql: such a case says where a question goes, not what it returns.
  • Where it runs. The demo's evals.yaml still names only chinook.yaml: mode plans needs a plan for every case with SQL, and a combined run would also move the Chinook records to a new database id. nl2sql init --demo copies routing.yaml with the other suites, and it is run by name. CI's integration job adds an "Eval gate (routing)" step (eval run --mode retrieval --suite evals/routing.yaml --baseline benchmarks --out demo-ci/eval_routing.json) and uploads eval_routing.json in eval-reports; its records live under benchmarks/retrieval/chinook-support-webanalytics/.
  • Tests. tests/integration/test_routing_gate.py: test_routing_suite_cases_are_approved_and_regenerated and test_routing_recall_does_not_regress (the gate against benchmarks/).
  • The wide schema. Its cases are in tests/realdb/test_routing_wide.py (test_routing_on_the_wide_schema), not in routing.yaml: they name the test bed's datasources, which the demo does not have. Pagila and the whole wide schema are indexed side by side on PostgreSQL only, since indexing 1,200 tables takes about 17 minutes on CI's runner; four questions about films must go to Pagila and four about payroll runs, ledger entries, vendor contacts and campaign clicks to the wide schema. After review it runs only when the realdb workflow sets REALDB_WIDE_ROUTING (nightly, on demand, and on pull requests that change indexing/), and the realdb job's timeout is 60 minutes.
  • Routing cases are a kind (after review). EvalCase.kind is answer (the default, left out of a suite file) or routing. A routing case has no sql, gets no result_match verdict, and is left out of execution accuracy in every mode: the board's accuracy.execution, the record's headline metrics and flips, and the gate's execution measures. Mode retrieval still gates it by status. The loader rejects an approved answer case with an allowed role and no sql, so a missing answer can no longer pass as "any answer". Records keep each case's kind. The Chinook suite and its digest are unchanged. The three cross-database cases are tagged with both their datasources; the suite's header names the cases with two defensible answers. Both changed the suite's digest, so the routing baseline was recorded again from CI (run 38788733052, 2026-10-15_baaae0c_retrieval.json: the same 97.4% top-1 and 98.7% shortlist).
  • Measured (key-free, local embeddings). On the routing suite, routing top-1 is 76/78 and shortlist recall 77/78. The misses: "Which customers complained most about refunds?" and "How many purchases were there per month in 2013?" rank chinook first; "Which customers both raised support tickets and made a purchase on the website?" drops webanalytics at the floor. The Chinook suite's retrieval gate is unchanged by the stricter scorer (all 43 Chinook cases rank chinook first).
  • Baselines, recorded on CI (run 38778183715, ubuntu-latest, Python 3.12, the same numbers as on Windows): benchmarks/retrieval/chinook-support-webanalytics/2026-10-15_a33c8ce_retrieval.json (routing top-1 97.4%, shortlist 98.7%) and a new Chinook retrieval record, benchmarks/retrieval/chinook/2026-10-15_a33c8ce_retrieval.json, which carries the routing metrics (100% / 100%) with no flip against the last. The realdb wide-schema test passed on that run too.

10. Schema drift: measure first

The owner wants drift captured and measured before anything acts on it. Nothing here re-indexes or schedules (D31).

engine.index.check()

engine.index.check(datasource_ids=None) -> list[DriftReport]

class DriftReport(BaseModel):
    datasource_id: str
    status: Literal["unchanged", "drifted", "not_indexed", "failed"]
    live_version: str | None          # the build pointer's schema version
    checked_at: datetime
    diff: SchemaDiff | None           # live database against the live build
    impact: DriftImpact | None
    error: str | None

For each datasource it reflects the live database with the datasource's IndexScope (adapter.fetch_schema_snapshot, no profiling: profile: catalog for the check), fingerprints it with generate_schema_fingerprint, and compares it with the fingerprint of the snapshot the live build pointer names. Equal fingerprints are unchanged; otherwise the diff and impact are computed and schema.drifted is published with detected_by: "check". Nothing is stored and nothing is rebuilt. nl2sql index --check prints the reports and exits 0 when nothing drifted, 1 when something did, 2 when a check failed, so a cron job or CI step can act on it.

The diff

class ColumnChange(BaseModel):
    table: str; column: str
    changes: dict[str, tuple[Any, Any]]     # "type", "nullable", "pk": (old, new)

class SchemaDiff(BaseModel):
    datasource_id: str
    from_version: str
    to_version: str | None                  # None: the live database
    tables_added: list[str]; tables_dropped: list[str]
    columns_added: list[ColumnRef]; columns_dropped: list[ColumnRef]
    columns_changed: list[ColumnChange]
    foreign_keys_added: list[str]; foreign_keys_dropped: list[str]
    engine_type: tuple[str, str] | None     # (old, new) when the adapter type changed
    def is_empty(self) -> bool: ...

It compares exactly what the fingerprint covers (schema/fingerprint.py): table and column names, types, nullability, primary keys, foreign keys, and the snapshot's engine_type, which the diff reports as engine_type: tuple[str, str] | None (a datasource whose type changed). The fingerprint also covers datasource_id, which is the same on both sides of any diff, so: for two snapshots of one datasource, diff.is_empty() is true exactly when the fingerprints are equal; a property test holds that. A rename is a drop and an add: there is no reliable way to tell a rename from a drop without asking someone.

Stored with the new version. When a build stores a snapshot whose fingerprint differs from the version it replaces, the orchestrator computes the diff and stores it beside the new version, then publishes schema.drifted with detected_by: "build". A version whose structure did not change (only statistics did) gets no diff. StateStore gains:

def put_schema_diff(self, diff: SchemaDiff) -> None: ...                       # keyed by datasource and to_version
def get_schema_diff(self, datasource_id: str, schema_version: str) -> SchemaDiff | None: ...

so the store kind moves to interface version 2 (the other three stores only declare it), and the sqlite store adds migration 4, a schema_diffs table that keep_snapshots prunes with its version. The diff's model lives in nl2sql_sdk.schema beside SchemaSnapshot. The version moves for all four slots, not only the state store, because the registry has one version per kind (INTERFACE_VERSIONS["store"] in registry.py) and the four slots share the nl2sql.stores group; a version per slot would be a second mechanism for one method.

Facade What it does
engine.index.check(datasource_ids=None) Above
engine.index.versions(datasource_id) -> list[SchemaVersionInfo] Each stored version, newest first: schema_version, created_at, fingerprint, live (a pointer names it), has_diff
engine.index.diff(datasource_id, schema_version=None) -> SchemaDiff \| None The stored diff that introduced a version (the live one by default)

Measuring is then a matter of reading these: how often each datasource's structure changes between builds, and how far the live database is from the build at any check.

Impact

DriftImpact lists what the diff breaks, from data the engine already has:

What How
Verified queries Each query in curated.yaml whose SQL reads a dropped table or column, or a column whose type changed: the tables and columns from the same sqlglot parse nl2sql curated validate uses
Policy rules Each role's allow or deny pattern that matched a dropped table and now matches nothing, from a new nl2sql.policies.patterns_matching(datasource_id, tables) (pattern matching stays in policies/, by the invariant)
Preferred tables The datasource's preferred_tables that were dropped

The plan cache needs no impact entry: it is keyed on schema_version, so a rebuild already makes its plans unreachable.

Built: schema drift (D1)

nl2sql_sdk.schema holds SchemaDiff (with counts() and is_empty()), ColumnChange and ForeignKeyRef, exported from nl2sql_sdk and SchemaDiff from nl2sql too. Three shapes differ from the sketch above, so a caller's scope can filter them without parsing names: tables are TableRefs (not str), ColumnChange.table is a TableRef, and a foreign key is a ForeignKeyRef (table, columns, referred_table, referred_columns) rather than a string. A table added or dropped is listed once, without its columns or foreign keys. nl2sql/schema/diff.py has diff_contracts(old, new, *, from_version, to_version) and visible_diff(diff, allows); a seeded random property test holds that the diff is empty exactly when the fingerprints match. The store kind is at interface version 2 (INTERFACE_VERSIONS), all four built-in stores declare it, and SqliteState adds migration 0004_schema_diffs (schema_diffs, keyed by datasource and to_version; put_schema_diff replaces and refuses a diff with no to_version; keep_snapshots deletes a pruned version's diff). Wave B takes version 3 and migration 5, Wave C version 4 and migration 6. IndexingOrchestrator._record_drift runs after the commit (a failure is logged, never fails the live build) and publishes through nl2sql/schema/drift.py's publish_drift(ctx, diff, detected_by) on ctx.bus with the engine's tenant_id. The caller's scope (binding extra): engine.index.check(datasource_ids=None, *, user_context), versions(datasource_id, *, user_context) and diff(datasource_id, schema_version=None, *, user_context) all require user_context, as datasources.schema does (M1). A caller sees only the tables its Scope.allows: drift only in denied tables is unchanged (and a stored diff reads None, has_diff false); a datasource it cannot reach is left out of check() and, named, is the same ValueError as an unknown id. schema.drifted carries the whole diff's counts and no table name; it is audited and off the stream (STREAM_EVENTS). DriftReport and SchemaVersionInfo live in nl2sql/schema/drift.py; SchemaVersionInfo has schema_version, fingerprint, live, has_diff and no created_at, since the store interface records no creation time (the sqlite version string starts with one). The check reflects with the datasource's IndexScope copied to profile: catalog, sample_values: false; a failure's error is redact_for(ctx, ...). Impact: nl2sql/schema/impact.py's drift_impact(ctx, diff, tables) runs every finder in FINDERS over the (caller-visible) diff, each filling its own field of DriftImpact (verified_queries: ImpactedQuery(datasource_id, query_id, reasons); policy_rules: ImpactedRule(role, kind, pattern); preferred_tables). Wave B adds glossary_terms as one more field and one more finder appended to FINDERS; no caller changes. Verified queries are matched from inspect_verified_sql, which now also returns columns. After review: SchemaVersionInfo.fingerprint is of visible_contract(contract, scope.allows) (the denied tables and the foreign keys to them removed), so a denied-only change looks like a statistics-only one; the policy-rule impact compares against the live tables the caller may read; a check publishes schema.drifted once per drift (_publish_check_drift remembers (from_version, the diff's sha256) per datasource in a WeakKeyDictionary keyed by the runtime, so per engine and in memory only); inspect_verified_sql's columns are (schema, table, column) with the table its qualifier's alias names (or the only table read, or ""), and an unqualified column in a multi-table query is a "may read" reason. nl2sql index --check exits 2 when nothing was checked. nl2sql.policies.patterns_matching(roles, datasource_id, tables) takes policies.roles too and returns (role, kind, pattern) triples; the boundary test now flags imports of the nl2sql.policies.patterns module only (its prefix also caught patterns_matching), and api/indexing_api.py joined its access-decision list. nl2sql index --check also takes --role (repeatable, default admin as nl2sql run) and --json; a config that fails to load exits 2.

11. Answer sanity checks

Cheap checks after a sub-query runs, before the aggregator. They live in pipeline/nodes/executor/sanity/, which takes in today's value_check.py. Every probe is a sqlglot expression built from the plan and rendered by the adapter (render_sql or its dialect), bounded by the datasource's row limit, and skipped for a table the caller's scope denies, as value_check.py does today. A check that fails to run reports nothing.

Check When it runs Detects Then (D32)
Missing value (exists) An empty result filtered on a string literal The column has no such value. With sample values in the snapshot the closest is taken from them with no query; otherwise from today's bounded DISTINCT probe. A close value and a retry left: retried once (VALUE_NOT_FOUND, ERROR). Otherwise a warning.
Join fan-out The plan joins a table on a key that is not unique on the other side (known from the snapshot's primary and foreign keys) and aggregates SUM, AVG or COUNT over a column of the one side. This includes one table joined to two others on their many side (a many-to-many sum), which is the same probe per many side. A probe over the plan's FROM, joins and WHERE: COUNT(*) against COUNT(DISTINCT <one side's primary key>). More rows than keys confirms that each row was counted several times. Under COUNT, confirmed with a retry left: retried once (JOIN_FAN_OUT, ERROR), telling the planner to count distinct keys, which the plan can say (Expr.distinct). Under SUM or AVG: a warning only, since the repair (aggregate the many side first) needs a derived table the plan cannot express; splitting such a question in the decomposer is a Wave B idea. Otherwise a warning.
All-null column A result with rows A result column whose every value is null: usually an outer join that matched nothing, or the wrong column A warning only (ALL_NULL_COLUMN): no probe, and no reliable fix to suggest

Retries are counted against settings.pipeline.retries.max, and each check retries at most once per sub-query, as VALUE_NOT_FOUND does. A verified query is not checked: a person wrote its SQL. settings.pipeline.sanity_checks: true turns them all off when false.

Typed warnings (D34). QueryResult.warnings becomes list[RunWarning], in S1, the PR that adds warnings (today's producers: result_from_state in api/query_api.py, executor/value_check.py, executor/node.py, the answer synthesizer):

class RunWarning(BaseModel):         # not Warning, which is a Python builtin
    code: str                     # an ErrorCode value: VALUE_NOT_FOUND, JOIN_FAN_OUT, ALL_NULL_COLUMN, ...
    node: str
    message: str                  # what the answer says
    sub_query_id: str | None = None
    detail: dict[str, Any] = {}

The answer synthesizer is given the warnings, as it is given VALUE_NOT_FOUND warnings today, and sanity.flagged is published for each.

Built: sanity checks and typed warnings (S1)

  • Where. pipeline/nodes/executor/sanity/: values.py (moved from executor/value_check.py), fanout.py, nulls.py, and __init__.py's run_checks(plan, *, adapter, allowed, tables, row_limit, row_count, frame, can_retry). ExecutorNode._sanity calls it after execute.after (H1's order is kept: a result a hook stopped is never checked), when settings.pipeline.sanity_checks is on and the execution succeeded. An empty result gets the value check; a result with rows the fan-out and null checks. Each check is wrapped: one that raises reports nothing.
  • Retries. can_retry(code) is true while retries remain, the plan is not pinned, and no earlier attempt of the sub-query raised that code, so each check retries at most once. Findings carry details.attempt; sql_agent.check_execution retries a VALUE_NOT_FOUND or JOIN_FAN_OUT ERROR of the current attempt (_SANITY_RETRIES).
  • Fan-out. A table is repeated when a join reaches, on its far side, columns that are not the far table's whole primary key (from relevant_tables[].primary_key; an unknown key counts as unique); every table on the near side of that join in the plan's join tree is repeated, which covers the many-to-many sum. A COUNT, SUM or AVG (not DISTINCT) of a plain column of a repeated table is confirmed by SELECT 1 FROM <the plan's FROM and joins> WHERE <the plan's WHERE> AND NOT alias.pk IS NULL GROUP BY <the plan's groups>, alias.pk HAVING COUNT(*) > 1 LIMIT 1 (after review: a COUNT(*) against COUNT(DISTINCT pk) over the whole join gave false findings on outer joins and grouped plans), built with the generator's GeneratorNode.attach_joins (now a static method) and rendered by the adapter; the key keeps its reflected case. No probe without a single-column primary key or with a table the scope denies.
  • Probes and hooks (owner's decision after review). A probe, the value check's or the fan-out check's, runs outside execute.before and execute.after. So no probe runs when any hook is attached at execute.before (run_checks(..., probes=False)): only the null check does. settings.pipeline.sanity_checks: false stays the off switch. The check lives in the executor, per sub-query, not in the aggregator beside unconverted_join_keys: the repeated rows are inside one sub-query's SQL, which the aggregator never sees.
  • All-null. check_nulls(frame) reads the saved result's null counts through ArtifactStore.scan_parquet, never its values.
  • Sample values. check_values(..., samples=) takes a column's snapshot sample values (from relevant_tables[].columns[].stats) before the DISTINCT probe; the existence probe still runs, since samples are partial.
  • RunWarning lives in nl2sql/common/errors.py, beside PipelineError, not in api/query_api.py as planned: the nodes that produce warnings must not import the API layer. It is exported as nl2sql.RunWarning. RunWarning.of(error, sub_query_id) turns an error into a warning, its details left behind; an ERROR or CRITICAL one (an attempt a retry replaced) gets detail={"recovered": True} in place of the old severity field, and the answer synthesizer skips those. GraphState.warnings and SubgraphExecutionState.warnings are List[RunWarning]; every producer builds one (the aggregator's RESULT_TRUNCATED, HOOK_DENIED and R2's JOIN_KEY_UNCONVERTED, the curated matcher's CURATED_FALLBACK, the schema retriever's fallback as SCHEMA_RETRIEVAL_FAILED, result_from_state's row-read failure as UNKNOWN_ERROR). extra="forbid", so a stray dict fails loudly.
  • The answer. The synthesizer's Data Notes now carry VALUE_NOT_FOUND, JOIN_FAN_OUT and ALL_NULL_COLUMN warnings, and each is appended to the answer's warnings.
  • sanity.flagged is published through nl2sql.events.publish for each finding: code, sub_query_id, retry (true for a retried ERROR).
  • The playground. NodeInspector.jsx reads a warning's code only, with no fallback to error_code; the bundle is rebuilt.
  • Breaking for REST clients. QueryResult.warnings[] items lost error_code and severity for code and detail.recovered. Runs are not stored across an upgrade, so no older warning is read back.
  • Tests. Every e2e test that opened the session demo's shared state.db (the fake_llm fixture, the RBAC API test, both evals tests, the temperature test) now gets a private copy.

12. MCP server (stdio)

nl2sql mcp serves the engine to an MCP client (a desktop assistant, an IDE) over stdio, using the official mcp Python SDK.

Rule Detail
An extra pip install "nl2sql-engine[mcp]". import nl2sql never loads mcp; the boundary test adds it to FORBIDDEN_AT_IMPORT.
Public facade only nl2sql/mcp/server.py imports the top-level nl2sql and mcp, nothing else from the engine, held by the same test that holds the API and the playground. nl2sql/cli/commands/mcp.py builds the engine and calls build_server(engine, identity).run("stdio"); identity is a UserContext or a function returning one, asked on every call, so Wave C can name a caller per HTTP request.
Who is asking (D33) stdio has no authentication: the person who started the process is the caller. --role (repeatable) and --user set the UserContext; without them the role is policies.default_role. That defaults to none (config/models.py), and a caller with no role reads nothing, so nl2sql mcp with neither refuses to start, naming both fixes; getting_started/mcp.md says so.
Logs To stderr only; stdout is the protocol.
HTTP and auth Wave C
Tool Arguments Returns Facade
ask question, datasource_id (optional) status, the answer (summary, content), per sub-query its SQL, columns and the first 20 rows, truncated, source, warnings, trace_id, and approvals_needed or stop when set engine.run_query
list_datasources none each datasource's id, type, description and status, for the datasources the caller's scope reaches engine.datasources.list, engine.policies.scope
describe_table datasource_id, table its columns (name, type, nullable, key, description), foreign keys, row count and description; "not found" for a table the caller may not read, so a denied table is indistinguishable from a missing one engine.datasources.schema(datasource_id, user_context=...)

engine.datasources.schema(datasource_id, *, user_context) takes the caller, required, and drops the tables its scope denies (B29). There is no default: an app that forgot the argument would fail open. A caller that wants the whole schema passes an admin UserContext explicitly. The playground's schema panel passes the selected role.

Built: the MCP server and the caller's schema (M1)

nl2sql/mcp/server.py has build_server(engine, identity) -> FastMCP (official mcp SDK, the mcp extra mcp>=1.10,<2) and PREVIEW_ROWS = 20; nl2sql mcp --role ... --user ... is cli/commands/mcp.py (caller(policies, roles, user), mcp_command). Shapes the design did not fix: every tool returns a JSON object (its structured content). ask returns status (the run's RunStatus, so Wave B's needs_clarification needs no new field; its clarification gets its own key), answer (summary, content), sub_queries (id, datasource_id, sql, columns, rows, total_rows, truncated), truncated, source, warnings (code, message), trace_id, and errors (code, node, message), approvals_needed and stop when set. An error's or warning's message is kept only for the codes written for the asker (refusals, hooks, limits, providers, the answer checks); any other message is replaced by its code and the trace id, since it may carry an exception's text. An exception out of the engine is INTERNAL_ERROR, logged; ask with a datasource_id the caller cannot reach is DATASOURCE_NOT_FOUND without running. describe_table answers status: ok with the schema() table, or not_found (a denied table, a missing one and a table in an unreachable datasource alike) with the tables the caller may read, or ambiguous when a bare name matches tables in two schemas. nl2sql mcp also starts without a role under allow_all (with a warning), and refuses a role the policies do not define, under allow_all too. After the attack review of PR #325: the identity is resolved inside each tool's guard (a resolver that raises gives INTERNAL_ERROR); SECURITY_VIOLATION always carries the generic refusal (GENERIC_REFUSAL); FastMCP is built with explicit settings; and /api/schema without ?role= is a 400. datasources.schema(datasource_id, *, user_context) filters through policy_engine.scope(...).allows (schema_view(..., allows=)), drops a foreign key to a denied table, and is in the boundary test's ACCESS_DECISIONS. The playground's /api/schema requires ?role= (400 without it) and the page sends its Ask as role. mcp is in FORBIDDEN_AT_IMPORT (which is what test_import_nl2sql_does_not_load_mcp was to check), and test_mcp_uses_only_the_public_api holds nl2sql/mcp/ to the top-level nl2sql and mcp to nl2sql/mcp/.

13. The registry after Wave A

Kind Entry point group Interface Version in 0.7 Built-ins
adapter nl2sql.adapters nl2sql_sdk.DatasourceAdapter 2 sqlite, postgres, mysql, mssql, duckdb
llm nl2sql.llms nl2sql.llm.LLMProvider 2 openai, anthropic, openai_compatible (with openrouter and ollama)
embeddings nl2sql.embeddings nl2sql.embeddings.EmbeddingsProvider 2 local, openai, openai_compatible
secrets nl2sql.secrets nl2sql_sdk.secrets.SecretProvider 1 env, aws, azure, hashicorp
store nl2sql.stores nl2sql_sdk.stores 2 (StateStore diffs) chroma, sqlite, files, local
policy check nl2sql.policy_checks nl2sql_sdk.policy.PolicyCheck 1 none
observer nl2sql.observers nl2sql_sdk.events.Observer 1 log, audit, otel
hook nl2sql.hooks nl2sql.hooks.Hook 1 none
scorer nl2sql.scorers nl2sql.evaluation.scorers.Scorer 1 result_match, outcome, retrieval_recall, faithfulness, routing

New on Registry: add_observer, add_hook, add_scorer and their decorators. Knowledge sources stay for Wave B.

Built: the three kinds (A0)

observer, hook and scorer are in nl2sql/registry.py (GROUPS, INTERFACE_VERSIONS at 1, add_* and the decorators). No built-in is declared yet: E2 adds the observers and V1 the scorers. E2 declared log, audit and otel and made the registry check an observer's requires.

Built: the five scorers (V1)

result_match, outcome, faithfulness, retrieval_recall and routing are declared in packages/nl2sql/pyproject.toml under nl2sql.scorers.

14. Bugs Wave A fixes

# Bug Evidence Fixed by
B14 (rest) Every log line's trace_id is null; OpenTelemetry is set up only on nl2sql run; opentelemetry is a hard dependency loaded by the runtime trace_context() in common/logger.py has no caller; configure_metrics only in cli/commands/run.py; common/metrics.py imported at module scope by pipeline/plan_cache.py and services/callbacks/token_handler.py E1 (correlation), E2 (the otel observer and extra)
B27 The audit log is a module-global writing logs/audit_events.log in the process's working directory, created on first import common/event_logger.py (event_logger = EventLogger(), os.makedirs("logs")) E2
B28 A run's cost is null unless the user writes prices, while a dated table sits unused outside tier 2 llm.prices defaults to {}; evaluation/prices.py is read only by evaluation/tier2.py E3
B29 engine.datasources.schema() shows every table, whatever the caller's role, so the playground's schema panel lists denied tables api/datasource_api.py schema() takes no caller; cli/demo/playground/app.py M1
B30 A join across datasources on keys of different types fails in the aggregator, terminally, or silently matches nothing CombineGroup.join_keys is never type-checked; aggregation/aggregator.py R2

15. Boundary and invariant changes

Rule Change
The SDK imports only pydantic and the standard library Still true. New: nl2sql_sdk.events.
import nl2sql loads no heavy library mcp joins FORBIDDEN_AT_IMPORT (opentelemetry is already there, and now the runtime does not load it either: test_building_an_engine_loads_no_opentelemetry builds a demo engine with no otel observer and checks sys.modules).
New: only nl2sql/observers/otel.py imports opentelemetry test_only_the_otel_observer_imports_opentelemetry
New: only nl2sql/mcp/ imports mcp, and it imports only the top-level nl2sql Added to test_the_cli_the_playground_and_the_api_use_only_the_public_api and test_the_playground_the_demo_command_and_the_api_import_only_the_public_nl2sql
New: hooks modify only before the validator test_hooks_modify_only_before_the_validator (section 6), and a new invariant Hooks Modify Before the Validator, Deny After in invariants.md
New: every published event is catalogued test_every_published_event_is_in_the_catalog (an AST scan of publish( call sites)
New: no event carries result rows; the stream yields only the caller-safe events test_no_bus_event_carries_rows, test_stream_never_yields_policy_or_hook_or_llm_call_events, and a new invariant Events Carry No Rows, and the Stream Carries No Operator Data in invariants.md
New: policy.decision is published only where access is enforced test_retriever_filtering_publishes_nothing
New: observers fail open, hooks fail closed A new invariant in invariants.md with its tests
nl2sql/evaluation/gold.py may import sqlite3 Removed (D27)
The environment allow-list CI in nl2sql benchmark becomes CI in nl2sql eval; NL2SQL_LOG_FORMAT is added for configure_logging
Datasource Access Is Policy-Gated Unchanged in substance: routing's searches now run only over datasources the scope reaches, still before any model call
The Validator Rejects Everything the Generator Can Extended: the join-key coercibility check is the validator's (the aggregator coerces what it allows); test_logical_validator_generator_parity.py gains a case
invariants.md, Policy Enforcement: "nothing outside nl2sql/policies/ matches a policy pattern" Unchanged; drift impact calls policies.patterns_matching

16. PR plan

Each PR is written test-first and updates the docs it affects in the same PR; a new docs page also gets its mkdocs.yml nav entry in that PR. At most four PRs are open at once. A0 lands first and alone. It front-loads the shared shapes (D35), and only those, so the four tracks touch different files; the overlaps that remain are listed under the table with the order they merge in.

flowchart TD
  D["0. This design"] --> A0["A0. Foundation"]
  A0 --> E1["E1. Run events, stream, log correlation"]
  A0 --> H1["H1. Hooks: runner, six points, registry"]
  A0 --> V1["V1. Evals: suites, Chinook moved, scorers, run, regenerate"]
  A0 --> R1["R1. Routing: phrasing entries, votes, RRF, settings"]
  E1 --> E2["E2. Observers: log, audit, otel extra; llm.call, llm.changed"]
  H1 --> H2["H2. Approval"]
  V1 --> V2["V2. nl2sql eval, the gate, records"]
  R1 --> R2["R2. Limits, join-key coercion and check"]
  E2 --> E3["E3. Price table, cost, caps"]
  E1 --> E4["E4. Index, datasource, parse-retry, policy, purge events"]
  V1 --> R3["R3. Routing suite and gate"]
  R1 --> R3
  V2 --> V3["V3. Seeding"]
  E3 --> V3
  E4 --> D1["D1. Schema drift"]
  H1 --> S1["S1. Sanity checks, typed warnings"]
  E1 --> M1["M1. MCP server"]
  E3 --> REL["Release 0.7"]
  H2 --> REL
  R2 --> REL
  R3 --> REL
  V3 --> REL
  D1 --> REL
  S1 --> REL
  M1 --> REL
# PR Files Depends on Tests first Docs
A0 Foundation. The SDK's Event and Observer. nl2sql/events/: the bus, the catalog (every Wave A event and its data model), the module publish, and RunScope complete: opened and closed by QueryAPI, its context entered inside run_with_graph's _invoke, run.started, run.finished (summary) and run.failed published on the bus, late events dropped, attach for run-scoped pieces. Runtime.bus. The observer, hook and scorer registry kinds. Every new config shape (observers, hooks, evals sections; settings.pipeline.max_llm_calls, max_cost_usd, sanity_checks; settings.routing; settings.retrieval.table_k, planning_k, decomposer_tables; eval_seeder in AgentName) with its loader entry, and settings.observability deleted with its two readers. The new ErrorCodes (HOOK_DENIED, HOOK_FAILED, NEEDS_APPROVAL, RUN_LIMIT_REACHED, QUESTION_TOO_BROAD, JOIN_KEY_TYPE_MISMATCH, JOIN_KEY_UNCONVERTED, JOIN_FAN_OUT, ALL_NULL_COLUMN). The new QueryResult fields status values, stop and approvals_needed (not the typed warnings: S1). Stub engine.events and engine.evals namespaces that later PRs fill. --log-format (and NL2SQL_LOG_FORMAT). A section nothing reads yet is accepted and validated; the PR that reads it is named in its docstring. Files: packages/sdk/src/nl2sql_sdk/events.py, nl2sql/events/* (new), runtime.py (bus), pipeline/runtime.py (_invoke), api/query_api.py (the scope, the fields), api/events_api.py and api/evals_api.py (stubs), public_api.py, __init__.py, registry.py, config/models.py, config/loader.py, common/errors.py, common/logger.py (the format), cli/main.py (--log-format), cli/commands/run.py and cli/common/engine.py (no settings.observability). — test_sdk_events_imports_only_pydantic, test_publish_calls_subscribers_in_order, test_a_raising_subscriber_never_fails_the_publisher, test_raise_subscribers_propagate, test_publish_refuses_an_uncatalogued_name, test_publish_validates_data_against_the_catalog, test_trace_id_reaches_graph_worker_threads, test_run_finished_is_published_once_with_a_summary, test_run_failed_is_published_on_the_bus, test_no_event_after_run_finished_on_timeout, test_no_bus_event_carries_rows, test_module_publish_outside_a_run_scope_is_a_no_op, test_two_engines_have_two_buses, test_registry_lists_observers_hooks_and_scorers, test_new_config_sections_are_strict, test_observability_setting_is_gone, test_every_published_event_is_in_the_catalog (architecture) architecture/wave_a.md (Built note), configuration/system.md (the new sections), configuration/settings.md
E1 Run events and the stream. pipeline/events.py publishes node.*, answer.delta, result.manifest; stream_query yields Events from the caller-safe allow-list, its terminal run.finished with data.result (QueryEvent deleted); TraceContextFilter fed by the run's context (B14, logs); the trace document's events; engine.events.subscribe and catalog; the CLI's run, the playground and nl2sql-api's stream route on the new names. Files: pipeline/events.py, api/query_api.py (stream_query), api/events_api.py, common/logger.py (the filter), tracing/document.py, tracing/recorder.py, cli/commands/run.py, cli/demo/playground/app.py, packages/api/src/nl2sql_api/routes/query.py, services/query.py. A0 test_stream_yields_events_in_publish_order, test_stream_ends_with_exactly_one_run_finished_or_failed, test_stream_terminal_event_carries_the_query_result, test_stream_never_yields_policy_or_hook_or_llm_call_events, test_every_log_line_in_a_run_has_its_trace_id, test_log_lines_on_worker_threads_have_the_trace_id, test_subscribe_receives_engine_and_run_events, test_trace_document_lists_the_run_events, test_query_event_is_gone (public surface), the API's test_query_stream_route on the new names observability/events.md (new, with its nav entry: the catalog, the stream, SSE), observability/debugging.md, api/core/ query page, api/rest/ stream route
H1 Hooks. nl2sql/hooks/ (__init__.py, points.py, runner.py); the runner built by the runtime; the six call sites, run.before in _invoke; fail closed, on_error: open; deny scopes, with a join/compare group dropped whole; hook.decision; the plan cache keeps the planner's plan (ASTPlannerResponse carries it beside the hooked one, and graph_utils caches that). Files: hooks/* (new), runtime.py (build the runner), pipeline/runtime.py, pipeline/graph_utils.py, pipeline/nodes/datasource_resolver/node.py, ast_planner/node.py, ast_planner/schemas.py, executor/node.py, curated_matcher/node.py, answer_synthesizer/node.py, aggregation/aggregator.py (a dropped group). A0 test_hooks_run_in_config_order, test_first_deny_ends_the_point, test_modify_chains, test_hook_exception_denies, test_on_error_open_allows_on_exception, test_disallowed_decision_denies_even_when_open, test_modified_plan_is_validated, test_datasources_after_cannot_add_a_datasource, test_cached_plan_passes_plan_after, test_cached_plan_is_the_planners_not_the_hooks, test_verified_query_passes_execute_before, test_deny_on_verified_path_does_not_fall_back, test_sub_query_deny_drops_only_that_sub_query, test_sub_query_deny_in_a_join_group_drops_the_group, test_plan_only_run_skips_execute_points, test_hooks_modify_only_before_the_validator (architecture), test_unknown_point_is_a_config_error configuration/hooks.md (new, with its nav entry and the 30-line example), architecture/invariants.md (new invariant), architecture/pipeline.md, security/model.md
V1 Evals core. nl2sql/evaluation/ reorganised: suite.py (case and suite models, loader), run.py (the modes; llm configs compared; plan cache off in full; curated off in plans and retrieval), scorers/ (interface and five built-ins; retrieval_recall.py becomes a scorer), regenerate.py (through the adapter), suites/chinook.yaml (moved, digest-pinned), report.py; engine.evals filled. Deleted in the same PR, with everything that imports them: engine.benchmark, api/benchmark_api.py, evaluation/types.py (BenchmarkConfig), evaluation/benchmark_runner.py, tier1.py, tier2.py (their logic is in run.py and report.py), evaluation/datasets/*.yaml, and the nl2sql benchmark command (cli/commands/benchmark.py, its entries in cli/main.py), so nothing is left importing them; nl2sql eval arrives in V2, with no release in between. The tier 1 integration tests move to engine.evals.run(mode="plans"), so CI's key-free step keeps running them. evaluation/presets/ stays. Feedback drafts in the case format (origin: feedback). nl2sql init --demo copies the suites. gold.py without sqlite3. Files: evaluation/*, api/evals_api.py, api/benchmark_api.py (deleted), public_api.py (benchmark removed), __init__.py (exports), feedback/drafts.py, api/feedback_api.py, config/project.py, cli/commands/init.py, cli/commands/benchmark.py (deleted), cli/main.py, the boundary test's exemption. A0 test_chinook_suite_holds_the_gold_data_unchanged, test_only_approved_cases_run, test_each_scorer_scores_its_modes, test_a_raising_scorer_marks_the_case_error, test_mode_plans_needs_no_key (today's tier 1 through engine.evals), test_modes_plans_and_retrieval_run_with_curated_off, test_mode_full_turns_the_plan_cache_off, test_mode_full_compares_named_llm_configs, test_mode_full_refuses_an_unpriced_model, test_curated_hit_is_also_scored_generated, test_report_has_accuracy_and_accuracy_generated, test_regenerate_runs_through_the_adapter, test_regenerate_leaves_a_failing_case_unchanged, test_feedback_export_writes_draft_cases, test_init_demo_copies_the_suites, test_gold_does_not_import_sqlite3 (architecture), test_benchmark_is_gone (public surface) evals/index.md, evals/cases.md, evals/scorers.md (new, with nav entries); benchmarks.md replaced; testing/evaluation-dataset.md; observability/feedback.md; CLAUDE.md (alt_sql)
R1 Routing. schema.datasource_example entries; Retriever.route with the five signals, RRF, floor and candidates; the scope filter before the search; the routing trace record; settings.retrieval and settings.routing read by the resolver and the schema retriever; the retrieval scorer moved off retrieve_datasource_candidates, TABLE_K and PLANNING_K; the 0.6 retrieval baseline recorded first. Files: indexing/chunk_builder.py, indexing/models.py, indexing/retrieval.py, api/indexing_api.py (ENTRY_TYPES), pipeline/nodes/datasource_resolver/node.py, pipeline/nodes/schema_retriever/node.py, evaluation/scorers/retrieval_recall.py (after V1). A0 test_each_sample_question_is_its_own_entry, test_rrf_fuses_signal_ranks, test_floor_ratio_drops_weak_candidates, test_resolver_forwards_at_most_max_datasources, test_denied_datasource_never_ranks, test_table_votes_route_a_question_without_examples, test_retrieval_k_values_come_from_settings, test_routing_record_in_the_trace architecture/indexing.md, architecture/nodes/datasource_resolver_node.md, architecture/nodes/schema_retriever_node.md, configuration/settings.md
E2 Observers. The observer kind built from observers:; built-in log, audit (B27; run summaries; storage.purge includes it) and otel (otel extra, own providers). Everything that fed the deleted modules moves in this PR: token_handler.py publishes llm.call instead of adding to token_usage_counter, plan_cache.py publishes plan_cache.lookup, and replace_llm publishes llm.changed instead of calling event_logger; then common/metrics.py and common/event_logger.py are deleted and opentelemetry leaves the core dependencies. Files: nl2sql/observers/ (new: __init__.py, log.py, audit.py, otel.py), runtime.py (build the observers; replace_llm), api/storage_api.py, pipeline/plan_cache.py, services/callbacks/token_handler.py, common/metrics.py and common/event_logger.py (deleted), packages/nl2sql/pyproject.toml. E1 test_default_observers_are_log_and_audit, test_log_skips_node_started_and_answer_delta_below_debug, test_observer_exception_is_logged_once_and_skipped, test_audit_writes_under_storage_path, test_audit_writes_one_summary_per_run, test_purge_deletes_old_audit_files, test_llm_call_is_published_per_call, test_llm_replace_publishes_llm_changed_without_keys, test_otel_without_the_extra_names_the_fix, test_otel_exports_node_spans_and_metrics (in-memory exporter), test_two_engines_export_to_two_otel_providers, test_building_an_engine_loads_no_opentelemetry, test_only_the_otel_observer_imports_opentelemetry (architecture) configuration/observers.md (new, with its nav entry), observability/stack.md, deployment/ OTel notes, configuration/settings.md (observability removed)
H2 Approval. ApprovalRequest (decomposition, sub-query, plan or verified ref, SQL hash, asker), Approval; run_query(..., approvals=) and stream_query; an approved_route graph entry that takes the pinned decomposition (scope-checked) in place of the resolver and decomposer; the planner returning a pinned plan as plan_source="approved", never cached; the curated matcher running a pinned verified query; the runner's checks (recomputed id, same asker and question, the hook's configured roles, the regenerated SQL's hash); the needs_approval ending. Files: hooks/approval.py (new), hooks/runner.py, public_api.py, api/query_api.py (run_query signature), pipeline/graph.py, pipeline/state.py, pipeline/routes.py, ast_planner/node.py, curated_matcher/node.py. H1 test_needs_approval_ends_the_run_with_a_request, test_matching_approval_allows, test_approved_rerun_uses_the_pinned_plan_even_when_the_model_would_replan, test_approved_rerun_skips_the_resolver_and_decomposer, test_approved_plan_is_never_cached, test_approval_for_other_sql_asks_again, test_approval_for_another_asker_is_not_accepted, test_edited_request_is_not_accepted, test_approver_without_role_is_not_accepted, test_next_hook_runs_after_an_approval, test_needs_approval_outside_execute_before_denies, test_verified_query_can_need_approval, test_approval_is_audited configuration/hooks.md (approval), api/core/ query page
V2 The CLI and the gate. nl2sql eval run (with --llm), regenerate, publish; evaluation/gate.py from tier 2's gate; records keyed by suite digest and mode; CI read by nl2sql eval. Files: cli/commands/eval.py (new), cli/main.py, evaluation/gate.py, evaluation/records.py, .github/workflows/test.yml (the retrieval gate step), the boundary test's READS_THE_ENVIRONMENT. V1 test_eval_run_exits_1_on_a_regression, test_eval_run_exits_3_at_the_cost_cap, test_eval_run_llm_takes_a_preset_or_a_file, test_gate_compares_only_the_same_suite_and_mode, test_gate_checks_both_accuracies, test_publish_writes_a_record evals/index.md (CLI, CI), development/releasing.md (the pre-release eval), benchmarks.md
R2 Limits and join keys (B30). max_sub_queries in the decomposer prompt and node, QUESTION_TOO_BROAD; the JoinKeyPair dtype rule in DecomposerResponse; join_key on the node's copy of ExpectedColumn, out of the tool schema; the aggregator's coercion and JOIN_KEY_UNCONVERTED; the validator's coercibility check. Files: pipeline/nodes/decomposer/schemas.py, decomposer/node.py, decomposer/prompts.py, pipeline/nodes/validator/node.py, aggregation/aggregator.py, aggregation/engines/polars_duckdb.py. R1 test_decomposition_over_the_limit_is_question_too_broad, test_join_key_without_dtype_is_retried_by_parse_retry, test_join_key_flag_is_not_in_the_tool_schema, test_text_and_integer_keys_join_after_coercion, test_unconverted_key_values_are_a_warning, test_validator_rejects_a_number_key_declared_temporal, test_validator_accepts_a_text_key_declared_numeric, test_unknown_type_is_not_checked, the parity case in test_logical_validator_generator_parity.py architecture/invariants.md (Combine Groups), architecture/nodes/decomposer_node.md, logical_validator_node.md, engine_aggregator_node.md
E3 Cost and caps. llm/prices.py (moved, dated); price_for with llm.prices overrides; the usage collector on llm.call; engine.llm.prices(); the budget attached by RunScope, cancelling the token with a RunStop reason; run_with_graph mapping that reason to status: "stopped"; the build-time check for a cost cap. Files: llm/prices.py, evaluation/prices.py (deleted; evaluation/run.py's import), services/callbacks/token_handler.py, nl2sql/limits.py (new), common/cancellation.py (the reason), pipeline/runtime.py (the stopped mapping), api/llm_api.py, runtime.py (the check). E2 test_known_model_is_priced_from_the_table, test_config_price_overrides_the_table, test_dated_variant_is_priced_by_its_configured_name, test_unknown_model_cost_is_null, test_call_cap_stops_before_the_next_call, test_cost_cap_stops_before_the_next_call, test_cap_hit_inside_the_planner_ends_stopped_not_planning_failure, test_stopped_result_names_the_limit_and_setting, test_cost_cap_with_an_unpriced_model_is_a_config_error, test_parse_retries_count_as_calls, test_limit_reached_is_audited configuration/llm.md (prices), configuration/settings.md (caps), observability/stack.md
E4 The Phase 2 events, published. index.started, index.column_failed, index.swapped, index.failed, datasource.failed and retention.purged through ctx.bus; llm.parse_retry through the run's scope; policy.decision at the three enforcement points. Files: indexing/rebuild.py, indexing/orchestrator.py, runtime.py (check_datasource, try_datasource), llm/wires/__init__.py, pipeline/nodes/validator/node.py (_validate_policy), curated_matcher/node.py (_check_access), datasource_resolver/node.py (the refusal), api/storage_api.py. E1 test_index_build_publishes_started_and_swapped_on_ctx_bus, test_index_issue_publishes_column_failed, test_failed_check_publishes_datasource_failed, test_purge_publishes_counts, test_parse_retry_is_published_in_the_run, test_validator_denial_publishes_policy_decision, test_retriever_filtering_publishes_nothing, test_verified_query_refusal_publishes_policy_decision observability/events.md, phase2.md section 9 (a Built note)
R3 The routing suite and gate. suites/routing.yaml over chinook, support and webanalytics; wide-schema cases in realdb; the routing scorer gate in CI against the recorded baseline. Files: evaluation/suites/routing.yaml, tests/integration/test_routing_gate.py, tests/realdb/test_routing_wide.py, benchmarks/retrieval/ baseline. R1, V1 test_routing_suite_cases_are_approved_and_regenerated, test_routing_recall_does_not_regress (integration), realdb: test_routing_on_the_wide_schema evals/cases.md (the routing suite), benchmarks.md
V3 Seeding. engine.evals.seed (caller required, batches of five, its own budget); the eval_seeder prompt; plans through the validator and generator; read-only execution; drafts appended; nl2sql eval seed. Files: evaluation/seed.py (new), evaluation/prompts.py (new), api/evals_api.py, cli/commands/eval.py. V2, E3 test_seed_asks_for_plans_not_sql (fake LLM), test_seed_asks_five_per_call, test_seed_stops_at_its_cost_cap, test_invalid_plan_is_dropped_and_counted, test_failing_or_empty_sql_is_dropped, test_drafts_are_written_as_draft_and_seeded, test_existing_question_is_skipped, test_draft_cases_never_run, test_seed_respects_the_callers_scope evals/seeding.md (new, with its nav entry: benchmark your own database)
D1 Schema drift. engine.index.check, versions, diff; SchemaDiff (with engine_type) in the SDK; StateStore.put_schema_diff and get_schema_diff, store interface version 2, migration 4; the orchestrator stores the diff and publishes schema.drifted; DriftImpact; policies.patterns_matching; nl2sql index --check. Files: schema/diff.py (new), schema/impact.py (new), packages/sdk/src/nl2sql_sdk/schema.py, nl2sql_sdk/stores.py, registry.py (store version), storage/sqlite.py and the other three stores' version, indexing/orchestrator.py, api/indexing_api.py, policies/patterns.py, cli/main.py (the index command's --check). E4 test_diff_is_empty_exactly_when_fingerprints_match (property, one datasource), test_engine_type_change_is_in_the_diff, test_rename_is_drop_and_add, test_check_reports_drift_without_reindexing, test_check_never_writes_the_index, test_build_stores_a_diff_for_a_structural_change_only, test_diff_is_pruned_with_its_snapshot, test_impact_lists_broken_verified_queries_and_rules, test_index_check_exit_codes, test_store_version_1_is_rejected; realdb: test_check_sees_an_added_column schema/drift.md (new, with its nav entry), schema/store.md, adapters/sdk.md (stores v2)
S1 Sanity checks and typed warnings. executor/sanity/ (values.py from value_check.py, fanout.py, nulls.py, __init__.py); sample values first for the closest value; the COUNT fan-out retry; RunWarning and QueryResult.warnings typed (D34); sanity.flagged. Files: pipeline/nodes/executor/sanity/* (new), executor/value_check.py (moved), executor/node.py, pipeline/subgraphs/sql_agent.py (check_execution retries JOIN_FAN_OUT), answer_synthesizer/node.py, api/query_api.py (RunWarning, result_from_state). H1 test_count_fan_out_is_confirmed_by_the_probe_and_retried_once, test_sum_fan_out_is_a_warning_only, test_many_to_many_sum_is_a_join_fan_out, test_unique_join_is_not_probed, test_all_null_column_is_a_warning_only, test_closest_value_comes_from_sample_values_without_a_query, test_probe_is_rendered_by_the_adapter, test_denied_table_is_never_probed, test_verified_query_is_not_checked, test_sanity_checks_off, test_warnings_are_typed, the existing VALUE_NOT_FOUND tests moved architecture/nodes/executor_node.md, observability/error-handling.md, api/core/ query page
M1 MCP server. nl2sql/mcp/server.py (ask, list_datasources, describe_table); nl2sql mcp, refusing to start with no role; the mcp extra; datasources.schema(..., user_context=) required (B29); the playground's schema panel passes its role. Files: nl2sql/mcp/ (new), cli/commands/mcp.py (new), cli/main.py, api/datasource_api.py, cli/demo/playground/app.py, pyproject.toml, the boundary test's lists. E1 test_import_nl2sql_does_not_load_mcp, test_mcp_uses_only_the_public_api (architecture), test_ask_returns_answer_sql_and_capped_rows (in-process MCP client, fake LLM), test_list_datasources_hides_unreachable_ones, test_describe_table_hides_a_denied_table_as_not_found, test_schema_requires_a_caller, test_schema_with_a_caller_drops_denied_tables, test_mcp_without_a_role_refuses_to_start, test_logs_go_to_stderr getting_started/mcp.md (new, with its nav entry), api/core/datasource.md

The waves, with at most four PRs at once:

Wave PRs Notes
1 A0 Alone; everything else branches from it
2 E1, H1, V1, R1 The four tracks start. Merge order: H1, V1, then R1 (see below)
3 E2, H2, V2, R2
4 E3, E4, R3
5 V3, D1, S1, M1 Whichever agent frees up first

Known overlaps, each with its order:

File PRs Order
pipeline/nodes/datasource_resolver/node.py H1 (the datasources.after call), R1 (routing), both wave 2 H1 merges first; R1 rebases
evaluation/retrieval_recall.py, which V1 turns into evaluation/scorers/retrieval_recall.py V1 (moves it), R1 (its calls to retrieve_datasource_candidates, TABLE_K, PLANNING_K), both wave 2 V1 merges first; R1 updates the scorer
runtime.py E3 (the cap check), E4 (check_datasource), both wave 4 E4 merges first; E3 adds one call
cli/main.py D1 (--check), M1 (mcp), both wave 5 Either order; each adds one command or option
The boundary test's constant lists most PRs Rebase on merge

Every other pair of PRs in one wave touches different files: A0 front-loads config/models.py, common/errors.py, events/run.py, the result fields and the engine.events and engine.evals namespaces in public_api.py, which were the files three tracks would otherwise have shared.

17. Release 0.7

  • release-please opens the release PR as usual. Releasing is the owner's act (see Releasing); no agent merges it.
  • docs/releases/0.7.0.md is written with the release-notes process. Its breaking-changes table covers: QueryEvent replaced by Event and the stream's new event names; settings.observability replaced by observers:; OpenTelemetry moved to the otel extra; the audit log's new place; engine.benchmark, nl2sql benchmark and chinook_gold.yaml replaced by engine.evals, nl2sql eval and the suite format; QueryResult.warnings typed and the new statuses; the store interface version 2; the new schema.datasource_example entries (run nl2sql index after upgrading); and the new default max_llm_calls: 60.
  • Before tagging, the CI gates are green, the realdb workflow is run on demand on main, and the owner runs nl2sql eval run --mode full on Chinook and routing with a key, once, and publishes the record.

18. Open questions

None blocking. Every decision above was made overnight and is marked for the owner to confirm; those the review of PR #308 changed are marked amended. A change to one is a change to the section it names and, where it says so, to the PR plan.

Waves B–D (preview)

Listed only so the interfaces chosen now leave room; none of it is designed here.

Wave B

  • glossary.yaml and the KnowledgeSource extension point (nl2sql.knowledge_sources).
  • A schema auto-profiler that drafts descriptions and glossary terms for review.
  • Clarify instead of guess: a structured clarification result in place of an answer.
  • Plan-to-English "why this answer", from the typed plan.
  • A cost guard: the adapter's estimate(sql) and a policy rule acting through Wave A's execute.before hook and approval.
  • A review queue for corrections (feedback turned into curated proposals).

Wave C

  • Conversation sessions, follow-up rewriting, and a chat tool registry for charts, diagrams and reports.
  • A chart spec chosen from a result's shape, and a dashboard generator.
  • Launchpad: nl2sql launch, the setup workflow with Connect a database.
  • MCP over streamable HTTP, with authentication.

Wave D

  • nl2sql-api /api/v1 parity with the facade (evals, approval, drift, events).
  • Jobs with SSE progress, /ready, the replay provider, and results bound to the user who asked (B13).
  • The web app on nl2sql-api, demonstrating everything.
  • Docker Compose for the whole stack.
  • The Textual terminal app.