The asymmetry
ExternalRef's doc comment refuses expansion with a stated reason: "expanding it would join a table this module does not own. Fetch the other side through that module's own API." The DSL takes module isolation seriously enough to make it a refusal.
Computed + FromSQL accepts the identical coupling with no comment: a correlated subquery naming another module's table is a static string, and nothing in the docs for Computed, FromSQL, or ADR-0041 says that this is the same door ExternalRef deliberately locked — with a worse blast radius, because a computed column is in the model's projection surface and in every write's RETURNING, not in one join behind one handler.
What it did to a consumer
From the studio-apps port (SQLB-PORT-FEEDBACK.md finding 37): timeentries replaced a LEFT JOIN projects — one query, one handler — with Computed("project_name", FromSQL("(SELECT name FROM projects …)")), "on the reasoning that the coupling was identical. It is not identical, and the difference is the whole point." The subquery rode into the RETURNING of every INSERT, so the table could not be written unless projects existed in the same database, and the module's isolation boot test failed on its own seed with relation "projects" does not exist. The column had to be removed from the declaration.
The port explicitly did not file this as a checkable defect, and the reasoning holds: refusing a subquery that names an unknown table would require resolving table names, which is exactly the dependency ExternalRef's free-text target exists to avoid. sqlb cannot check this. That makes the doc comment the only lever — and today it is silent.
What to write, and where
On FromSQL (and the computed section of the schema docs / ADR-0041's caveats):
- The footprint. A computed column travels with the model: it is selectable by every mount that opts in, and it is in the
RETURNING of every INSERT, UPDATE and (with AfterDeleteRows) DELETE. A subquery here runs where a hand-written query's subquery does not — state the contrast with a join in one handler explicitly, because "the coupling is identical" is the exact plausible-wrong reasoning a porter applies.
- The boundary rule. The question to ask is not "is this a subquery" but "whose table does it name" (the port's own formulation, proven twice:
project_name into a foreign module broke boot; chat's participant_ids over its own chat_members is correct and deleted an N+1). A subquery may name this module's tables; one naming another module's couples every statement of this table to that module's presence, which is the coupling ExternalRef refuses to expand for.
- The cross-reference.
ExternalRef's "fetch the other side through that module's own API" is the answer here too.
If #164's write-side opt-in lands (writes narrowing computed columns the way #92 narrowed reads), point 1 shrinks but the boundary rule stands: the read path still carries the coupling to every mount that selects the column.
The asymmetry
ExternalRef's doc comment refuses expansion with a stated reason: "expanding it would join a table this module does not own. Fetch the other side through that module's own API." The DSL takes module isolation seriously enough to make it a refusal.Computed+FromSQLaccepts the identical coupling with no comment: a correlated subquery naming another module's table is a static string, and nothing in the docs forComputed,FromSQL, or ADR-0041 says that this is the same doorExternalRefdeliberately locked — with a worse blast radius, because a computed column is in the model's projection surface and in every write'sRETURNING, not in one join behind one handler.What it did to a consumer
From the studio-apps port (SQLB-PORT-FEEDBACK.md finding 37): timeentries replaced a
LEFT JOIN projects— one query, one handler — withComputed("project_name", FromSQL("(SELECT name FROM projects …)")), "on the reasoning that the coupling was identical. It is not identical, and the difference is the whole point." The subquery rode into theRETURNINGof every INSERT, so the table could not be written unlessprojectsexisted in the same database, and the module's isolation boot test failed on its own seed withrelation "projects" does not exist. The column had to be removed from the declaration.The port explicitly did not file this as a checkable defect, and the reasoning holds: refusing a subquery that names an unknown table would require resolving table names, which is exactly the dependency
ExternalRef's free-text target exists to avoid. sqlb cannot check this. That makes the doc comment the only lever — and today it is silent.What to write, and where
On
FromSQL(and the computed section of the schema docs / ADR-0041's caveats):RETURNINGof every INSERT, UPDATE and (withAfterDeleteRows) DELETE. A subquery here runs where a hand-written query's subquery does not — state the contrast with a join in one handler explicitly, because "the coupling is identical" is the exact plausible-wrong reasoning a porter applies.project_nameinto a foreign module broke boot; chat'sparticipant_idsover its ownchat_membersis correct and deleted an N+1). A subquery may name this module's tables; one naming another module's couples every statement of this table to that module's presence, which is the couplingExternalRefrefuses to expand for.ExternalRef's "fetch the other side through that module's own API" is the answer here too.If #164's write-side opt-in lands (writes narrowing computed columns the way #92 narrowed reads), point 1 shrinks but the boundary rule stands: the read path still carries the coupling to every mount that selects the column.