Skip to content

[finding] plugin-detail's isConcurrentUpdateError narrows to a required code: 'CONCURRENT_UPDATE' while accepting name-only errors that lack it #6421

Description

@claude

Observation-class finding, spotted while implementing objectui#6375 (the subsumed CONCURRENT_UPDATE guard in packages/data-objectstack). Not touched by that PR — its scope was the normaliseClientError guard and the isConcurrentUpdateError doc in data-objectstack only, so this is recorded rather than edited.

What

packages/plugin-detail/src/ConcurrentUpdateDialog.tsx exports a type predicate whose narrowed type is unsound for exactly the limb it was written to accept:

exportfunctionisConcurrentUpdateError(err: unknown): err is {code: 'CONCURRENT_UPDATE';currentVersion?: string;currentRecord?: Record<string,unknown>|null;message?: string;}{if(!err||typeoferr!=='object')returnfalse;conste=erras{code?: string;name?: string};returne.code==='CONCURRENT_UPDATE'||e.name==='ConcurrentUpdateError';}

The name === 'ConcurrentUpdateError' limb accepts an object that carries nocode. The narrowed type asserts code: 'CONCURRENT_UPDATE' as a required literal. So for the cross-realm case the limb exists for — a host that bundles the adapter twice, where instanceof fails and the class name is the only discriminator left — the predicate hands the caller a type that claims a property the value does not have.

Why it is worth a card rather than a silent fix

There is no symptom today, and that is the whole reason to record it rather than patch it under an unrelated PR. The only consumer is packages/plugin-detail/src/InlineEditSaveBar.tsx:183:

if(isConcurrentUpdateError(err)&&canAtomic){setConflict(buildConflict(draft,err));}

buildConflict reads only the two optional fields (currentVersion / currentRecord), never err.code. So nothing reads the unsound part of the narrowing. The next reader who does — err.code === 'CONCURRENT_UPDATE' type-checks and is silently undefined on a name-only error — gets no compiler help, which is what makes this worth writing down before that happens rather than after.

Declaring code?: 'CONCURRENT_UPDATE' (optional) would make the narrowed type honest at zero runtime cost. Whether that is worth a patch at all is triage's call; the two-limb runtime check itself is deliberate and should stay (see the rationale recorded above isConcurrentUpdateError in packages/data-objectstack/src/index.ts and, in full, above isViewConfigPermissionDeniedError in the same file).

Context: three independent copies of this predicate

Deliberate, documented, and not itself the finding — noted so triage sees the blast radius of any decision here:

  • packages/data-objectstack/src/index.tserror is ConcurrentUpdateError (narrows to the class; duck-typing to the class is the intended cross-realm behaviour).
  • packages/plugin-form/src/occSave.tsx:78 — returns plain boolean, so it has no narrowing to be unsound about. Its own test pins the name limb (occSave.test.tsx:86).
  • packages/plugin-detail/src/ConcurrentUpdateDialog.tsx:223 — the one above.

Each copy is deliberate: the two plugins document that they duck-type in order not to depend on the adapter. Only the third declares a narrowed object type.

Severity

Low, filed flat rather than pre-graded — no user-visible symptom, no test moves either way, and the fix (if any) is a one-character ?.

Where

  • packages/plugin-detail/src/ConcurrentUpdateDialog.tsx — the exported isConcurrentUpdateError return type
  • packages/plugin-detail/src/InlineEditSaveBar.tsx:183 — its only consumer

Found from: objectui#6375.


Generated by Claude Code

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions