Several databases behind one data system, declined¶
Status: accepted, and it decides two declines. Nothing here is built and nothing here asks to be built. It supersedes nothing and amends nothing: DataFusion for local execution said federation is explicitly later, and this record is what later than what turned out to mean once both cheap routes were run rather than reasoned about.
The goal it was asked about is one question spanning several data sources, initially several DuckDB
databases. Two routes promised to deliver it cheaply. Both are declined, for different reasons
and with different evidence, and what is built instead is decided in ADR 0007,
Federating across different data systems: every data source is first class behind the Warehouse
port, reached by the semantic compiler rendering that source's dialect, with standard credentialed
access; one source is a whole-query pushdown, several sources are per-source legs that DataFusion
combines. There is no DuckDB special case, and that is the sentence this record exists to
support with numbers.
A record of two declines is worth as much as a record of a decision, and for the same reason: the next person to reach for either route needs the evidence rather than the verdict. So the evidence is kept whole below, including the parts that were the argument for a route before they became the argument against it.
Which claims were run, and which were reasoned. Every measurement was produced by a throwaway
spike - crates with their own [workspace] table, outside this repository and not merged into it -
and re-run independently while this record was written. Where a claim was not executed it says so
in the sentence that makes it. That distinction is the whole credibility of this document, so it is
kept per claim rather than declared once.
And a third category, because "run" is not "reproducible" and this record used to blur them. The spike is not in this repository and cannot be reached from it, so for a reader "run" means executed twice by the author rather than checkable from here. A record whose credibility rests on the run/reasoned distinction owes the reader the version of that distinction which holds for the reader:
- Reproducible from this record alone. Every version resolution, from
cargo generate-lockfileover the requirements in the table below. Both compiler errors, quoted verbatim with the two versions in each graph named beside them. Every DuckDB transcript, because the statements are quoted and the library is the pinnedv1.5.5 (Variegata) d8cdaa33fdthe dev shell provides. The disallowed licence, because it is a registry field anddeny.tomlis in this repository. - Run, and NOT reproducible from this record. The four timings below, and the transposed aggregate. For the second - the finding that outranks everything else here - the exact ingredients are listed where it is stated, so it can be rebuilt from nothing but this page. For the timings there is no such recipe, and the reading beside them is narrowed to what survives that.
Context¶
The goal contained an ambiguity, and both declined routes lived inside it. A data system here is a connection and an identity, not a file. Two DuckDB database files opened by one process, under one set of file-system permissions, in one transaction, are one data system that stores its tables in two places. Two data systems are two logins, two policy engines and two answers to who is asking, which is the reason a plan resolves to exactly one of them.
Reading the goal the first way makes it cheap and makes it the wrong thing. Reading it the second way makes it federation. Three routes were available:
- Below the port. One adapter, one connection, several databases attached to it. Declined here.
- At the port. A source is a source: one adapter and one credential each, legs rendered by our own generator, something above combining them. This is what ADR 0007 decides and this record does not duplicate.
- Above the port, by a federation layer. A crate finds the largest subplan each source can run, pushes it there, and combines the results. Declined here.
The first decline: a federation layer returned a wrong number¶
This goes first because it outranks every other argument in this document, and because it would not have been found by reading manifests.
At DataFusion 54, with a federated DuckDB adapter written exactly as the crate's own example writes one, a single-source aggregate over one database came back with two columns transposed. Run, not reasoned - the statement the layer generated and the rows it returned, verbatim:
[sales.duckdb] SELECT sum("orders"."amount") AS "total", "orders"."rep_id" FROM "orders"
WHERE (...) GROUP BY "orders"."rep_id" ORDER BY "orders"."rep_id" ASC NULLS LAST
+--------+-------+ what the same question answers directly:
| rep_id | total | rep_id 10 -> 150.00
+--------+-------+ rep_id 11 -> 275.00
| 150 | 10.00 |
| 275 | 11.00 |
The unparser emits the aggregate column before the group column; the plan's schema has the group column first; and the layer's cast step casts by position. Its own doc comment says so - "It assumes the record batch columns are correctly ordered." Decimal and Int32 cast into each other cleanly, so nothing raised an error. Reconciling by name inside the adapter repairs it, verified in the same spike by a switch that does exactly that; nothing in the trait requires an adapter to do it, and the crate's own example does not.
What it takes to see it again, in full, so the finding does not rest on a spike nobody else can
open. The recipe rather than the code, because the code is six declarations and a main:
| Ingredient | Value |
|---|---|
datafusion |
54.1.0 - the major the federation crate requires, not the 55.0.0 this workspace pins |
datafusion-federation |
0.5.5, default features on, plus its SQL sub-crate |
duckdb |
1.10505.0, which brings arrow 58.4.0 |
| The adapter | SQLExecutor implemented as the crate's own example implements it: execute the string, hand the driver's batches straight back, and do not reconcile the batch schema against the plan's by name |
| The table | one DuckDB file, one table, one integer group column and one exact-decimal measure column, so each casts into the other without raising |
| The question | one aggregate grouped by that integer column |
The two column types are the load-bearing part and they are not exotic: an integer group key beside an exact decimal measure is the ordinary shape of every metric in this repository's own corpus. Make the group key a string instead and the defect stops being a transposition and becomes a null measure column, which is the second transcript below.
With both tables in one database file the whole query fuses into one pushed statement, and then the shift is total:
+--------+--------+-------+
| region | orders | total |
+--------+--------+-------+
| 4 | 425 | |
| 1 | 75 | |
The group column now holds counts, the count column holds sums, and the measure is empty, because a region string cast to a decimal is null.
For a system whose whole product is an answer certified under a definition digest and carried with provenance, a wrong number is worse than a refusal, and this one arrives with the digest attached. That is the argument. What follows is why the route would have been expensive even without it.
What was measured¶
The pins are Cargo.toml and Cargo.lock; the resolutions are cargo generate-lockfile on
throwaway crates; the DuckDB transcripts are the duckdb the dev shell provides, which is the
version the adapter links.
| Fact | Value |
|---|---|
| DataFusion, pinned | 55.0.0, default-features = false, features parquet and datetime_expressions |
| DataFusion, latest published | 55.0.0 |
| Arrow, in the lock | 59.2.0 under DataFusion and 58.4.0 under duckdb. Two majors, already |
| duckdb crate, pinned | 1.10505.0, which requires arrow ^58, not optional |
| DuckDB library | v1.5.5 (Variegata) d8cdaa33fd |
sqlparser in the lock |
absent. The dialect layer is polyglot-sql 0.9.2 |
datafusion-federation, latest published |
0.5.5, requiring datafusion ^54 |
datafusion-federation, repository main |
the same requirement, 54 |
datafusion-table-providers, latest published |
0.13.1, requiring datafusion ^54.0 and arrow ^58.0 |
| Its DuckDB provider | requires duckdb =1.10505.0 - our exact pin - plus sea-query, snafu, secrecy, r2d2 |
What the resolver does with it, and what the compiler then says¶
| Probe | Packages | What arrives |
|---|---|---|
| The pinned DataFusion and its two features, alone | 239 | no sqlparser, no datafusion-sql, no bzip2 |
The same, plus datafusion-federation = "0.5" |
334 | datafusion 54.1.0 and 55.0.0; arrow 58.4.0 and 59.2.0; datafusion-sql 54.1.0; sqlparser 0.62.0; bzip2 0.6.1 |
DataFusion held at 54.1.0, federation with default-features = false |
286 | one DataFusion, and still datafusion-sql 54.1.0, sqlparser 0.62.0, bzip2 0.6.1 |
Two DataFusion majors resolve rather than conflict, and then do not compile. Cargo is content: they are different semantic majors, so it links both. The compiler is not, and the message is the finding - built, not reasoned:
error[E0308]: mismatched types
| let _ctx = SessionContext::new_with_state(state);
| expected `SessionState`, found a different `SessionState`
note: there are multiple different versions of crate `datafusion` in the dependency graph
| datafusion-55.0.0/src/execution/session_state.rs this is the expected type
| datafusion-54.1.0/src/execution/session_state.rs this is the found type
The SQL parser comes back, and the third row is why holding DataFusion back does not avoid it.
Federation depends on datafusion with default features, and features unify across a build graph,
so the sql feature is on whatever this workspace asks for. Cargo.toml turns it off deliberately,
and its comment is the strongest sentence in that file: not calling a parser is a convention, not
having one is a property. Adopting federation compiles datafusion-sql and a second SQL parser -
sqlparser 0.62.0, beside polyglot-sql - into the shipped binary.
A licence the allowlist does not carry comes back with it. The default features include
compression, which brings bzip2 0.6.1 and through it libbz2-rs-sys, whose registry licence
field is bzip2-1.0.6. deny.toml allows eight licences and that is not one of them, and
unused-allowed-license = "deny" means the list is exactly the graph rather than a wish list. Not
run: cargo deny over the probe. What was checked is the licence string and the allowlist.
What the pushed-down half executes, and what it does not prune¶
SQLExecutor's executing method takes one string:
fn execute(
&self,
query: &str,
schema: SchemaRef,
filters: &[Arc<dyn PhysicalExpr>],
) -> Result<SendableRecordBatchStream>;
There is no parameter list, and DataFusion's own unparser produces the string. So the statement that
executes is rendered by DataFusion rather than by sutura-sql and no golden covers it, and every
value from the question is inside that string as text, because there is nowhere else for it to be.
The second of those is a row in the invariants table and it is not a row that bends. The first is the
worry ADR 0003 recorded from the other side: that unparser has known dialect defects, which is why
rendering for a remote data system stayed with the dialect layer. It is also the line ADR 0007 draws
between a combiner and a generator: DataFusion may combine, and never generates.
Observed in the same run, and worth recording because it is the opposite of what a federation layer
is assumed to buy: it did not prune the projection below its own boundary. The cross-source leg
shipped SELECT "orders"."order_id", "orders"."rep_id", "orders"."day", "orders"."amount" when only
two of those four columns were needed above it.
Handing the driver's batches to the engine does not compile¶
The obvious shape for a source-per-provider route, and at these pins a type error. Built, not reasoned:
error[E0308]: mismatched types
| ctx.register_batch("t", first)?;
| expected `datafusion::arrow::array::RecordBatch`, found `arrow::array::RecordBatch`
note: there are multiple different versions of crate `arrow_array` in the dependency graph
| arrow-array-59.2.0/src/record_batch.rs this is the expected type
| arrow-array-58.4.0/src/record_batch.rs this is the found type
Two bridges exist and both cost something. Arrow IPC is safe and copies every buffer on the way out
and again on the way in. The Arrow C data interface is version-stable across majors by design, and
at these pins there is nothing to call it with: the pinned duckdb crate has no
ArrowArrayStream surface at all - checked, zero occurrences in its source - only a stream_arrow
returning batches of the older Arrow. Obtaining a C stream therefore means raw FFI through
libduckdb-sys, and unsafe_code = "forbid" means first-party code cannot write it. Precise about
the Arrow side: its reader's try_new is safe and only from_raw is unsafe, so the ban bites on
getting the stream out of the driver rather than on Arrow's own API.
This constrains how a leg's rows come back, not whether the route is right. ADR 0007's shape
sends each leg through sutura-sql and its own adapter, so what crosses the port is what the port
already carries; this gap is about putting the driver's own Arrow into the engine's session, which is
one implementation of that and not the only one.
The numbers, on five million rows¶
Same question, same rows, all four legs agreeing on the answer. Warm runs.
Read the build column before the numbers, because it decides which comparisons this table can carry. The first row is DuckDB's own release C++ executing inside the library; the other three are first-party Rust compiled without optimisation. That is not a footnote, it is the reason the time column is not a finding: a debug Rust leg against a release C++ leg measures the compiler profile at least as much as it measures the route.
| Leg | Built as | Time | Peak resident |
|---|---|---|---|
| Several databases attached to one DuckDB connection | release C++, inside the library | 0.01s | 65 MiB |
| A provider per source, each pushing its own filters | debug Rust | 0.10s | 123 MiB |
| The federation layer, at DataFusion 54 | debug Rust | 0.09s | 119 MiB |
| A provider per source, pulling whole tables | debug Rust | 0.46s | 767 MiB |
There are two readings here and only one of them is a measurement.
What this table does establish is the comparison among the three Rust legs, which were built identically and are therefore comparable to each other. Federation buys nothing over a hand-written pushdown: the two middle rows are within noise of one another on both columns. And pulling whole tables is the one bad row - six times the memory and five times the time of pushing the filters down - bad in the way that matters, because it is the row that pulls rows into this process.
What it does NOT establish is attaching against the rest. Nothing here supports "an order of magnitude faster", and an earlier version of this record said exactly that while ADR 0007 quoted it back as a finding. Withdrawn rather than qualified. The honest claim is that attaching buffers the least - 65 MiB against 119 to 123 MiB - and peak resident set is the column where the profile mismatch does least damage, because what dominates it is the data a route holds rather than the instructions it took getting there. Even that is an indication and not a measurement, since an unoptimised build allocates more of its own, so it is stated as a floor worth knowing and never as a ratio. Its time is not comparable to the other three at all, and no number derived from that cell may be quoted anywhere.
Rerunning the three in release is the cheap way to make the time column mean something, and nobody has. Until somebody does, a claim that attaching is faster is unsupported by this record - which is the state a reader should be left in rather than a ratio to repeat.
And a hand-written pushdown fails silently. The same provider, against a column typed
TIMESTAMP instead of DATE, emitted SELECT * FROM orders for five million rows: 0.54s and 896
MiB, the right answer, no error, no warning. Its renderer returns "no SQL" for a literal type it does
not cover, and "no SQL" means the filter is simply not pushed. Reproduced. That is a hazard for
anything that renders per source, ADR 0007's track included, and it is why "the filter was not
pushed" has to be observable rather than a performance mystery.
What attaching does, verified¶
Recorded in full, because a decline that did not establish the thing works is not a decline, it is a guess. Two database files, each attached read-only, DuckDB 1.5.5:
- A join across them returns the right rows, and its plan shows both filters and both projections pushed into the two scans.
- A write through a read-only attachment is refused:
Invalid Input Error: Cannot execute statement of type "INSERT" on database "src_a" which is attached in read-only mode! - A prepared statement with bind parameters spans them.
- Databases can be attached with no file at all, in memory, which is what made it attractive as a
fixture:
ATTACH ':memory:' AS mem_aalongsidemem_bgives two catalogs in one process with no path on disk. - An unqualified table name does not resolve.
Catalog Error: Table with name regions does not exist! Did you mean "src_b.regions"?
That last one matters, because a statement this repository generates says FROM "orders" and nothing
else: a table in a plan is one identifier and the generator force-quotes it. Two repairs exist and
they differ in the way that would have decided between them: SET search_path works, and with the
same table in two attached databases it answers from the first one silently - reproduced, two
databases each holding orders, and the aggregate is the first one's rows with no diagnostic
anywhere. One view per model in the session's own catalog also works, and a second CREATE VIEW of
the same name fails loudly: Catalog Error: View with name "orders" already exists!
And the finding that made the route look free: the golden statement runs unchanged. Taken verbatim
from crates/sutura-cli/tests/snapshots/subscription-months-by-region-and-term__statement.snap, its
? placeholders and its LIMIT 10001 included, executed over views on two separate database files:
the right three rows. Not one byte of generated SQL differs from the single-database case.
So the route works, it is cheap, and it changes no generated SQL. It is still declined, and the next two sections are why.
The second decline: attaching forecloses the property that is the product¶
AGENTS.md's first paragraph says end-to-end impersonation is the point of this system. That has since hardened from an aspiration into a requirement, and the requirement is per source rather than per dataset:
Every source declares how it establishes the identity a query runs as, as a closed two-variant
SourceIdentity.ImpersonationAtSource- the query reaches the data as the asking subject - is the strong posture.SharedServiceUser- one identity for every caller - is the weak one, and it is permitted in both deployment modes: in single-user operation it is simply correct, because the one configured credential is that one user's, and in multi-user operation it is reachable only where an operator acknowledged that specific source, through a required per-source key thatSettings::refusalschecks at boot, with the operator's stated reason printed at startup beside the posture.
This record used to state it as "permitted only in a single-user role that reads everything", and
that was wrong rather than merely narrow. ADR 0008 and
Pluggable by declaration own the posture, and both permit an
acknowledged SharedServiceUser source in multi-user operation. The sentence is corrected here
rather than qualified, because two records disagreeing about what a deployment may do is worse than
either version of the rule. What survives from the old sentence is the part that was doing the work:
the weak posture must be arrived at on purpose, and it must be visible in the answer rather than
inferred from a deployment diagram.
Nothing about data sensitivity is declared here. This record previously argued the decline from
"the critical datasets are read under the asking user's own credentials in every case", which is a
per-dataset classification that the plan withdrew: what a
person may see lives in the data catalog and in that person's own permissions at the source, and a
classification authored here would be a second opinion about somebody else's authorization. So the
governing sentence is now an operator obligation and not a check - critical data goes on a source
declared ImpersonationAtSource - and what sutura mechanises is that SharedServiceUser is
unreachable by accident and unreachable in silence.
None of that changes the cost this section prices, and that is why the correction is safe to make here: a route where no leg can EVER run as the caller fails the requirement at every resolution, per dataset, per source or per deployment. ADR 0008 is the record of the requirement and this section states only what the route in front of it costs.
Against that, the three routes are not equivalent, and the difference is structural rather than a matter of effort:
| Route | Can a leg ever run as the subject who asked? |
|---|---|
| Attaching several databases to one connection | No, structurally. One process, one connection, one operating-system identity for every attached file. ATTACH takes no credential, and a networked scanner's credential would be a connection-global secret rather than a per-request one |
| A federation layer | No. SQLExecutor::execute receives a string, a schema and physical filters, and no session. Worse for this purpose: the layer's fusion key is the adapter's compute_context, so identity would have to be inside that string or two subjects' sub-plans could fuse into one - observed fusing in the spike when two sources shared a context |
| A source per adapter | Yes. A whole plan - never a fragment - crosses the port to one adapter that owns one connection, which is where a per-request credential belongs. ADR 0007 decides which plan shapes those are and the property that matters here holds for all of them: one plan, one source, one credential. Within the engine the same holds by a second route: TableProvider::scan receives state: &dyn Session, so per-request extensions are reachable. One hazard to design around rather than discover: ExtensionOptions requires an entries method that renders its values, which is exactly the shape Secret exists to prevent |
So the cheapest route forecloses the headline property, and the weak posture is the only one it
could ever declare. That was survivable while the goal was one person on a laptop, and it stays
correct for a single-user deployment - which is the mode examples/single-player is, and a first-class
one rather than a degraded one. It is not survivable as an architecture, and the reason is narrower
than the sentence this paragraph used to carry: not that some datasets are classified critical, since
nothing here classifies data, but that a source served under one identity for every caller is a
posture an operator has to be able to choose per source - and attaching removes the choice. Every
attached database inherits the one process identity, so a deployment that wanted one source
ImpersonationAtSource and one SharedServiceUser has no way to say so, and no way to record which
posture produced an answer. A connection with one operating-system identity cannot be told who is
asking, so it cannot be the strong posture for anything.
There is a second, sharper version of the same point, and it is the one that decides the case rather than merely weakening it. The cheapest next step from attaching two local databases is attaching a Postgres: one extension, no new Rust, the same code path. That step is the composing adapter with a synthetic source name that this record refuses in advance below. Under it a catalog declares one source, the plan names one source, the plan-stage set has one member and passes, and a plan cannot silently span two sources reads green over a two-identity question. Attaching two local files is harmless. Attaching a Postgres is the refused shape, and the distance between them is one line of configuration.
For completeness, since it was the blocker while the route was still a candidate: a DuckDB
connection is Send and not Sync - it holds a RefCell, and a compiler error says so - while the
service requires its warehouse to be Send + Sync + 'static. A shared connection was therefore never
the served shape without a mutex that queues every concurrent question or a pool that re-attaches per
connection. It is a note rather than an argument now: the route is declined on identity, not on a
trait bound. The bound does not go away, though. Every connection-holding adapter meets it, so it is
inherited by the network adapters ADR 0007 plans rather than retired with this decline.
The third decline: it is not a good test fixture either¶
Attaching two in-memory databases is a genuinely attractive fixture: no container, no daemon, no temporary directory, and 0.01s. It is still the wrong one, and the reason is the same reason the architecture declines it.
A fast-tier test that attaches two databases to one connection exercises a shape this system does not ship. Two DuckDB connections - one per source, one adapter each - is precisely the production shape of ADR 0007's several-source track, in the dialect that already has goldens. A test whose setup differs structurally from production is the kind of coverage that reads green while proving something else, and this repository has already paid for one of those: an engine that named itself after the catalog's declared source made a source comparison vacuously true, and the test that now covers it records that the call previously succeeded because the file was there and nothing else failed either.
Two things make the choice easier than a purity argument would:
The production-shaped fixture needs no new adapter code, and the attached one does.
DuckDbWarehouse::open, ::in_memory and ::attach_csv already exist, so two connections over two
in-memory databases with a CSV view each is a fixture written entirely in a test. Attaching needs a
new attach_database affordance, a view registration per model that uses CREATE VIEW rather than
the existing affordance's CREATE OR REPLACE VIEW, and a collision refusal behind it - new
production code, kept narrow, for a shape nothing ships.
And neither is needed yet. The two-source refusal is already provoked by fixtures built in Rust
with no data system at all, in crates/sutura-app/tests/support/oracle.rs and
crates/sutura-http/src/testing.rs, and that file mentions DuckDB zero times. A DuckDB-backed
multi-source fixture becomes useful the moment there is a combiner to test, which is ADR 0007's work,
and at that moment the right one is two connections. Building the attached one now would be work that
gets thrown away, which is the same conclusion from the other end.
What is declined, precisely¶
datafusion-federation is declined at these pins. First because it returned a transposed
aggregate with no error, under a cast step documented to trust column order and an unparser that does
not preserve it. Then because it does not support the DataFusion this workspace runs, and the two ways
to make it fit are to hold the engine at a major a contrib crate chooses for us or to link two
engines, which does not compile. And then because it brings a second SQL parser and a disallowed
licence into the binary, executes a statement rendered by somebody else with the question's values
inlined in it, and did not even prune the projection below its boundary.
Attaching several databases to one connection is declined as an architecture, including for DuckDB, including in single-user operation, and including as a test fixture. It forecloses per-subject impersonation structurally; its cheapest continuation is a shape that makes a two-identity question read green; and as a fixture it is more new production code than the production-shaped alternative for less relevant coverage.
What is built instead is not decided here. ADR 0007, Federating across different data systems,
decides it: every data source first class behind the Warehouse port, the semantic compiler rendering
that source's dialect through polyglot-sql with bind parameters and forced quoting intact, standard
credentialed access per source, one source pushed down whole and several sources combined by
DataFusion above the legs. No DuckDB special case, and no exception for the local file that started
this.
None of the six single-source assumptions is relaxed by this record. It declines routes; it moves no check. What each track of ADR 0007 forces, and in what order, is that record's business.
Where the one-source assumption lives, and what would replace it¶
Kept because it is what any future federation work has to move, and because it was surveyed for this record rather than for that one.
A survey of the tree found six independent single-source assumptions, and the refusal everybody
names is neither the first nor the most important. In the order a question meets them: the startup
refusal in both composition roots, which rejects a catalog naming more than one source; the
compile-time engine-source constant the declared source must equal; the service holding exactly one
warehouse; the plan stage's source set (one is a mono plan, exactly two are split into legs, three or more refuse as PlanSpansTooManySources); the plan having one
source field while a join carries only a table name; and answer refusing when the plan's source is
not the warehouse's.
In either shipped binary the plan-stage refusal is unreachable. The startup refusal fires first, before a socket is opened, so a deployment cannot load a two-source catalog at all. Deleting the plan-stage check would have no observable effect on a deployment - worth knowing precisely so that nobody reads it as permission.
Removing it would produce a wrong answer rather than an error. The plan stage collects the joined
model's source into its set and then throws it away: the plan is stamped with the metric's own model's
source alone. So answer's guard compares one name against the same name and passes, and the engine
resolves the joined table in a flat, unqualified namespace out of the one data directory. If the file
is there, the engine joins it and returns rows under the metric's certified name and the bundle's
digest, with the second source having decided nothing at all. Same failure class as the federation
defect above, arrived at from inside our own code.
The startup refusal, the one that actually holds today, has no test. Its wrong-name branch is covered; its more-than-one branch is not. By this repository's own standard that is a mechanism nobody has watched fail, and it is worth a test whichever route eventually lands.
And what replaces the rule when it does move: the plan stage's set stops holding source names and
starts holding the identity each source resolves to, refusing unless exactly one is in it. Two sources
that resolve to one subject are then allowed and two subjects are refused, which is the property that
was always meant. That check cannot be written today, and the reason has narrowed since this was
written: the types now exist - sutura_domain::identity holds the principal chain, the request context and the
CredentialBroker port, and Warehouse::execute takes a credential minted for the source it reads.
What is still missing is a second identity for two sources to resolve to: no adapter in this build can
carry a per-subject credential, so every leg presents the identity this deployment holds for that
source and a set keyed on identity would hold one element for a reason that has nothing to do with the
asker.
The shape refused in advance¶
Named so it cannot arrive unnoticed, and cited by ADR 0007 as the hazard at the end of the attach route: an adapter that composes two others, implements the port itself, and reports a synthetic source name. It passes the startup check, it passes the plan stage, and it executes a two-identity question. Nothing mechanical forbids it. What this record does is refuse it in advance and say why: it produces precisely the state the one-source rule exists to prevent, while every check reads green.
What does hold, mechanically, is narrower and worth stating beside it. A plan cannot represent two
sources: the plan holds one source name, and a join and a column hold none, so a table in a plan
carries no source at all. Making a two-source plan representable means changing a domain type whose
serialized form is pinned by snapshots, so the first commit of any such attempt moves the 21
plan@markdown goldens.
And a correction this record owes, because it claimed a stronger mechanism than exists. An earlier
version of this paragraph said the definition digest is taken over that same serialized form, so such
a change "moves goldens and moves the digest". The second half is false. DefinitionDigest::of
takes the Definitions and the Knowledge and nothing else, so the digest covers what a catalog
authored and not what the compiler decided; QueryPlan is not in it, and no change to QueryPlan
moves a digest. The two are pinned by different snapshots on purpose - the plan by plan@markdown,
the digest by catalog_digest@markdown - and conflating them made a plan-shape change sound like a
provenance change. What is true and is the point that survives: the digest covers every model's
source, so a catalog that moves a model to a second system already produces a different digest,
which is a fact about the catalog rather than about the plan type.
The tool surface: there is nothing to widen¶
A federated question is the same question. The caller names a metric; the metric names a model; the model names a source; a dimension's relationship names a target model which names a source. Every source is derived from the pinned bundle, and the caller has no field that could name, add, remove or hint at one. So AGENTS.md's row for a new or widened tool input does not apply, now or when real federation lands. Provenance does not change either: the digest already covers every model's source, so a catalog that moved a model to a second system already produces a different digest.
One limit, stated so nobody over-claims in the other direction: the mechanism that row cites - dumped
tool schemas and a byte-compare - is not built. schemars is not a dependency, and AGENTS.md's own
canonical-sources table marks it planned. What is built and does hold is deny_unknown_fields on both
the domain query type and the wire body, with a test that provokes it.
What does not change¶
| Guarantee | Still held by |
|---|---|
| A plan resolves to exactly one data system | The plan stage's source set, unchanged and untouched by this record |
| No value from a question reaches the statement as text | Unchanged, and it is the specific thing the declined federation route could not offer: SQLExecutor::execute takes a string and no parameter list, while a GeneratedQuery keeps statement and parameters in separate fields |
The executed SQL is owned by sutura-sql |
Unchanged, and it is the line ADR 0007 makes load-bearing: DataFusion may combine legs and never generates one |
| We never translate SQL, and the one thing we parse is parsed at load | Unchanged, and nothing here parses anything. Precise about the declined route: its unparser is a generator rather than a transpiler, so what it would have broken is the ownership of the executed statement, not the transpile ban |
| A result that hit the row cap is refused, not truncated | Unchanged. Both declined routes would have raised the question of how an intermediate result is bounded; neither is adopted, so neither does. Where that question is answered is the plan, and the answer is a working set in bytes rather than a row count per leg |
| No result cache | Unchanged. Both declined routes buffer inside this process and the measurements above are how much. Not a cache, because nothing is keyed and nothing is reused across questions, and the honest way to say it is that reuse across questions is what would make it one, at which point AGENTS.md's rule applies and it is keyed on subject first or not at all |
| Adding a data system is a registration, not a test edit | Unchanged, and narrower than it reads: the test matrix's axis is which adapter, one warehouse per cell, one source name shared by all of them. A second SOURCE is a signature change on the harness, which is one of the costs ADR 0007 carries |
| The domain acquires no framework dependency | Unchanged. This record adds no dependency at all |
| Refusal is a result, not an error | Unchanged. Nothing here adds a refusal or a failure mode |
| Every query runs as the calling principal | Not held anywhere today, and this record's contribution is to say which routes could ever hold it. Attaching cannot, a federation layer cannot, a source per adapter can |
Consequences¶
- The roadmap's stated approach is declined and so is the cheap alternative. What remains is the more expensive shape, and the value of this record is that the expense is now a known quantity rather than a suspicion: a wrong number from one route, a foreclosed product property from the other.
- The measurements survive the decline and are the baseline for the route that is built - the memory column of them. 65 MiB for attaching is the floor any per-source shape is measured against; 123 MiB is what a rendered-per-source leg buffered on the same question; 767 MiB is what pulling whole tables costs, and it is the number to quote whenever pushdown per leg is described as an optimisation rather than a requirement. The times are carried in the table for completeness and are not a baseline: three of the four legs are unoptimised builds and the fourth is not, so the only valid time comparison is among the three, where a rendered pushdown and the federation layer are within noise and pulling whole tables is five times either.
- A silent-degradation hazard transfers to the route that is built. A renderer that returns no SQL for an uncovered literal type does not fail; it stops pushing. Measured at 0.54s and 896 MiB with a correct answer and no diagnostic. Anything rendering per source needs that to be observable.
- The Arrow major gap is a live constraint on one implementation of the built route. Putting the
driver's own batches into the engine's session does not compile at these pins, and the two bridges
are a copy per buffer or raw FFI that
unsafe_code = "forbid"bans. It does not constrain sending a whole rendered leg to an adapter, which is what the port already carries. - Two costs are inherited rather than retired. Which artifact links a native driver - nixpkgs has
no musl
libduckdb, and the cross-built artifacts link the engine alone for that reason - and how a serving surface holds a connection that isSendand notSync. Both belong to any connection-holding adapter, so ADR 0007 carries them; declining the attach route did not remove either. - The multi-source fixture is two connections, not two attachments, and it needs no new adapter code. Until there is a combiner to exercise, the existing Rust-built two-source fixtures are sufficient and they touch no data system at all.
- A multi-source corpus belongs in
examples/multi-player, not in the single-player one. Moving a model in the existing corpus to a second source was costed and is not viable as an incremental step: the definitions and digest snapshots move, every question joining the moved model flips from planned to refused, and the SQL golden count drops, which moves a sentence in AGENTS.md that a gate counts. Superseded for the directory, not for the reasoning:examples/multi-playerwas later taken for the served-deployment DataHub example (docs/adr/0016's amendment, issue #202), so the multi-source corpus this entry prices still has no home and is unbuilt - it awaits the federation workdocs/adr/0007lists, which is where the "two connections, not two attachments" reasoning above continues to apply. - The engine stays behind the port for now. AGENTS.md says it belongs above the port once federation lands, and that stays a design target whose terms ADR 0007 sets. What "above the port" means concretely: the port stops carrying a whole plan and a finished aggregate and starts carrying a fragment and intermediate rows, which gives up the property that push-down is complete today, and the part that is not pushed down becomes ours to filter. The measured 767 MiB row is what that looks like when it goes wrong.
- A join that is not pushed buffers a side whole, by design. The engine's hash join reported
mode=Partitionedin the spike's plans, and the alternative it chooses elsewhere collects one side entirely. That is not a defect; it is precisely AGENTS.md's row about anything that stores or forwards rows, which has no mechanism and is a human review question. Flagged rather than resolved, and it belongs to whoever builds a combiner.
What is explicitly not decided¶
- What is built instead. ADR 0007 decides the shape and the order; nothing in this record should be read as scheduling it.
- The impersonation requirement itself and its roles. ADR 0008 is that record, and ADR 0009 settles the part it got wrong: nothing here classifies data, because the catalog and the asking person's permissions at the source do. This one only says which routes can and cannot satisfy it.
- Which upstream release closes the Arrow gap, and which closes the version skew. They are two
different events and this record used to name a third that is neither. An earlier version said the
thing to watch was "whether DataFusion 56 moves to Arrow 59" - which the table above already
contradicts: the pinned
datafusion 55.0.0is already onarrow 59.2.0, and a DataFusion 56 would move nothing here. Corrected, fromCargo.lockrather than from memory:- The Arrow gap is the
duckdbcrate. It is pinned at1.10505.0, it requiresarrow ^58non-optionally, and that is wherearrow 58.4.0in the lock comes from. So what removes the bridge problem is theduckdbcrate reaching Arrow 59 - or, symmetrically, this workspace holding the engine at 58, which is a downgrade of the thing that executes every query and is not a trade anybody has argued for. That gap constrains one implementation of the built route and nothing about whether the route is right. - The version skew is
datafusion-federation. It requiresdatafusion ^54on the registry and on its repository main alike, while this workspace pins55.0.0. What removes that is the crate following DataFusion forward, and it is independent of the Arrow question. Nothing here predicts either, and the wrong-number finding would still stand after both: the transposed aggregate is a cast-by-position defect in the layer, not a version mismatch, and it is what the decline actually rests on.
- The Arrow gap is the
- Whether the column-order defect is reported upstream. The repair is to reconcile by name, and the ingredients for rebuilding it are in the recipe above, so a report is a matter of somebody spending the afternoon rather than of anything being unknown. Stated as narrowly as this record can honestly put it: at these pins, with an adapter written the way the crate's own example writes one, the layer returned a transposed aggregate. Whether that becomes an issue, a pull request, or neither is a choice about how this project spends its time, and no claim here depends on the answer.
- Whether the engine ever reads DuckDB storage directly. At these pins it cannot, and the two bridges are above. The C data interface route additionally has nothing to call it with today, which makes it a driver question rather than a design one.
- Whether to fork or vendor the federation crate. Not attempted. It would mean owning an optimizer rule in the query path, and after the fork both the unparser's dialect defects and the column-order repair would be ours.
- Whether per-model file locations are worth having for the engine. It is one source with several paths rather than several sources, so it forecloses nothing and decides nothing here; it is a configuration question for whoever wants it.
- Anything that stores or forwards rows. AGENTS.md says nothing mechanical governs it and that it is a human review question rather than an agent's to certify.
Alternatives considered¶
Adopt the federation crate and hold DataFusion at the previous major. The honest version of "just use it", and the only one where a single engine links. Rejected first on the transposed aggregate, which is a wrong number rather than a cost. Then on the rest, all measured: the engine that executes every query would sit on the version a contrib crate happens to target; the graph grows by 47 packages; and the SQL parser and the disallowed licence arrive anyway, because feature unification does not care that we asked for neither.
Adopt it alongside the pinned DataFusion. Resolves, then fails to compile, with two SessionState
types in one graph. The error text is above.
Fork it onto the pinned DataFusion. The only route that keeps both the current engine and the crate's optimizer rule. Rejected: it puts a fork of a query-path optimizer under our maintenance, and after the fork the column-order repair and the unparser's dialect defects are both ours.
Use the contrib DuckDB table provider. Attractive on one detail - it pins the same duckdb version this workspace does - and rejected on the rest: the previous DataFusion major, the previous Arrow major, and a normal dependency list that adds a third SQL builder, a second error library and a second secret type to a workspace with considered opinions about all three.
Attach several databases to one connection and serve it. The lightest measured route, working, with
no change to any generated statement. Rejected on impersonation: one connection is one
operating-system identity and ATTACH takes no credential, so every attached database has the same
posture and it is the weak one - the deployment cannot declare a posture per source, cannot be told who
is asking, and cannot record which posture produced an answer. Its cheapest continuation is the
composing adapter with a synthetic source name, which is refused in advance above.
Attach several databases only in the fast test tier. Tempting, and rejected on the shape rather than on the cost: the fixture would exercise something this system does not ship, while two connections are the production shape, need no new adapter code, and cost almost nothing more. The existing Rust-built fixtures already cover the two-source refusal in the meantime.
SET search_path instead of one view per model. The sub-decision inside the attach route, recorded
because it is the kind of thing that gets reached for again: with the same table in two attached
databases it answers from the first and says nothing, where a second CREATE VIEW fails loudly. A
silent choice of source under a certified metric name is the class of failure this repository refuses
at the cost of a whole invariant elsewhere.
Export each database to Parquet and answer over the exports. Works today with no code at all, and worth naming because it is the baseline every option here has to beat. It fails on freshness and it doubles the storage, and a copy is read under whoever made it - the same objection ADR 0003 raised against an acceleration layer, arriving by hand. It also fails the impersonation requirement for the same reason attaching does.
Relax the plan-stage refusal and let the engine sort it out. The change the goal's framing invites. With both guards gone the plan carries only the metric's own model's source, the engine resolves the joined table in a flat namespace out of one directory, and a question reaching across two nominal sources is answered under the metric's certified name and the bundle's digest. Not a refusal, not an error - a number. The same failure mode as the federation defect, arrived at from inside our own code, which is why both are in this record.