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 · :74 — export type DriftSeverity = 'warn' | 'info' | 'verbose';
types.ts:70 — DriftChange · :72 — SoftDriftChange = Exclude<DriftChange, 'invalid'>
drift.ts:100 — return { levelOf: (c) => severity[c] ?? DEFAULT_LEVEL[c], allow: null };
drift.ts:50-56 — validationErrors, the bare issues.map
drift.ts:77-79 — detailFor for coerced
drift.ts:113 — export function classifyDiff( · :145 — if (matchAny(ignore, path)) continue;
engine.ts:1020 / :1238 / :1453 — if (finding.level === 'error') fatal = true;
engine.ts:460-473 — validateOutput; :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/.
Scenario: new-enum-value
Proofs:
docs/scenarios/proofs/new-enum-value/(8 scripts, 273 checks, on this branch and onorigin/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 meetsrequires_actioneight months later.First, what works
.transformboxing only unrecognised values, underdrift(), is a real answer. The callsucceeds, the vendor's value survives, one finding is emitted (
all 5 elements: string -> object,sample data[12].status), and a clean batch reports 0.finding with a concrete
sample, per ADR 0017.inputvalidation rejects a removed enum value in 0 vendor requests.@stitchapi/fingerprint-zodabstaining on.catchis the correct call — it cannot know whatthe fallback does. The consequence in §4 is worth surfacing anyway.
1.
DriftSeverityis enforced by the compiler and not by the runtimetypes.ts:73states the invariant in its own doc comment:resolveSeverityreads the map with no validation (drift.ts:100):and the engine's fatality test is a bare
if (finding.level === 'error') fatal = true— at foursites on
origin/main(engine.ts:1020,:1238,:1453, plus:1655forstale).So
drift(schema, { severity: { coerced: 'error' } })produces{ level: 'error', change: 'coerced', path: 'status', detail: 'string -> string' }and fails thecall. 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 sayso), or make the capability real — a documented
severity: { coerced: 'error' }is genuinelyuseful, and it is currently the only measured way to make a
.catch()fallback fail closedwhile 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,
outputinstead ofdrift:sampledrift)data[].statusdata[41].statusoutput)data[].status×3validationErrors(drift.ts:50-56) is a bareissues.map— no grouping, nosample. So thefailing 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
validationErrorsoutput through the same group-then-summarize step, or at minimumpopulate
samplewithrenderConcretePath(iss.path)— the information is in the issue.3. A
coerceddetail cannot name the value, and merges distinct onesdetailFor(drift.ts:77-79) renderscoercedas`${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 oneall 5 elements: string -> stringline 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
samplecarry more than onecoordinate when a group is heterogeneous by value rather than by detail — or to include the old
value in
detailfor scalar changes, where it is a bounded string.4. The enum-tolerant spellings are exactly the uncacheable ones
A Zod
outputwith no fingerprint strategy refuses to cache — 2 requests for 2 identical calls.@stitchapi/fingerprint-zodabstains on.catchand on.transform, which are precisely the twospellings that survive a new enum value. A plain widened
z.enumfingerprints and self-invalidatescleanly. 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 itback — the hit path yields hard errors only.
Ask: mostly a docs ask — say that
.catch/.transformabstain, and that a cache hit does notreplay 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 awayAn
input.bodyrejection reaches the caller asStitchError{ name: 'StitchError', status: undefined, attempts: 0 }— discriminable from any otherfailure only by the
invalid body:message prefix. The spine'serrorevent carries the stitchname (
create), notValidationError, so the class name never leavesvalidateInput.Ask: preserve the name, or expose a discriminant that is not a message prefix.
Reproduction
A real
node:httpvendor on 127.0.0.1 with a request ledger; the fourth enum value is served from aflag, never a clock. C7 additionally probes which cache vocabulary the tree implements and prints it.
Which engine
drift.tsis byte-identical between this worktree andorigin/main;types.ts:70/72/74identical.
engine.tsline numbers shift but the cited lines do not change. Two real differences,neither affecting §§1–3:
validateInputreturns the parsed input on main, with error constructionline-for-line identical; and the ADR 0004 cache vocabulary moved from flat
cache.version/cache.onUnfingerprintableto acache.fingerprintenvelope.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'vsInvalid option: expected one of …) —validationErrorscopiesiss.messageverbatim, so thatdifference 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/zodis 3.25.76 whilepackages/core/package.jsondeclares^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 ·:74—export type DriftSeverity = 'warn' | 'info' | 'verbose';types.ts:70—DriftChange·:72—SoftDriftChange = Exclude<DriftChange, 'invalid'>drift.ts:100—return { levelOf: (c) => severity[c] ?? DEFAULT_LEVEL[c], allow: null };drift.ts:50-56—validationErrors, the bareissues.mapdrift.ts:77-79—detailForforcoerceddrift.ts:113—export function classifyDiff(·:145—if (matchAny(ignore, path)) continue;engine.ts:1020/:1238/:1453—if (finding.level === 'error') fatal = true;engine.ts:460-473—validateOutput;:468— the__kind === 'drift'branchFound 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/.