Skip to content

A JSON body refuses a bigint that query encodes fine, { value, type } is encoded as a file, and a cross-field finding's path is the empty string #701

Description

@rejifald

Scenario: money-units
Proofs: docs/scenarios/proofs/money-units/ (8 scripts, 251 checks on this branch and 248 on origin/main, real node:http)

No disclosure content — encoding, diagnostics and cache-correctness findings. Every proof was run
on both trees (this worktree and a scratch checkout of origin/main); the three-check delta is
deliberate engine-conditional branching, described below. Zod's coercion is Zod's, not the
library's — versions are read from the manifest at run time and printed.

First, what works, and one of them is a direct hit

  • The library already guards the money shape in multipart. isFileWrapper
    (http-adapter.ts:388-402) deliberately excludes a plain object that merely carries a value key,
    and the comment names the case: { value: 100, currency: 'USD' } is not a file, because
    treating it as one "encoded value as a tiny Blob and silently DROPPED its siblings." Measured:
    { value, currency_code } survives a multipart body with the pairing intact. That is a real,
    documented, working guard for exactly this scenario.
  • bigint is type-tagged in the cache key (cache.ts:39-43, bigint:4200), so 4200 and
    4200n never collide.
  • query encodes all four money carriers exactly — number, decimal string, float and bigint.
  • output + superRefine carries a cross-field money rule, levels it error and makes it
    fatal.
  • vary closes a cross-currency cache collision in one config key.

1. A JSON body hard-fails on a bigint that query encodes fine

Measured off the vendor's ledger:

carrier query JSON body
4242 ?amount=4242 {"amount":4242}
"42.42" ?amount=42.42 {"amount":"42.42"}
42.42 ?amount=42.42 {"amount":42.42}
4242n ?amount=4242 no requestStitchError: Do not know how to serialize a BigInt

stringifyLeaf (util.ts:332-341) lists bigint among the scalars, so the query path is fine.
encodeRequestBody's JSON arm is JSON.stringify, which throws on a BigInt by specification, so
the call never leaves the process.

Integer minor units as a bigint is the representation the money literature recommends, and nothing
in the config or the types says it cannot ride a JSON body. The failure is a runtime throw whose
message names BigInt and not the field, the stitch or the slot.

Ask: either encode a bigint in a JSON body as a JSON number when it is within Number.MAX_SAFE_INTEGER
(and refuse loudly above it), or reject it at construction the way the other dead-config guards do.
A ConfigError naming the slot would be a large improvement on a TypeError from the standard
library.

2. { value, type } is encoded as a file, and it is a real money shape

isFileWrapper returns true when w.type !== undefined. { value: '42.42', type: 'money' } — a
perfectly ordinary domain shape — therefore takes the file branch: one part
name="amount"; filename="blob" with Content-Type: money, and the sibling is gone. Silently,
with a 200.

The guard immediately above it exists precisely to stop this happening to { value, currency }, so
the intent is clear and the hole is one key wide.

Ask: require a Blob/Uint8Array/ArrayBuffer value or an explicit filename before
treating type alone as a file marker — type without any binary payload is far more likely to be a
domain discriminator than a MIME type.

3. A cross-field finding's path is the empty string

A .refine() on the object — the only way to express "the divisor depends on currency" — fails
the call with:

{ "level": "error", "path": "", "change": "invalid", "detail": "Invalid input" }

renderPath([]) returns '' (drift.ts:27-34) and a bare Zod .refine() produces path: [], so
a cross-field violation is reported at a path that renders as nothing. Supplying
path: ['amount'] moves it onto the field that is correct — the relationship is what is wrong,
and a DriftFinding carries one path.

Ask: render the root as something legible ('(root)' or '$'), so a finding at the object level
is distinguishable from a finding with a missing path. This is cosmetic and it is the difference
between a readable log line and an empty one.

4. Two representations of the same money derive one cache key, and a cross-currency hit follows

42.00 and 42 are the same IEEE-754 double, so they canonicalise identically. With the currency
riding in a header that is not in vary, a ¥42 call was silently served the $42.00 answer
proven from the vendor's echoed sequence number: two successful calls, one vendor request,
report().cache === 'hit'.

The mirror is also worth knowing: four spellings of one $42.00 charge (4200, "4200", 42.00,
4200n) derive four keys, so a shared cache measured a permanent 0 % hit rate across them,
and migrating a codebase to bigint minor units invalidates every existing entry.

Neither is a defect on its own — the key is a faithful function of the request — but nothing reports
that a discriminating field is outside the key. vary fixes it in one line and its absence is
silent.

Ask: consider a construction-time note when a stitch declares cache and reads a request header
that is not in vary. That is the same class as the existing dead-config diagnostics.

5. Smaller, all measured

  • 7 of 16 schema × representation cells accept, 2 of them silently retype, and all 7 report 0
    findings.
    z.coerce.number() holds 4242 from a minor-unit vendor and 42.42 from a
    decimal-string vendor with the same schema and a clean report both times.
  • z.coerce.string() over { value, currency_code } accepts and hands back
    "[object Object]", reported as object -> string, warn, non-fatal.
  • The coerced detail is byte-identical for a no-op and for the defect: string -> number for
    both jpy "4242" → 4242 and usd "42.42" → 42.42. (Carry-forward of the already-filed kinds-only
    finding — reported here because this is where the two kinds are least informative.)
  • .report().raw held exponent, minor and the decimal string together in one body. Every
    part of the answer was on the wire; no seam relates them.

Reproduction

npx tsx docs/scenarios/proofs/money-units/c4-round-trip.ts   # §§1-2
npx tsx docs/scenarios/proofs/money-units/c6-cache-key.ts    # §4

Eight scripts run offline against a node:http vendor serving one charge in four representations
across usd (exponent 2), jpy (0) and kwd (3), plus an exponent table, an invoice with a stated
total, and an echo endpoint. The ledger records the exact query string and raw body. No timing
assertion appears anywhere in the directory.

Note on an already-filed issue

#679 (a bigint in params vanishing) was
re-measured: it reproduces on this branch and is fixed on origin/main
expandTemplateVar (util.ts:390-398) now lists bigint among the scalars, with a comment naming
the exact symptom. Recorded so the issue can be closed with a reference.

Source references (verified against origin/main)

  • http-adapter.ts:388-402 — the isFileWrapper comment naming { value: 100, currency: 'USD' }, and w.type !== undefined
  • util.ts:332-341stringifyLeaf, listing bigint
  • util.ts:390-398expandTemplateVar's scalar arm, now including bigint
  • drift.ts:27-34renderPath, returning '' for []
  • drift.ts:50-57validationErrors, mapping each issue to level: 'error', change: 'invalid'
  • drift.ts:65-69DEFAULT_LEVEL, coerced: 'warn' · :77-84 — the kinds-only detail
  • cache.ts:32-61stable(); :39-43`bigint:${…}` and its comment
  • engine.ts:460-473validateOutput, diffing only on the __kind === 'drift' branch
  • surface.ts:197const isFalsy = (value: unknown): boolean => !value; (why verdict.flag passes 2645)
  • Counts on origin/main: currency as a word 1 (http-adapter.ts:390), toFixed 0, decimal 1 (trace.ts:157), bigint in 6 files

Found by a scenario pass that researches a real-world API integration problem, captures it, and proves or refutes each claim with runnable offline code. Every source reference above was read on origin/main.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P0Silent data loss, money, credential, or user source — live on mainbugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions