Release candidate — 1.0.0-rc.7
StitchAPI

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

ApproachWhere it breaks
Strict schema validationCatches everything — including the added field that broke nothing. Alarm fatigue.
Parse loosely, cast defensivelyNever alarms, and manufactures the $0 transaction.
Contract tests in CICan't see a canary that started after your deploy.
Level the findingsThe right model — needs a vocabulary most validators lack.
Log and aggregateThe only way to see 5% → 25%. Needs the finding to name the field.
Pin a vendor API versionThe 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

  1. A coerced finding cannot say whether the coercion was destructive. "12345" → 12345 and "abc" → 0 emit byte-identical findings — warn | coerced | transaction_id | string -> number — because detail is kindOf(old) -> kindOf(new) and values never appear in a finding. Only joining drift to result on ctx.spanId in a sink separates them.
  2. 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 beats DriftOptions on exactly this row.
  3. severity is keyed by mechanism, not by change class. It takes undeclared/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.
  4. No per-path severity. ignore is the only path-aware lever and it is on/off. "Coercion on transaction_id pages, coercion on description doesn't" has no spelling.
  5. A soft finding cannot be promoted to fatal through the type. error isn't in DriftSeverity (a cast past it does work at runtime).
  6. Soft findings are invisible on the awaited path. await and .safe() carry nothing — StitchError has no findings, and a hard failure gives only a generic contract violation (drift). The trace sink for the same run named the field and both types. drift() with only .safe() does nothing for you.
  7. A schema strips what it doesn't declare. The added field in the addition case is undefined on data; reading it needs .inspect().raw, which is a fresh request.

Two ordinary spellings manufacture the $0 charge.

z.coerce.number() maps null0 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

On this page