Skip to content

A severity map can make a soft drift finding fatal, and the hard path loses the array index #703

Description

@rejifald

Scenario: new-enum-value
Proofs: docs/scenarios/proofs/new-enum-value/ (8 scripts, 273 checks, on this branch and on origin/main, under Zod 3.25.76 and 4.4.3)

Found while measuring what a client can do when a vendor adds a value to a response enum — the
z.enum(['pending','succeeded','failed']) that meets requires_action eight months later.

First, what works

  • A .transform boxing only unrecognised values, under drift(), is a real answer. The call
    succeeds, the vendor's value survives, one finding is emitted (all 5 elements: string -> object,
    sample data[12].status), and a clean batch reports 0.
  • Array collapse is exactly right on the soft path — 200 records with 3 new values give one
    finding with a concrete sample, per ADR 0017.
  • input validation rejects a removed enum value in 0 vendor requests.
  • @stitchapi/fingerprint-zod abstaining on .catch is the correct call — it cannot know what
    the fallback does. The consequence in §4 is worth surfacing anyway.

1. DriftSeverity is enforced by the compiler and not by the runtime

types.ts:73 states the invariant in its own doc comment:

/** Non-fatal severities a soft drift finding can carry. (Fatality is the schema's job — make the field required.) */
export type DriftSeverity = 'warn' | 'info' | 'verbose';

resolveSeverity reads the map with no validation (drift.ts:100):

return { levelOf: (c) => severity[c] ?? DEFAULT_LEVEL[c], allow: null };

and the engine's fatality test is a bare if (finding.level === 'error') fatal = true — at four
sites on origin/main (engine.ts:1020, :1238, :1453, plus :1655 for stale).

So drift(schema, { severity: { coerced: 'error' } }) produces
{ level: 'error', change: 'coerced', path: 'status', detail: 'string -> string' } and fails the
call
. Measured, C4(d).

The compiler stops this in TypeScript source. It does not stop a JS caller, a config deserialised
from JSON or YAML, or anything past a cast — and a severity map is exactly the kind of config that
gets externalised.

Ask: either validate the map in resolveSeverity (drop or clamp an out-of-union level, and say
so), or make the capability real — a documented severity: { coerced: 'error' } is genuinely
useful, and it is currently the only measured way to make a .catch() fallback fail closed
while still holding the sentinel. Right now it is neither: it works, and the types say it cannot.

2. The hard path does not collapse, and it erases the array index

Same 200 records, same 3 new values, output instead of drift:

path findings path text sample
soft (drift) 1 data[].status data[41].status
hard (output) 3 data[].status ×3 absent

validationErrors (drift.ts:50-56) is a bare issues.map — no grouping, no sample. So the
failing path emits one finding per bad element, each of which has had its index rendered away, and
the concrete coordinate is not recoverable from the finding at all. The soft path solved both
problems; the hard path has neither fix.

Ask: run validationErrors output through the same group-then-summarize step, or at minimum
populate sample with renderConcretePath(iss.path) — the information is in the issue.

3. A coerced detail cannot name the value, and merges distinct ones

detailFor (drift.ts:77-79) renders coerced as `${kindOf(oldValue)} -> ${kindOf(value)}`.
For an enum fallback both sides are a string, so the detail is always string -> string.

Because the homogeneity test groups on detail, two different unknown statuses merge into one
all 5 elements: string -> string line naming neither. Measured, C3.

Ask: this is a deliberate design (the detail is a kind pair, and scenario 46 measured the same
vocabulary for money). The cheap improvement is to let the sample carry more than one
coordinate when a group is heterogeneous by value rather than by detail — or to include the old
value in detail for scalar changes, where it is a bounded string.

4. The enum-tolerant spellings are exactly the uncacheable ones

A Zod output with no fingerprint strategy refuses to cache — 2 requests for 2 identical calls.
@stitchapi/fingerprint-zod abstains on .catch and on .transform, which are precisely the two
spellings that survive a new enum value. A plain widened z.enum fingerprints and self-invalidates
cleanly. So the forward-compatible schema is the one that silently loses its cache.

And once pinned with an explicit version: the miss emits the finding, the hit emits none, while
serving the byte-identical rewritten value. onUnfingerprintable: 'revalidate' does not bring it
back — the hit path yields hard errors only.

Ask: mostly a docs ask — say that .catch/.transform abstain, and that a cache hit does not
replay soft findings. The second is a real trap for anyone using drift findings as a monitoring
signal: the signal's rate is a function of the cache hit rate.

5. ValidationError's identity is rebuilt away

An input.body rejection reaches the caller as
StitchError{ name: 'StitchError', status: undefined, attempts: 0 } — discriminable from any other
failure only by the invalid body: message prefix. The spine's error event carries the stitch
name (create), not ValidationError, so the class name never leaves validateInput.

Ask: preserve the name, or expose a discriminant that is not a message prefix.

Reproduction

npx tsx docs/scenarios/proofs/new-enum-value/c4-severity.ts
npx tsx docs/scenarios/proofs/new-enum-value/c3-the-array.ts

A real node:http vendor on 127.0.0.1 with a request ledger; the fourth enum value is served from a
flag, never a clock. C7 additionally probes which cache vocabulary the tree implements and prints it.

Which engine

drift.ts is byte-identical between this worktree and origin/main; types.ts:70/72/74
identical. engine.ts line numbers shift but the cited lines do not change. Two real differences,
neither affecting §§1–3: validateInput returns the parsed input on main, with error construction
line-for-line identical; and the ADR 0004 cache vocabulary moved from flat
cache.version/cache.onUnfingerprintable to a cache.fingerprint envelope.

Zod: every claim was run under both 3.25.76 and 4.4.3. The two disagree on the hard-path
message text (Invalid enum value. Expected … received 'requires_action' vs
Invalid option: expected one of …) — validationErrors copies iss.message verbatim, so that
difference is Zod's, not StitchAPI's. It is called out where it appears and no claim rests on it.

Unrelated, but noticed: this worktree's packages/core/node_modules/zod is 3.25.76 while
packages/core/package.json declares ^4.4.3
— a stale install, not a lockfile disagreement.

Source references (verified against origin/main)

  • types.ts:73 — the doc comment stating the invariant · :74export type DriftSeverity = 'warn' | 'info' | 'verbose';
  • types.ts:70DriftChange · :72SoftDriftChange = Exclude<DriftChange, 'invalid'>
  • drift.ts:100return { levelOf: (c) => severity[c] ?? DEFAULT_LEVEL[c], allow: null };
  • drift.ts:50-56validationErrors, the bare issues.map
  • drift.ts:77-79detailFor for coerced
  • drift.ts:113export function classifyDiff( · :145if (matchAny(ignore, path)) continue;
  • engine.ts:1020 / :1238 / :1453if (finding.level === 'error') fatal = true;
  • engine.ts:460-473validateOutput; :468 — the __kind === 'drift' branch

Found while writing scenario 49 ("the status value that wasn't in the enum") for the docs. Every claim is backed by a runnable offline proof in docs/scenarios/proofs/new-enum-value/.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Correctness bug, live on mainbugSomething isn't workingdocumentationImprovements or additions to documentation

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions