A table outside the connection's own dataset¶
Status: accepted. Built, gated and pinned by goldens across four dialects, and a qualified
read is proved against a real project - a project.dataset.table path is resolved by the service
and a wrong dataset in one is refused. A cross-PROJECT read is not proved, because the acceptance
credential's IAM refuses datasets.create and there was no second project to read across; What is
claimed, and what is not at the end is the section to read before taking that green for more than it
is.
0017 and
0018 got a statement of ours accepted by a real
BigQuery dataset. This record is about the gap between BigQuery works and BigQuery works
here: the deployment this ships into is multi-project, and a metadata document could not name a
table outside the one dataset its connection defaults to.
The report, and why the failure mode is a wrong number¶
Three things blocked it, all verified in the tree rather than inferred:
- A qualified name was unrepresentable.
catalog::Modelheld onetable: TableName, andTableNameparses throughparse_identifier, which accepts[A-Za-z_][A-Za-z0-9_]*and rejects a dot asIllegalCharacter. There was no field for a path and no value that would have passed. - The statement was emitted bare, for every dialect -
builder::select(..).from(table). - Resolution was a single connection-level default. The
BigQueryadapter holds onedefault_dataset, fixed when the warehouse opens and put into everyJobRequest, so an unqualified name resolves there and nowhere else.
The interesting half is what happens when the table exists in both places. A table absent from
the default dataset is a clean 404 - honest. A table of the same name present in the default
dataset is read instead, and a plausible number comes back under a certified metric with its
definition digest unmoved. And same-name tables are the normal shape of a multi-project estate:
dev/prod splits, per-tenant datasets and staging copies all reuse table names deliberately. So this
sits in the same class as a wrong date bucket - valid SQL, nothing raised, wrong answer - and that is
what decides every choice below in favour of a refusal over a best effort.
Decision 1 - a qualified name is a composition of parsed identifiers, never a string with dots¶
sutura_domain::model::qualified holds QualifiedTable, which is an Option<TableQualifier> and a
TableName; TableQualifier is an Option<ProjectName> and a non-optional DatasetName.
Relaxing TableName to admit a . would have been one line, and it would have silently
invalidated the reasoning two invariants rest on. No identifier reaches the statement unquoted
and no value from a question reaches the statement as text are both asserted over the golden corpus
by stripping quoted spans with a single toggle, and golden/shared.rs says in as many words why
that toggle is sound: an identifier is [A-Za-z0-9_] and nothing else, "which is what makes that a
fact rather than an assumption - so neither a " nor a ' can occur inside a name." A dotted string
would have put a whole path where those assertions expect one name, and nothing would have failed.
So each part is parsed by the parser for its position and the generator quotes per part.
QualifiedTable::parse is a splitter in front of the canonical constructor for the convenience of
whoever writes a catalog document - it splits on ., which no part can contain, and hands each piece
onward. Nothing stores the text it was handed.
Three consequences worth stating because each was a decision:
- A project without a dataset is unrepresentable rather than refused.
project..tableis not a thing any data system names, andTableQualifierhas no constructor taking a project alone and no optional dataset field. There is no check to get wrong. ProjectNameis the one name shape in this workspace that admits a hyphen, and it is not a loosening of the property above. ABigQueryproject id is lowercase letters, digits and hyphens- the credential this repository's acceptance leg runs under has one, which is how this was found
rather than assumed - while a dataset id is letters, digits and underscore, exactly
parse_identifier's set.model::Hyphensis a flag on one parser rather than a second parser, and its documentation states the licence precisely: a hyphen is not",'or`, so the stripping argument is intact. A variant admitting a quote character, a dot or whitespace would invalidate it, and that is written where the flag is. A legacy domain-scoped project -example.com:project- is refused, because a:or a second.inside one part is the defect this module exists to prevent. Serializeis hand-written to emit the dotted text.serde(try_from)affectsDeserializealone, and a derivedSerializehere would write a struct this type's ownDeserializerefuses - the asymmetry that shipped in this workspace onDate. It matters because the definition digest is taken over the serialized form: a derived one would have covered a field layout appearing in no catalog file. An unqualified table therefore serializes exactly as aTableNamedid, which is why no existing golden moved and no existing digest moved.
The authoring surface is the same table: key carrying one part, two or three. There is no second
key for an author to get out of step with the first, and every document already on disk loads byte
for byte.
Decision 2 - cross-project is NOT federation, and must not reach the splitter¶
A source is a credential plus a billing project, not a project. Two datasets, or two projects,
reached by one credential in one statement is one source, and neither the splitter nor
PlanSpansTooManySources (PlanSpansTwoSources when this was written, before 0007's
federation landed) must fire on it.
This is the most expensive thing in this record to get wrong, and it was live rather than
hypothetical: row 10's splitter and combiner were being built in parallel. BigQuery joins across
projects natively - one credential, one job, one engine - and pushes the join down. Routing that
through a splitter and a client-side combiner would replace a pushed-down join with a slower one and
discard exactly the pushdown that makes the adapter worth having.
So the mechanism is a shape rather than a check: sutura_semantic::plan collects SourceName and
nothing from any table path, and the paragraph saying why now sits at that collection rather than
in a document. PlanJoin carries its own QualifiedTable, so a fact table in one dataset joined to a
dimension table in another is one JOIN in one statement. What the source count still acts on is a
second credential, which is the case it was always for: two are split into legs, and more are
refused.
Decision 3 - how deep a path a data system resolves is declared, not guessed¶
Dialect::qualification returns sutura_domain::model::Qualification from an exhaustive match, on
the DateTruncShape and identifier_quote precedent - a fifth dialect cannot compile without
answering.
It has to be a declaration because the dialect layer will render three parts for any target: its
TableRef is a name plus two Options with no per-dialect arity check. So without this, a
project.dataset.table rendered for a target with no third position produces a statement that fails
at the data system or, worse, resolves the leading part as something else.
| Dialect | Resolves | Why that arm |
|---|---|---|
BigQuery |
project.dataset.table |
A first-class path. One credential reaches several projects, and a cross-project join is native. This is the reason the feature exists |
| Postgres | schema.table |
Cross-database is not a thing it does. Its three-part form is accepted only when the leading part is the database already connected to, so rendering one would work or fail on a connection detail no catalog can see |
ClickHouse |
database.table |
It has databases and no catalog above them. A rendering claim, not an execution one - nothing here executes ClickHouse |
DuckDB |
the table alone | The arm worth reading twice, because it is narrower than what DuckDB can parse. DuckDB has schemas and attached catalogs. This is declared for what a DuckDB deployment here can resolve: sutura-exec-duckdb registers one view per model in the default schema and sutura-exec-datafusion registers one file per model in its own registry, so a qualified name resolves to nothing in either. Widening this arm is a change to what those adapters attach |
A path deeper than the target is GenerateError::QualificationUnsupported, naming both depths.
Never a dropped qualifier - dropping the part that does not fit is the wrong-number failure at
the top of this record, moved rather than fixed. It is a GenerateError and not a RefusalReason
because no question a caller could ask produces one: a table path comes from a catalog document,
which is upstream.
The engine and the two binaries that link it refuse the same thing independently:
DataFusionWarehouse::scan before it consults the registry, and sutura-serve and sutura-cli
at boot, so a deployment that could not serve its bundle does not start.
No explicit table alias is emitted, and that is a declaration rather than an oversight. A column
is qualified by the table's bare name, and FROM a.b.c gives the reference an implicit alias of c
in all four targets. Saying so explicitly with AS "c" would be better, and it is not reachable
through the fluent builder this repository uses deliberately: left_join takes a &str and the
aliasing constructor is private, so it would mean hand-building the AST. The implicit alias is what
holds, the live run is what measures it on the target where a misquoted identifier is not a syntax
error, and sutura_sql::generate::table_path carries this paragraph beside the code.
Decision 4 - a label may not be spelled the same as a table the statement reads¶
Definitions::assemble already refused a dimension named after its metric or after the time bucket.
It now also refuses LabelShadowsTable: the metric's own name, TIME_BUCKET_LABEL and every
dimension name, against the metric model's table and every joined model's table.
This one is a report, not a prediction. The first live BigQuery submission returned 400
invalidQuery - "Cannot access field day on a value with type INT64" - because a metric label
equalled the table name and GoogleSQL resolved the qualifier in table.column to the select-list
alias instead of to the table. Same root cause as the unqualified table itself: a physical name that
nothing checked against the labels beside it.
It is refused for every dialect rather than for the one that reported it, because a rule about which of two things a qualifier binds to is precisely the kind of difference nobody should maintain per target - and the alternative outcomes across four dialects are a wrong number, a rejected statement and silence.
It checks every label against every table, which is stricter than the collision that has to bite.
A joined table is in the FROM only when a dimension reached through it is asked for, but any other
dimension can be asked for in the same question - so which pairs meet at query time is a function of
the question, and a load-time check that tried to predict it would sometimes let one through.
Refusing the cross product costs a catalog author one rename and cannot be wrong in the direction that
returns a number. The check reads the table's bare name, because that is what a qualifier binds
to: a metric named after the dataset collides with nothing.
Amended, because the first version of this check compared for equality and a review reproduced the
gap. GoogleSQL's lexical reference lists aliases within a query, column names and field names
as NOT case-sensitive - checked 2026-08-30 - while its table names are case-sensitive by default.
So a table named Orders is a distinct table, the qualifier Orders still resolves to a select-list
alias spelled orders, and an equality check let every such pair through to collide exactly as the
same-case one did.
The comparison now folds ASCII case, and it folds it for the two older checks too - a dimension named
after the time bucket, and a dimension named after its metric - because GoogleSQL documents a result
column's name as case-insensitive as well, so Period beside period is one column there. Neither
needed a new variant; what changed is the comparison.
Which rule to compare under is a decision rather than an implementation detail, and it is made where
the vocabulary is. sutura_domain::model::IdentifierCase is the vocabulary and
sutura_sql::Dialect::identifier_case is the per-dialect declaration, on the Qualification and
DateTruncShape precedent from Decision 3: the domain names what the difference is, a target answers
for itself, and a comparison decides. BigQuery is InsensitiveAscii from the reference above, and
DuckDb is too - measured on the pinned DuckDB 1.5.5, where a table created as a quoted Orders
is bound by a quoted orders qualifier and returns a result.
A bundle is dialect-agnostic, so both this check and Decision 5's compare under
IdentifierCase::COARSEST whatever a dialect declares. That makes the declaration a self-check and
not a barrier, and saying which is the point: a value declared Sensitive cannot make a bundle
unsafe, which is why Postgres and ClickHouse are declared from documented behaviour and not
measured - neither has a server in this repository to ask. What the declaration buys is
every_dialect_is_at_most_as_case_folding_as_the_catalog_assumes, so a variant folding more than
ASCII case fails a test instead of quietly making both comparisons too fine.
Decision 5 - two tables whose paths end in one name are refused, not aliased around¶
Decision 1 says a column is qualified by the last part of a path, because FROM a.b.orders gives
the reference an implicit alias of orders in all four targets. What Decision 1 did not say is what
happens when two of the tables in one statement end their paths the same way, and a review reproduced
it by changing one fixture: a fact table at analytics-prod.sales.orders joined to a dimension table
at reference-data.crm.orders rendered an ON clause reading orders.customer_id = orders.id - one
table compared with itself - with every projected column qualified by an identifier naming two tables.
A real DuckDB 1.5.5 answers that statement Binder Error: Ambiguous reference to table "orders"; a
target that binds it to one side instead returns a number under a certified metric name. Same-name
tables across datasets are the estate shape this record exists for, so it is reachable rather than
exotic.
Distinct explicit aliases are the fix that keeps the question answerable, and they are not reachable
through the builder this workspace renders with. Measured against polyglot-sql 0.9.2:
SelectBuilder::from_expr takes an expression, so the FROM side could carry an AS, but
left_join and every other join method take a &str table name and join_with_kind is private - so
the joined side cannot be aliased without hand-building a select expression with upwards of thirty
fields, which sutura_sql::generate's own header rules out. An alias on one side of a join and not
the other is not a fix.
So the decision is the other one, and it is made unrepresentable rather than checked at render
time: sutura_domain::plan::StatementTables is the only way to a QueryPlan or to a
LegPlan::Fact - QueryPlan::new takes it instead of a table plus a vector of joins, and the leg
variant holds one as a field - and its parse refuses two occurrences answering to one identifier
under IdentifierCase::COARSEST. There is therefore no ambiguous plan of either shape for any dialect
to render. sutura_semantic::plan turns that refusal into
RefusalReason::PlanTablesShareAnIdentifier, beside PlanSpansTooManySources, which is the other
refusal that stage produces about the shape of a statement rather than about anything a caller wrote.
A QUERY-time refusal and not a load-time one, which is the opposite of Decision 4 and deliberately
so. A colliding label costs its author a rename; a colliding table is a physical name nobody
here can change, and refusing the metric at load would make this record's own estate shape
unauthorable. So the metric stays authorable and only a question that actually puts both tables in one
statement is declined - a dimension on the fact table's own model is still answered, which
golden/service.rs asserts as the second half of the same test.
The comparison is over positions rather than over distinct paths, so the same table joined twice through two relationships is refused too: two occurrences under one identifier is a duplicate alias whether or not they name the same rows.
The limit this section used to state was the fact leg, and it was reached rather than left
hypothetical - which is why the correction is recorded here instead of the claim being quietly
widened. This record shipped saying the guard did not reach a LegPlan::Fact, that nothing outside
a test constructed a leg, and that "the change that gives a leg a producer is the change that should
route it through StatementTables". 0007's
splitter is that change, and the first version of it built both legs by struct literal: a federated
question with a same-source hop to a colliding table rendered
FROM ...sales.orders LEFT JOIN ...crm.orders ON orders.customer_id = orders.customer_id, measured
through generate_leg for Dialect::BigQuery. Worse than the whole-answer case rather than equal to
it, because a leg's rows are combined above it and nothing downstream sees the statement. Fact now
carries a StatementTables as its field, pinned by a compile_fail doctest with a compiling twin
(E0559 on table and joins), and golden/service.rs provokes the refusal through a three-model
catalog. A prediction in a doc comment is not a mechanism.
What remains is narrow: a LegPlan::Lookup reads one table and declares no joins, so it has no pair
to compare, and two LEGS whose tables collide are not this defect - each leg is its own statement on
its own data system, and what the combiner joins on is a label rather than a table qualifier.
What the goldens pin, and where¶
crates/sutura-app/tests/golden/qualified.rs, a third file on the dialect axis for the reason
golden/legs.rs is a second one: the question corpus cannot produce these. A question has no
field naming a table, so a path comes from a catalog document - and the shipped corpus under
examples/single-player is deliberately left unqualified, because the two data systems that
execute it register one file per model and would have nothing to resolve a project against.
Qualifying the corpus would have moved every existing golden and broken the executed axis in order to
demonstrate a rendering.
Three fixtures - a dataset qualifier, a project above a dataset, and that fact table joined to a
dimension table in another project - expanded over every registered dialect. Each cell handles
both outcomes, read off Dialect::qualification rather than listed, so a fifth dialect is covered
without an edit. Five statements carry the row cap, which moves AGENTS.md's golden count from 88 to
93.
What is claimed, and what is not¶
Claimed, and measured against a real project on 2026-08-31. The acceptance leg reads the same
table three ways in one test - unqualified through the job's defaultDataset, then dataset.table,
then project.dataset.table - and all three answer 42 for June and 99 for July. So a qualified path
both renders and resolves, and the implicit-alias declaration in Decision 3 holds on the target
where a misquoted identifier would not have been a syntax error.
The negative control is what makes that a measurement rather than an inference, and it is the
half worth insisting on: the same table name asked for in sutura_no_such_dataset.<table> is
refused. Without it, a service that ignored the qualifier and resolved the last part in its
default dataset would have produced three identical green legs while proving nothing - which is this
record's own opening failure, one layer further out.
Not claimed, and each of these is a gap rather than a hedge:
- No second dataset was reached, and no second project. Measured rather than assumed: the
acceptance service account can see one dataset, and
datasets.insertreturns403 accessDenied ... does not have bigquery.datasets.create permission, so neither a second dataset nor a second project could be created to read across. What that leaves unproved is narrower than it sounds and is worth stating exactly: the three-part path is proved to resolve - the qualifier is read, and a wrong dataset in it is refused - and what is unproved is a read whose leading part is a project the credential does not already bill to. That is an IAM grant rather than a code change; the statement is identical in shape either way. It is the one thing to re-run when a second project exists. - A cross-project JOIN was not executed. It is rendered, parse-checked, and pinned as one
statement with one
JOINand oneSourceName- which is the design claim of Decision 2 - but no local test can show a service performing it, and none pretends to. - Nothing about the corpus. The live legs are hand-built statements, exactly as 0018 says of its own.
- Nothing about identity. A service-account key is
SharedServiceUser. Cross-project reads under a per-subject credential are 0008's business, and this record touches none of it.