Symptom

GET /v4/locations-with-pricing/nearby on Busways UAT returned 1 location instead of ~34. No error thrown, no warning logged — just wrong data silently.

Bug chain

flowchart TD
    A["Zod schema<br/>publish omitted<br/>→ undefined"]
    B["undefined === 'true'<br/>→ false<br/>(boolean, not undefined)"]
    C["Repo layer<br/>publish === false<br/>branch hit<br/>appends publish=false<br/>to params"]
    D["omni<br/>sends Publish:<br/>&BoolValue{Value: false}<br/>via gRPC"]
    E["charge-points<br/>req.Publish != nil<br/>→ filter applied<br/>WHERE publish = false"]
    F["1 unpublished test location <br/> returned"]

    A --> B --> C --> D --> E --> F

Root: the Zod schema’s .transform() received undefined (omitted param) and evaluated undefined === 'true' as false — a real boolean, not absent.

// Before (broken) — undefined leaks through as false
publish: z.string().optional().transform((val) => val === 'true')

Warning

Transform edge caseundefined === 'true' is false, not undefined. If .optional() feeds into .transform() without a .default(), a missing param silently becomes false.

Key insight: google.protobuf.BoolValue vs plain bool

The proto used google.protobuf.BoolValue (a nullable wrapper type), not bool:

Proto typeAbsent/nilExplicit falseExplicit true
booldefaults to falsefalsetrue
BoolValuenil → no filter appliedfalseWHERE publish = falsetrueWHERE publish = true
if req.Publish != nil {  // nil = no filter; non-nil = apply filter
    locationFilters = append(locationFilters, publishFilter)
}

Sending false explicitly was catastrophic because it was non-nil. Sending no param was safe.

Tip

Know which wrapper your service uses before reasoning about default filter behaviour. BoolValue nil ≠ bool false.

v3 design (discovered along the way)

v3 intentionally used z.string().default('') — the empty string never matched 'true' or 'false', so no publish param was ever forwarded. Publish stayed nil → no filter → all locations including unpublished returned. This was the intended behaviour for v3.

Fix

// After — .default('true') ensures transform always receives a string
publish: z.string().optional().default('true').transform((val) => val === 'true')

.default('true') runs before .transform(), so the transform always receives a string. Result is an unambiguous explicit boolean.

Lessons

  • "it returns results" ≠ "it's correct" — silent wrong-data bugs are harder to catch than errors
  • Always verify env vars in the live pod manifest, not just the code; an env var guard that isn’t set in the target deployment is not a fix
  • Check which proto wrapper type a service uses before reasoning about nil/false semantics

See also