Skip to content

addTo/subtractFrom accumulation bypasses declared column type bounds (Int 32-bit, Long ±2^53) at commit-merge #1372

Description

@kriszyp

Summary

Incremental addTo/subtractFrom operations silently commit values that exceed a column's declared type bound. A single large delta that itself exceeds the bound IS correctly rejected — but the accumulated running total escapes validation entirely.

Confirmed affected types (both engines, no divergence):

  • Int (32-bit): addTo(1) on a column holding 2^31−1 → commits 2147483648 with no error, no wrap
  • Int underflow: subtractFrom(1) on −2^31+1 → commits −2147483649
  • Long (±2^53): two addTo(2^53) calls → commits 2^54 = 18014398509481984

Correct (unaffected): SQL UPDATE col = col + 1 correctly rejects the out-of-range resolved value (calls set() with the final computed value).

Root cause

Addition.update(prev) { return (+prev||0) + this.value } at resources/tracked.ts:590 returns the summed result directly to the storage layer without routing through set(). The per-delta validator in set() (≈82–97) validates value.__op__ ? value.value : value — i.e. the delta — which passes for any single in-range increment. The commit-merge path is unguarded.

Impact

Any fleet using a typed Int column to cap a counter or quota (e.g. 32-bit sequence, rate-limit counter) or relying on the Long ±2^53 bound under addTo/subtractFrom workloads does not get that cap. Values silently exceed the declared range with no error and no data corruption (values stay exact JS-safe integers). The only safe workaround today is using SQL arithmetic UPDATE (which enforces correctly) or an open/untyped column.

Fix

Re-validate the resolved sum against the column's type bound in tracked.ts:590 — call the column validator with the committed result after merge, or inline the bound check.

Repro

# Int overflow (F-018)
npm run test:integration -- "integrationTests/qa-scratch/qa110-atomic-edges.test.ts"   # probe 6a
HARPER_STORAGE_ENGINE=lmdb npm run test:integration -- "integrationTests/qa-scratch/qa110-atomic-edges.test.ts"

# Int underflow + Long accumulation
npm run test:integration -- "integrationTests/qa-scratch/qa112-int-bound.test.ts"
HARPER_STORAGE_ENGINE=lmdb npm run test:integration -- "integrationTests/qa-scratch/qa112-int-bound.test.ts"

Harper commit: 7aaa5a152 (branch kris/repl-no-revalidate-1302-core; root cause in tracked.ts is branch-independent).

Found by the automated QA explorer campaign (waves 33–34). 🤖 Generated with Claude Code

Activity

  1. kriszyp commented on Jun 24, 2026

    @kriszyp
    MemberAuthor

    Re-confirmed on main v5.1.11 + the SUM/Long read-side companion

    Re-confirmed on main 541a3d33d (v5.1.11) that addTo accumulation crosses the declared Long bound and silently rounds:

    npm run test:integration -- "integrationTests/qa-scratch/qa214-numeric-precision.test.ts"
    
    • addTo wedge: seeded 9007199254740987, addTo 10, expected 9007199254740997, got 9007199254740992 — drift −5 ((prev||0)+delta rounds in double at the 2^53 boundary).
    • Round-trip reject confirms the validator's stated bound is exactly ±2^53: inserting 9007199254741000 → 400 "Value … must be an integer (from -9007199254740992 to 9007199254740992)". So Long behaves as ±2^53, not true 64-bit.

    Companion read-side leg (same root, JS-double accumulation): SQL SUM over a Long column drifts past 2^53 — 200 rows × MAX_SAFE_INTEGER returned a total off by −100 vs the BigInt oracle. That accumulator leg is also noted on the SQL umbrella #1397; flagging here since it shares the root cause with the addTo bound bypass this issue tracks.


    QA-explorer campaign (scenario QA-214). Filed for @kris.

    — Claude (Opus 4.8, 1M)

  2. added theissue type on Aug 7, 2026
  3. kriszyp commented on Sep 3, 2026

    @kriszyp
    MemberAuthor

    An exploratory-QA run added the write-path control this issue was missing, which sharpens it from "numeric coercion never consults the bound" into a path-asymmetry statement:

    • Direct write path enforces the bound loudly and verifiably. 2^53+2 on a Long → HTTP 400, then 404 on read-back (so nothing was stored). A server-side 64-bit BigInt is rejected, not truncated.
    • Atomic path does not. addTo(2^53) twice on a Long commits 2^54; Int MAX +1 commits 2147483648.

    Same value, same declared column, opposite outcomes depending on which path wrote it. That is a cleaner one-line statement of the defect than the coercion framing, and it names the fix site directly: addTo must run the same bound check the write path already runs.

    Re-verified on origin/main (93752ad79) — the code moved since this issue was filed but the gap is unchanged:

    • resources/tracked.ts Addition.update() now delegates to resources/crdt.ts addValues(previousValue, value), which is pure arithmetic (previous + value, or a BigInt add) with no bound or type check against the declared column type.
    • resources/Table.ts validateValue() — the only bound-enforcing validator — unwraps if (patch && value.__op__) value = value.value and therefore validates only the delta, never the post-merge sum. The merge itself happens later at commit time in updateAndFreeze, with no re-validation.

    Posted from finding-triage (QA finding QA-769), 2026-09-02.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:storageStorage engine, LMDB/RocksDB, compaction

    Type

    Fields

    Priority

    P2

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions