Repository navigation
Description
cloudflare-export: num() reinterprets empty, hex and scientific strings as real measurements — a drifted '' request count exports as a measured 0
Evidence (verified this run, main da42bfe7)
pm-stack/cloudflare-export/cloudflare-quota-export.mjs, num() (lines 85–92):
const num = (v, field) => {
const n = typeof v === "string" || typeof v === "number" ? Number(v) : NaN;
if (!Number.isFinite(n) || n < 0) {
throw new ExportError("unparseable", 6, `graphql: field ${field} not a finite non-negative number`);
}
return n;
};The helper is the exporter's fail-closed gate for every GraphQL measurement it publishes — its error text promises that anything not "a finite non-negative number" aborts the tick as unparseable (exit 6) instead of reaching a status: "ok" document. But bare Number() accepts far more than the API's decimal spellings, so the gate passes values it exists to reject. Every measurement flows through it: R2 request counts (classifyR2, line 136), KV request counts (foldKv, line 161), the generic firstSum totals (line 173), and R2 storage payloadSize (line 216).
Probe (laptop, importing the real module — file md5 0fbd8dae4f5eba83adb88ec6092d1039 identical on both clones; probe ~/git/fleet/probe-2026-10-30-laneB-cfnum/; one sum.requests value mutated per case through classifyR2 with actionType PutObject):
| input | result |
|---|---|
"" |
OK — classA = 0 (a drifted empty value exports as a measured zero) |
" " (whitespace) |
OK — classA = 0 |
"0x10" |
OK — classA = 16 (hex reinterpreted) |
"1e2" |
OK — classA = 100 (scientific reinterpreted) |
"1.5" |
OK — classA = 1.5 (fractional request count) |
" 12 " |
OK — classA = 12 (whitespace-trimmed by coercion) |
controls: "12abc" / "-3" / "Infinity" |
correctly throw unparseable (exit 6) |
foldKv shows the same hole (requests: "" → reads = 0, no throw).
Why it matters
This exporter follows the PM stack's fail-closed design: a drifted upstream shape must produce a failed tick, never a plausible reading (compare classifyR2/foldKv's own missing-groups guards in the same file, which throw precisely so a missing field is not published as zero). An empty-string count — the classic shape-drift / missing-value spelling from an upstream API — currently sails through num() because Number("") === 0, and is published in a status: "ok" document as a measured 0 requests / 0 payload bytes. The aggregator ingests that as fact, so real Cloudflare usage silently reads as zero exactly when the GraphQL API starts drifting. Hex and scientific spellings are silently reinterpreted into different numbers instead of failing closed, and a fractional "count" is published as-is.
Acceptance criteria
num()accepts only plain decimal spellings for string inputs (the GraphQL API's actual format): empty, whitespace-only or whitespace-padded, hex (0x…), scientific (1e…), and fractional strings throwExportError("unparseable", 6, …)naming the field, at every call site (R2, KV,firstSum, R2 storage payload).- Numeric (non-string) inputs keep working exactly as today, including the existing finite / non-negative checks; legitimate decimal-string counts (e.g.
"1200") still parse. - New tests in
cloudflare-quota-export.test.mjscover:""and" "request counts rejected;"0x10"/"1e2"/"1.5"rejected; a decimal-string control still accepted; all existing tests still pass (node --test pm-stack/cloudflare-export/).
Dedup
- meta-exporter: parseSubscriptionQuota's num() reinterprets empty, hex and scientific strings as real measurements — a drifted '' used value exports as a measured 0% window #2789 (open) is the same
Number()pattern in meta-exporter'sparseSubscriptionQuota— different app, file, and parser; its fix does not touch this exporter. - supabase-export: parseDbSize/parseRequestCount accept empty, hex, scientific and fractional strings as real measurements — Number("") reads a drifted db-size row as a measured 0-byte database #2743 (open) is the same pattern in supabase-export's
parseDbSize/parseRequestCount— different app/file again. - cloudflare-export: R2 bucket enumeration fails open — a 500/malformed buckets response publishes r2Buckets: [] in a status:ok document, and pagination past 100 buckets is silently dropped #2574 / cloudflare-export: GraphQL errors assumed to be an array of {message: string} — other shapes throw TypeErrors misclassified as fetch-failed, and a single-object errors is silently ignored #2608 (open) are this same file but different mechanisms (R2 bucket enumeration failing open; GraphQL
errorsshape handling) — neither coversnum()'s string coercion.
Activity
dezren39 commentedon Oct 30, 2026
🤖 Agent note · filed
dezren39 commentedon Oct 30, 2026
Agent note — triage verdict: CONFIRMED (queue task 3961)
Re-verified independently this shift, not taken from the filing probe:
b706a837:num()atpm-stack/cloudflare-export/cloudflare-quota-export.mjs:85–92is byte-identical to the issue's quote (bareNumber()+ finite/non-negative gate). Call sites confirmed at lines 136 (classifyR2), 161 (foldKv), 173 (firstSum), 216 (R2 storagepayloadSize).classifyR2, one mutatedsum.requestsper case):""→ classA 0," "→ 0,"0x10"→ 16,"1e2"→ 100,"1.5"→ 1.5," 12 "→ 12 — all no-throw; controls"12abc"/"-3"/"Infinity"correctly throwunparseable(exit 6).foldKvwithrequests: ""→ reads 0, no throw. Every row of the issue's probe table reproduced exactly.Follow-up: fix enqueued as queue task 4021 (small_fix, dedupe
repo-health:v1:fix:developing-today/code:issue:2791) carrying the issue's acceptance criteria verbatim. This issue stays open until that fix lands.dezren39 commentedon Oct 31, 2026
🤖 Design-architect pass 25 (2026-10-31) — agent note: this issue is ADOPTED as Phase 3 (with #2743) of design #3005 (a measurement is a plain decimal), linked as its sub-issue and labelled
source/design-architect. Triage: do NOT re-file, re-scope, or split it; record the phase structure instead. The design generalises the landed sibling gate (meta-exporter via #2789, CLOSED) to this exporter: plain-decimal spellings only, with this exporter's existing fail-closed outcome on refusal. The closing inventory audit for the whole family is #3006, gated on this phase landing. Flag genuine blockers on #3005.10 remaining items
dezren39 commentedon Nov 1, 2026
🤖 Agent note · parked
node --test pm-stack/cloudflare-export/cloudflare-quota-export.test.mjs— 19 passed.jules-daily-flake-syncandjules-daily-codebase-ideationfailed with zero jobs; the same workflows also failed with zero jobs on clean base26e2bd41104f2eae147537b2ef01f9d534df4bcf. CodeRabbit is skipping. No merge performed.dezren39 commentedon Nov 1, 2026
🤖 Agent note · picked up
awaiting_cicheckpoint.dezren39 commentedon Nov 1, 2026
🤖 Agent note · parked
dezren39); required checks are also not green.dezren39 commentedon Nov 1, 2026
🤖 Agent note · parked
dezren39 commentedon Nov 1, 2026
🤖 Agent note · picked up
dezren39 commentedon Nov 1, 2026
🤖 Agent note · done