Skip to content

ExternalRef refuses expansion to protect module isolation, and FromSQL lets the identical coupling into every statement unchecked — nothing says so #167

Description

@jryannel

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):

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentation

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions