Skip to content

finding: SqlDriver's #3821 recovery ladder widens an unresolvable projection to every field #7589

Description

@claude

What this is

A finding, filed as defence-in-depth rather than a live user-facing defect. Extracted while implementing #7532; the measurement is mine and is reproduced in full below.

Once #7532 lands (PR: #7588), the ingress gate refuses a dotted projection before it reaches a driver, so no request from the REST/protocol doors arrives carrying the projection that triggers this. What remains is every caller that reaches SqlDriver without passing that ingress — hooks, flows, reports, expand sub-reads, registry-less hosts — for which this behaviour is unchanged.

Observed

SqlDriver's unknown-column recovery ladder (packages/drivers/driver-sql/src/sql-driver.ts, ~L3395-3425) catches no such column and retries the query with select('*'). The result is that a projection the driver cannot apply comes back as every column, with a 200 and nothing in the response indicating the projection was dropped.

Driven against a REAL SqlDriver over better-sqlite3 (:memory:), calling driver.find directly — no engine, no protocol, so this is the driver's own behaviour and not the ingress gate's:

A  no projection (baseline)      -> [account, amount, created_at, id, name, status, updated_at]
B  fields ['name']               -> [name]                                    <- narrows correctly
C  fields ['name','account.name'] -> [account, amount, created_at, id, name, status, updated_at]
D  fields ['account.name']        -> [account, amount, created_at, id, name, status, updated_at]

Rows C and D are byte-identical to row A — to asking for no projection at all. Knex renders "account"."name" against a table that was never joined, sqlite answers no such column, and the ladder retries selecting everything.

Reproduction harness (drop into packages/drivers/driver-sql/src/):

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { SqlDriver } from '../src/index.js';

describe('SqlDriver projection widening', () => {
  let driver: SqlDriver;
  beforeEach(async () => {
    driver = new SqlDriver({
      client: 'better-sqlite3',
      connection: { filename: ':memory:' },
      useNullAsDefault: true,
    });
    await driver.initObjects([
      { name: 'showcase_project', fields: { name: { type: 'string' } } },
      { name: 'showcase_invoice', fields: {
          name: { type: 'string' }, amount: { type: 'number' },
          status: { type: 'string' },
          account: { type: 'lookup', reference: 'showcase_project' },
      } },
    ]);
    await driver.create('showcase_project', { id: 'p1', name: 'Apollo' } as any);
    await driver.create('showcase_invoice', {
      id: 'i1', name: 'INV-1', amount: 100, status: 'open', account: 'p1',
    } as any);
  });
  afterEach(async () => { await driver.disconnect(); });

  it('widens instead of narrowing', async () => {
    const rows: any[] = await driver.find('showcase_invoice', { fields: ['name', 'account.name'] } as any);
    // Observed: every column. A projection that cannot be applied returns MORE.
    expect(Object.keys(rows[0]).sort()).toEqual(['name']);
  });
});

Why it is deliberate today, and why it is still worth a card

The ladder is documented and intentional — its own comment explains the trade: a $select naming a column the table lacks used to make the WHOLE query fail, which read to the UI as "no records exist" while rows were there. Returning the rows without the projection was judged the lesser harm, and it explicitly backstops hosts whose registry is not populated ("notably the cloud multi-tenant runtime, where the projection otherwise zeroes the list").

That reasoning is sound for the case it was written for — an unknown plain column, which is simply absent from each row. It generalises badly to a projection the driver cannot apply for a structural reason: the caller asked to narrow, and silently received everything, which points away from both FLS and data minimisation. Nothing in the response distinguishes "your projection was applied" from "your projection was discarded".

Not fixed here, deliberately

Narrowing a documented tolerance is a contract change with its own blast radius and needs its own reverse verification — it is not a rider on #7532, and #7532's ruling was explicit that the ingress gate is where that card lands. Filed unassigned and without a domain:* label so routing stays the triage seat's call.

Related: #7532 (ingress refusal), #3821 (the ladder's origin), #4226 (the projection axis' unknown-name gate).


Generated by Claude Code

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions