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 request — StitchError: 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-341 — stringifyLeaf, listing bigint
util.ts:390-398 — expandTemplateVar's scalar arm, now including bigint
drift.ts:27-34 — renderPath, returning '' for []
drift.ts:50-57 — validationErrors, mapping each issue to level: 'error', change: 'invalid'
drift.ts:65-69 — DEFAULT_LEVEL, coerced: 'warn' · :77-84 — the kinds-only detail
cache.ts:32-61 — stable(); :39-43 — `bigint:${…}` and its comment
engine.ts:460-473 — validateOutput, diffing only on the __kind === 'drift' branch
surface.ts:197 — const 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.
Scenario: money-units
Proofs:
docs/scenarios/proofs/money-units/(8 scripts, 251 checks on this branch and 248 onorigin/main, realnode:http)First, what works, and one of them is a direct hit
isFileWrapper(
http-adapter.ts:388-402) deliberately excludes a plain object that merely carries avaluekey,and the comment names the case:
{ value: 100, currency: 'USD' }is not a file, becausetreating it as one "encoded
valueas 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.
bigintis type-tagged in the cache key (cache.ts:39-43,bigint:4200), so4200and4200nnever collide.queryencodes all four money carriers exactly — number, decimal string, float and bigint.output+superRefinecarries a cross-field money rule, levels iterrorand makes itfatal.
varycloses a cross-currency cache collision in one config key.1. A JSON body hard-fails on a bigint that
queryencodes fineMeasured off the vendor's ledger:
querybody4242?amount=4242{"amount":4242}"42.42"?amount=42.42{"amount":"42.42"}42.42?amount=42.42{"amount":42.42}4242n?amount=4242StitchError: Do not know how to serialize a BigIntstringifyLeaf(util.ts:332-341) listsbigintamong the scalars, so the query path is fine.encodeRequestBody's JSON arm isJSON.stringify, which throws on aBigIntby specification, sothe call never leaves the process.
Integer minor units as a
bigintis the representation the money literature recommends, and nothingin 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
bigintin a JSON body as a JSON number when it is withinNumber.MAX_SAFE_INTEGER(and refuse loudly above it), or reject it at construction the way the other dead-config guards do.
A
ConfigErrornaming the slot would be a large improvement on aTypeErrorfrom the standardlibrary.
2.
{ value, type }is encoded as a file, and it is a real money shapeisFileWrapperreturns true whenw.type !== undefined.{ value: '42.42', type: 'money' }— aperfectly ordinary domain shape — therefore takes the file branch: one part
name="amount"; filename="blob"withContent-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 }, sothe intent is clear and the hole is one key wide.
Ask: require a Blob/
Uint8Array/ArrayBuffervalueor an explicitfilenamebeforetreating
typealone as a file marker —typewithout any binary payload is far more likely to be adomain 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 oncurrency" — failsthe call with:
{ "level": "error", "path": "", "change": "invalid", "detail": "Invalid input" }renderPath([])returns''(drift.ts:27-34) and a bare Zod.refine()producespath: [], soa 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
DriftFindingcarries one path.Ask: render the root as something legible (
'(root)'or'$'), so a finding at the object levelis 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.00and42are the same IEEE-754 double, so they canonicalise identically. With the currencyriding 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.
varyfixes it in one line and its absence issilent.
Ask: consider a construction-time note when a stitch declares
cacheand reads a request headerthat is not in
vary. That is the same class as the existing dead-config diagnostics.5. Smaller, all measured
findings.
z.coerce.number()holds4242from a minor-unit vendor and42.42from adecimal-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 asobject -> string,warn, non-fatal.coerceddetail is byte-identical for a no-op and for the defect:string -> numberforboth jpy
"4242" → 4242and usd"42.42" → 42.42. (Carry-forward of the already-filed kinds-onlyfinding — reported here because this is where the two kinds are least informative.)
.report().rawheldexponent,minorand the decimal string together in one body. Everypart of the answer was on the wire; no seam relates them.
Reproduction
Eight scripts run offline against a
node:httpvendor serving one charge in four representationsacross
usd(exponent 2),jpy(0) andkwd(3), plus an exponent table, an invoice with a statedtotal, 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
bigintinparamsvanishing) wasre-measured: it reproduces on this branch and is fixed on
origin/main—expandTemplateVar(util.ts:390-398) now listsbigintamong the scalars, with a comment namingthe exact symptom. Recorded so the issue can be closed with a reference.
Source references (verified against
origin/main)http-adapter.ts:388-402— theisFileWrappercomment naming{ value: 100, currency: 'USD' }, andw.type !== undefinedutil.ts:332-341—stringifyLeaf, listingbigintutil.ts:390-398—expandTemplateVar's scalar arm, now includingbigintdrift.ts:27-34—renderPath, returning''for[]drift.ts:50-57—validationErrors, mapping each issue tolevel: 'error', change: 'invalid'drift.ts:65-69—DEFAULT_LEVEL,coerced: 'warn'·:77-84— the kinds-onlydetailcache.ts:32-61—stable();:39-43—`bigint:${…}`and its commentengine.ts:460-473—validateOutput, diffing only on the__kind === 'drift'branchsurface.ts:197—const isFalsy = (value: unknown): boolean => !value;(whyverdict.flagpasses2645)origin/main:currencyas a word 1 (http-adapter.ts:390),toFixed0,decimal1 (trace.ts:157),bigintin 6 filesFound 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.