The vendor changed the shape for 5% of responses
Leveled drift catches a canary rollout precisely and refuses to invent a value. What it cannot do is tell a harmless coercion from a destructive one.
The problem
A vendor ships a response-shape change — but not all at once. A canary at 5% of traffic, then
25, then 50. Or not a rollout at all: the shape varies by data, like a geocoder that returns
null for formatted_address only on ambiguous queries.
Intermittent breakage is harder than total breakage. A change that breaks every call is found in minutes and rolled back. A change that breaks 5% looks like flakiness, sits in the backlog for a week, and gets fixed once someone spots the pattern.
Four change classes, and treating them alike is the mistake — addition is non-breaking by every published policy, removal and type change are breaking, and a field becoming nullable is warning-level and the most intermittent of all.
The dangerous one is the type change, because a naive cast produces a plausible value: a
payment provider moves transaction_id from 12345 to "12345", your code casts to int, gets
0, and processes a $0 transaction.
The common solutions
| Approach | Where it breaks |
|---|---|
| Strict schema validation | Catches everything — including the added field that broke nothing. Alarm fatigue. |
| Parse loosely, cast defensively | Never alarms, and manufactures the $0 transaction. |
| Contract tests in CI | Can't see a canary that started after your deploy. |
| Level the findings | The right model — needs a vocabulary most validators lack. |
| Log and aggregate | The only way to see 5% → 25%. Needs the finding to name the field. |
| Pin a vendor API version | The real fix where offered; useless against data-dependent nulls. |
What StitchAPI does
The default is safe, and that is the headline. Against z.number(), a transaction_id that
arrives as "12345" produces error | invalid | transaction_id | Invalid input: expected number, received string and the call fails with data: null. Even z.coerce.number() on
"abc" fails — Zod rejects NaN. StitchAPI does not manufacture a $0 charge on its own.
The precision is excellent. Over 100 calls where a geocoder returned null on 5 of them,
drift fired on exactly calls [20, 40, 60, 80, 100] — matching the vendor's own ledger,
zero false positives on the other 95, each finding naming formatted_address with
null -> string.
And aggregation is a real seam, which is what makes an intermittent change actionable. A
TraceSink sees every event of every call, and ctx.spanId lets you hold per-call state
honestly:
const charges = stitch({
url: 'https://api.vendor.com/charges/{id}',
output: drift(StrictCharge, { severity: { undeclared: 'verbose' } }),
trace: new DriftRate({ window: '15m' }),
});Measured: 5.0% of calls: warn|coerced|transaction_id|null -> number (5/100, 5 landed 0),
and on a rolling window the same field widening 5.0% → 25.0% as the canary expanded. Across
six workloads at 100 calls each: silent on a 100% addition rollout, one alert line per
breaking class at 5%, and zero $0 charges. The same six workloads against the soft schema
teams write for availability kept 100% of calls and produced ten $0 charges.
What StitchAPI does not solve here
- A
coercedfinding cannot say whether the coercion was destructive."12345" → 12345and"abc" → 0emit byte-identical findings —warn | coerced | transaction_id | string -> number— becausedetailiskindOf(old) -> kindOf(new)and values never appear in a finding. Only joiningdrifttoresultonctx.spanIdin a sink separates them. - The nullable class is inexpressible. "Nullable is a warning, value intact" — the fourth
industry class — has no spelling.
.nullable()produces nothing at all, so the 5% rollout is invisible; the strict schema turns it into a 5% error rate that also discards the four fields that were fine. A hand-rolled classifier beatsDriftOptionson exactly this row. severityis keyed by mechanism, not by change class. It takesundeclared/coerced/defaulted— of which only addition maps 1:1 to an industry class. Removal, type change and nullability each land on a kind decided by your schema, so their loudness is a schema decision rather than a severity one.- No per-path severity.
ignoreis the only path-aware lever and it is on/off. "Coercion ontransaction_idpages, coercion ondescriptiondoesn't" has no spelling. - A soft finding cannot be promoted to fatal through the type.
errorisn't inDriftSeverity(a cast past it does work at runtime). - Soft findings are invisible on the awaited path.
awaitand.safe()carry nothing —StitchErrorhas nofindings, and a hard failure gives only a genericcontract violation (drift). The trace sink for the same run named the field and both types.drift()with only.safe()does nothing for you. - A schema strips what it doesn't declare. The added field in the addition case is
undefinedondata; reading it needs.inspect().raw, which is a fresh request.
Two ordinary spellings manufacture the $0 charge.
z.coerce.number() maps null → 0 with no .catch() involved, because
Number(null) === 0. Measured: null, "", " ", false and [] all coerce to exactly
0; only "abc" rejects. And .catch(0) hands the caller 0 for anything.
.default('usd') on a removed field is the same shape — it fabricates a value the vendor
never sent, at verbose. On money, prefer a failed call to a plausible number.
A cache hit emits no drift, so caching divides your drift rate by the miss ratio. Measured: 5 calls against a vendor drifting on 100% of responses reported 20%.
Two smaller counting traps: findings are not calls (2 findings on one response reads as 200%
unless you collapse on ctx.spanId), and .report()/.inspect() are fresh probes —
they cost a request, tick your denominator, and answer about a different response. .report()
called right after a drifting call reported zero findings.
StitchAPI vs the common solution
A wash on size — 93 lines against 92. But the halves are not alike: detection is 9 declarative lines against ~35, while aggregation is ~84 lines of user code either way, because the library counts nothing.
What the hand-rolled 92 lack is the resilience stack, measured here as one retry line
absorbing 8 × 503 across the canary while the rate still counted 100 logical calls out of
108 wire requests.
See also
The page that moved while you were reading it
Offset pagination over a live collection silently returns wrong lists. A client cannot fix that — but it should not report a clean run over data it lost.
The export that eats the heap
The NDJSON decoder is genuinely O(1) — and the engine retains every chunk one line later, so neither await nor .stream() is memory-bounded.