Scenario: not-json
Proofs: docs/scenarios/proofs/not-json/ (8 scripts, 274 checks, real node:http)
No disclosure content — correctness, retry-classification and observability findings. One item
(§5) is a privacy asymmetry that currently favours the failure path; it is reported because the
protection exists and works, and the success path bypasses it.
First, what works
hooks.onResponse is exactly the right seam and behaves correctly: it sees headers and body,
its rejection is not retried (1 request under retry: { attempts: 3 }, versus 3 for a
wrapping adapter or fetchAdapter({ fetch })), and it can mutate ctx.res.body — which is how a
correct JSON payload mislabelled text/html is recovered rather than rejected.
- The non-enumerable error channel does real work.
engine.ts:370-378 pins the failing response
where "the body never serialises into a trace sink." Measured: on the failure path a sink sees
neither <!DOCTYPE nor the maintenance text.
- The whole arrangement is 25 executable lines and holds four properties at once: six not-JSON
responses detected before the caller, 200 characters of the page on StitchError.body, zero
characters in the sink, 1 request per call, and legitimate non-JSON endpoints untouched.
1. Declaring the response type makes the retry worse, not better
http-adapter.ts:129-138 auto-detects: application/json/+json parses, everything else becomes
response.text(). So an HTML challenge at 200 is a value. The natural corrective —
wire: { response: 'json' } — forces JSON.parse, and the resulting SyntaxError is thrown by the
adapter, which lands in the transport catch (engine.ts:691-704) that retries on
attempt < max regardless of retry.on.
Measured against a real challenge endpoint, counting requests server-side:
| arrangement |
requests the vendor counted |
200 challenge, retry: { attempts: 3 } |
1 |
200 challenge, wire.response: 'json', same retry |
3 |
With circuit: { failures: 2 } the second arrangement also opens the breaker after 2, where the
first never opens it across 4 calls. So the config change that makes the failure visible also
makes the client hammer the thing that is watching for automated behaviour.
Ask: this is the third scenario in the pass to hit "a transport throw is retried on attempt
count alone" (already filed as #696 and #688). This one is the sharpest argument for it: a
decode failure is deterministic, and retrying it cannot help.
2. When the parse throws, 1.1 % of the page survives and no seam holds the rest
.body, .url, .status and .cause are all undefined. The only trace of the document is
the fragment V8 quotes into the message — Unexpected token '<', "<!DOCTYPE "... is not valid JSON
— 10 of 952 characters. The adapter raised a real SyntaxError with that identical message and
the class identity is not preserved as .cause.
On that path hooks.onResponse never fires, and hooks.onError's context is exactly
{ attempt, error, name } — no res. So nothing can retain the page, which is the one artefact
that says what actually happened (its <title> reads Just a moment...).
By contrast, at 403 or 503 the whole page is on StitchError.body with .url.
Ask: attach the response to a decode failure the way a status failure already does — the
adapter has it in scope.
3. output's diagnosis is identical for three different causes
An output contract does run (a 200 passes interpret) and does reject the page. Its entire
diagnosis is one error/invalid finding at the root path, Expected object, received string —
byte-identical for a WAF challenge, a login form and a mislabelled payload. The words html,
page, challenge, login, maintenance and < appear 0 times across findings, message and
status.
drift() adds nothing: validateOutput short-circuits on hard errors before classifyDiff
(engine.ts:466-467), so soft drift never sees a diff. Control: on healthy data the same drift()
fires info/undeclared, so the silence is the mechanism, not a misconfiguration.
Also worth knowing: output: z.string() validates the challenge page.
Ask: not a bug — but a line in the drift guide that a root-level invalid on a string almost
always means "this response is a different document", since that is the common cause and the
message does not say it.
4. A Content-Type check is the worst of the three obvious rules
Scored over ten real responses (6 not-JSON arms, a healthy control, a CSV export, a text/plain
health check, a 204):
| rule |
wrong verdicts / 10 |
false positives |
| content-type is JSON |
6 |
4 |
first-byte sniff < |
1 |
0 |
vendor marker cf-mitigated |
4 |
0 |
| first-byte + empty-body |
0 |
0 |
The content-type rule's false positives are all working endpoints: the CSV export, the health check,
the 204, and a correctly-shaped payload served under text/html. Worth stating somewhere, because
it is the rule everyone reaches for first.
5. The trace protection is exactly backwards
On the failure path the error event carries no body — by design, and it works. On the
success path the result event hands a TraceSink the entire 952-character vendor page,
because a challenge is a successful call with a string value.
So the privacy control is present on the path where the body is a known error and absent on the path
where the body is an unknown document from an intermediary.
Ask: consider bounding, or making configurable, what result.data hands a sink — or at minimum
note in the trace guide that result is unbounded where error is not.
6. A seam-level guard cannot be opted out of by a member
Hooks chain child→base (stitch.ts:133-137) — both run — so a legitimately non-JSON member of a
guarded seam inherits the guard and breaks. Measured: the CSV export and the text/plain health
check both fail. The cheapest escape is 3 executable lines that pre-wrap the body, and it changes
the member's result type, so the caller has to unwrap.
Ask: a documented way for a member to replace rather than extend an inherited hook, or a note in
the seam guide that hooks are additive and cannot be removed downstream.
Reproduction
npx tsx docs/scenarios/proofs/not-json/c3-retry-and-the-waf.ts
Eight scripts run offline against a real node:http server that serves a faithful reconstruction of
an interstitial challenge (title, ray id, noscript block, _cf_chl_opt script, plus
cf-mitigated/cf-ray/Server headers), a maintenance page, a real 302 to a login form, both
content-type mislabels, an empty 200, a 204, a CSV export and a text/plain health check. Request
counts are read from the server's ledger.
Source references (verified against origin/main)
http-adapter.ts:129-138 — const isJson = responseType === 'json' || contentType.includes('application/json') || contentType.includes('+json'); if (isJson) { … parsed = text === '' ? undefined : JSON.parse(text); } else { parsed = await response.text(); }
engine.ts:633 / :635 — const max = cfg.retry?.attempts ?? 1; / const retryMatch = acceptsStatus(cfg.retry?.on ?? [429, 502, 503, 504]);
engine.ts:691-704 — the transport try/catch; if (attempt < max) { with no error classification
engine.ts:370-378 — Object.defineProperty(evt, ERROR_SOURCE, { value: err, enumerable: false }); and its comment
engine.ts:466-467 — const { value: validated, errors } = await validateValue(cfg, raw); if (errors.length) return { value: raw, findings: errors };
engine.ts:728 — await cfg.hooks?.onResponse?.({ name: nameOf(cfg), attempt, res });
engine.ts:847-854 — the status-failure path that pins e.response = res
stitch.ts:133-137 — // onResponse/onError/onRetry unwind child→base / const ordered = k === 'onRequest' ? fns : fns.slice().reverse();
stitch.ts:547-555 — rebuildError populating body/url from the pinned response
surface.ts:234-237 — httpInterpret = (res, cfg) => verdictOf(res, cfg) ?? { ok: true, data: res.body };
types.ts:1435-1447 — HookContext / Hooks
drift.ts:50-57 — validationErrors, mapping each issue to level: 'error', change: 'invalid'
Found by a scenario pass that researches a real-world API integration problem, captures it, and proves or refutes each claim with runnable offline code. Every source reference above was read on origin/main.
Scenario: not-json
Proofs:
docs/scenarios/proofs/not-json/(8 scripts, 274 checks, realnode:http)First, what works
hooks.onResponseis exactly the right seam and behaves correctly: it sees headers and body,its rejection is not retried (1 request under
retry: { attempts: 3 }, versus 3 for awrapping adapter or
fetchAdapter({ fetch })), and it can mutatectx.res.body— which is how acorrect JSON payload mislabelled
text/htmlis recovered rather than rejected.engine.ts:370-378pins the failing responsewhere "the body never serialises into a trace sink." Measured: on the failure path a sink sees
neither
<!DOCTYPEnor the maintenance text.responses detected before the caller, 200 characters of the page on
StitchError.body, zerocharacters in the sink, 1 request per call, and legitimate non-JSON endpoints untouched.
1. Declaring the response type makes the retry worse, not better
http-adapter.ts:129-138auto-detects:application/json/+jsonparses, everything else becomesresponse.text(). So an HTML challenge at 200 is a value. The natural corrective —wire: { response: 'json' }— forcesJSON.parse, and the resultingSyntaxErroris thrown by theadapter, which lands in the transport catch (
engine.ts:691-704) that retries onattempt < maxregardless ofretry.on.Measured against a real challenge endpoint, counting requests server-side:
retry: { attempts: 3 }wire.response: 'json', same retryWith
circuit: { failures: 2 }the second arrangement also opens the breaker after 2, where thefirst never opens it across 4 calls. So the config change that makes the failure visible also
makes the client hammer the thing that is watching for automated behaviour.
Ask: this is the third scenario in the pass to hit "a transport throw is retried on attempt
count alone" (already filed as #696 and #688). This one is the sharpest argument for it: a
decode failure is deterministic, and retrying it cannot help.
2. When the parse throws, 1.1 % of the page survives and no seam holds the rest
.body,.url,.statusand.causeare allundefined. The only trace of the document isthe fragment V8 quotes into the message —
Unexpected token '<', "<!DOCTYPE "... is not valid JSON— 10 of 952 characters. The adapter raised a real
SyntaxErrorwith that identical message andthe class identity is not preserved as
.cause.On that path
hooks.onResponsenever fires, andhooks.onError's context is exactly{ attempt, error, name }— nores. So nothing can retain the page, which is the one artefactthat says what actually happened (its
<title>readsJust a moment...).By contrast, at 403 or 503 the whole page is on
StitchError.bodywith.url.Ask: attach the response to a decode failure the way a status failure already does — the
adapter has it in scope.
3.
output's diagnosis is identical for three different causesAn
outputcontract does run (a 200 passesinterpret) and does reject the page. Its entirediagnosis is one
error/invalidfinding at the root path,Expected object, received string—byte-identical for a WAF challenge, a login form and a mislabelled payload. The words
html,page,challenge,login,maintenanceand<appear 0 times across findings, message andstatus.
drift()adds nothing:validateOutputshort-circuits on hard errors beforeclassifyDiff(
engine.ts:466-467), so soft drift never sees a diff. Control: on healthy data the samedrift()fires
info/undeclared, so the silence is the mechanism, not a misconfiguration.Also worth knowing:
output: z.string()validates the challenge page.Ask: not a bug — but a line in the drift guide that a root-level
invalidon astringalmostalways means "this response is a different document", since that is the common cause and the
message does not say it.
4. A
Content-Typecheck is the worst of the three obvious rulesScored over ten real responses (6 not-JSON arms, a healthy control, a CSV export, a
text/plainhealth check, a 204):
<cf-mitigatedThe content-type rule's false positives are all working endpoints: the CSV export, the health check,
the 204, and a correctly-shaped payload served under
text/html. Worth stating somewhere, becauseit is the rule everyone reaches for first.
5. The trace protection is exactly backwards
On the failure path the
errorevent carries no body — by design, and it works. On thesuccess path the
resultevent hands aTraceSinkthe entire 952-character vendor page,because a challenge is a successful call with a string value.
So the privacy control is present on the path where the body is a known error and absent on the path
where the body is an unknown document from an intermediary.
Ask: consider bounding, or making configurable, what
result.datahands a sink — or at minimumnote in the trace guide that
resultis unbounded whereerroris not.6. A seam-level guard cannot be opted out of by a member
Hooks chain child→base (
stitch.ts:133-137) — both run — so a legitimately non-JSON member of aguarded seam inherits the guard and breaks. Measured: the CSV export and the
text/plainhealthcheck both fail. The cheapest escape is 3 executable lines that pre-wrap the body, and it changes
the member's result type, so the caller has to unwrap.
Ask: a documented way for a member to replace rather than extend an inherited hook, or a note in
the seam guide that hooks are additive and cannot be removed downstream.
Reproduction
Eight scripts run offline against a real
node:httpserver that serves a faithful reconstruction ofan interstitial challenge (title, ray id,
noscriptblock,_cf_chl_optscript, pluscf-mitigated/cf-ray/Serverheaders), a maintenance page, a real 302 to a login form, bothcontent-type mislabels, an empty 200, a 204, a CSV export and a
text/plainhealth check. Requestcounts are read from the server's ledger.
Source references (verified against
origin/main)http-adapter.ts:129-138—const isJson = responseType === 'json' || contentType.includes('application/json') || contentType.includes('+json'); if (isJson) { … parsed = text === '' ? undefined : JSON.parse(text); } else { parsed = await response.text(); }engine.ts:633/:635—const max = cfg.retry?.attempts ?? 1;/const retryMatch = acceptsStatus(cfg.retry?.on ?? [429, 502, 503, 504]);engine.ts:691-704— the transporttry/catch;if (attempt < max) {with no error classificationengine.ts:370-378—Object.defineProperty(evt, ERROR_SOURCE, { value: err, enumerable: false });and its commentengine.ts:466-467—const { value: validated, errors } = await validateValue(cfg, raw); if (errors.length) return { value: raw, findings: errors };engine.ts:728—await cfg.hooks?.onResponse?.({ name: nameOf(cfg), attempt, res });engine.ts:847-854— the status-failure path that pinse.response = resstitch.ts:133-137—// onResponse/onError/onRetry unwind child→base/const ordered = k === 'onRequest' ? fns : fns.slice().reverse();stitch.ts:547-555—rebuildErrorpopulatingbody/urlfrom the pinned responsesurface.ts:234-237—httpInterpret = (res, cfg) => verdictOf(res, cfg) ?? { ok: true, data: res.body };types.ts:1435-1447—HookContext/Hooksdrift.ts:50-57—validationErrors, mapping each issue tolevel: 'error', change: 'invalid'Found by a scenario pass that researches a real-world API integration problem, captures it, and proves or refutes each claim with runnable offline code. Every source reference above was read on
origin/main.