Skip to content

readRowById swallows engine failures into null, so a store outage is indistinguishable from an absent row at every gate that probes with it #7505

Description

@os-zhuang

Found while implementing #7474 (splitting the assertControlledByParentWrite refusal legs). Filed rather than fixed: fixing it changes what several security gates answer during an outage, which is a wider decision than the ruled card.

What was measured

packages/plugins/plugin-security/src/security-plugin.ts — the shared single-row probe:

private async readRowById(object: string, id: unknown, context: any): Promise<Record< string, unknown > | null> {
  if (id == null || typeof this.ql?.findOne !== 'function') return null;
  try {
    const row = await this.ql.findOne(object, { where: { id }, context });
    return row && typeof row === 'object' ? (row as Record< string, unknown >) : null;
  } catch {
    return null;
  }
}

Three distinct facts collapse into one null: the row does not exist; the engine threw (driver down, table missing, timeout); no engine is wired at all. Every caller then reads null as "no such row".

Why it is more visible after #7474

Before that card, assertControlledByParentWrite's !row branch answered 403 PERMISSION_DENIED — requires edit access to its master record, which was untrue for all three causes equally. It now answers 404 RECORD_NOT_FOUND, which is exactly right for the first cause and newly wrong-in-a-specific-way for the other two: a driver outage is reported to the caller as "that record does not exist", an answer an SDK treats as terminal (do not retry, drop the id) rather than as a transient fault it should back off on. The platform has a code for this — ERR_DATASOURCE_UNAVAILABLE / 503 — and this path cannot reach it.

The same null also feeds getCallerPreImage, so the collapse is not local to the controlled-by-parent gate.

Direction, not a prescription

Distinguishing "absent" from "could not be read" in the probe's return (and letting an engine fault propagate rather than being flattened) is the shape; whether each caller should then fail closed on a fault, or surface the datasource error, is per-gate and is the part that needs deciding. Note the fail-closed direction is already the house answer next door — assertControlledByParentWrite's master-RLS probe catches and treats a throw as "not visible", and the sharing service throwing denies both faces (controlled-by-parent-sharing.test.ts, "fail-closed"). A probe that fails closed on the DETAIL row would answer 403, not 404.

Pointers

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions