FROZEN — immutable history. Superseding/current decisions live in
docs/decisions/. Do not edit.
ADR 0005 - Embedded-state extraction: structured sidecar + JS-shell warning#
Status: Accepted (2026-07-02). Shipped in 0.6.0.
1 Context#
Many activity/listing pages are JS-rendered: the HTML the fetcher receives is a shell whose visible DOM is nearly empty, with the real record living in machine-readable state the browser would hydrate. Two shapes are common and near-perfect precision without an LLM:
<script type="application/ld+json">schema.org markup (Event / Course / Product), and- framework state blobs: Next.js
<script id="__NEXT_DATA__">and genericwindow.__INITIAL_STATE__/__PRELOADED_STATE__hydration payloads.
Traverse's prep layer STRIPS every <script> before it builds the prepared text
(ADR 0004), so this state is thrown away today: a shell page yields little or
nothing, and even a content page loses its high-precision JSON-LD.
Two features close that gap, both at the prep layer so Traverse stays fetch-agnostic (it never renders):
- harvest the embedded state so a caller can use it, and
- warn, machine-actionably, when a page looks like an un-rendered shell so a downstream pipeline can retry with a browser render.
Issue #11 left one design question open: surface embedded state as pre-verified proposals (provenance = the script tag's char range) or as a structured sidecar.
2 Decision#
D1 - Structured sidecar, NOT proposals (the provenance invariant decides it)#
Embedded state is surfaced as a parsed EmbeddedState sidecar
({ jsonLd: unknown[]; nextData?; initialState? }) on the prep/extract result —
NOT as ExtractionProposals.
This is forced by the provenance invariant (ADR 0001 §4, ADR 0004 D3), which is
sacred: every proposal's excerpt MUST occur verbatim in the FULL prepared text,
and extract() derives locator = "chars:<start>-<end>" as offsets into that
prepared text, re-verifying with indexOf. Embedded state comes from <script>
blocks that prep strips, so it is NOT in the prepared text. A JSON-LD-derived
proposal would therefore be dropped by the very normalizer that enforces the
contract. Making it survive would require one of:
- injecting the JSON-LD text back into the prepared text (pollutes the text the provider sees and every downstream offset), or
- a second locator scheme anchored to raw-HTML offsets (forks the one invariant
extract()guarantees — a consumer could no longer trust that achars:locator always indexes the prepared text).
Both damage the property that makes a proposal trustworthy. The issue's own suggestion ("provenance = the script tag's char range") is exactly the raw-HTML offset scheme we reject: those offsets index the original document, not the prepared text every other locator is anchored to.
The sidecar sidesteps this cleanly: it is honest about being derived from a
different source (the raw markup, not the prepared text), it carries no false
chars: provenance, and it keeps domain mapping (JSON-LD Event/Course/Product ->
caller field names) where it belongs — with the caller, who owns ALL field
vocabulary (Traverse defines zero field names; see src/types.ts and ADR 0001).
A future slice can add opt-in, honestly-scoped embedded-provenance proposals if
a caller wants them, but that is additive and not built here.
D2 - Harvesting runs BEFORE shell classification#
prepareAndChunk (and prepareContent) harvest embedded state from the raw HTML
first, then classify the shell. A shell page carrying rich __NEXT_DATA__ is
extractable from the sidecar WITHOUT a render — that is the whole win — so the
warning is downgraded in that case (D3).
The sidecar is harvested ONCE from the whole page and attached to the top-level result, so it is never duplicated across chunks. It is also attached even when every provider call fails (it is prep-derived, not provider-derived), so a shell page stays extractable from the sidecar on a total provider outage.
D3 - JS-shell detection: absolute-floor-gated heuristic, machine-actionable warning#
detectJsShell flags a page as a suspected shell only when ALL of:
- prepared text is below an ABSOLUTE floor (
SHELL_PREPARED_TEXT_FLOOR = 600chars) AND belowSHELL_TEXT_RATIO_MAX = 8%of the raw HTML, AND - a structural shell signal holds: scripts are
>= SHELL_SCRIPT_RATIO_MIN = 45%of the raw HTML, OR a known client-render mount (#root/#__next/#app) is present but empty.
The absolute floor is the primary false-positive guard and is why it is a floor, not a bare ratio: the real 2.7MB listing from ADR 0004 prepares to ~23k chars — a 0.85% text ratio that a ratio-only heuristic would wrongly flag — yet it is content-rich. 23k >> 600, so it is never flagged. A content page is defined by having real prepared text, regardless of how heavy its analytics/framework scripts are; requiring low ABSOLUTE text (not just a low ratio) encodes that.
The warning is emitted through the existing warnings string channel but is
machine-actionable: it STARTS with a stable code and carries the ratio numbers.
js-shell-suspected: ...— likely a shell, no usable embedded state -> caller should render upstream and retry.js-shell-suspected-embedded-state-available: ...— same shell shape BUT embedded state was harvested -> caller should prefer the sidecar and NOT render.
Both start with js-shell-suspected, so a coarse startsWith check catches
either while the suffix distinguishes the render-vs-sidecar decision.
3 Thresholds and false-positive testing#
| Constant | Value | Role |
|---|---|---|
SHELL_PREPARED_TEXT_FLOOR |
600 chars | absolute floor; primary false-positive guard |
SHELL_TEXT_RATIO_MAX |
0.08 | prepared text must be a tiny slice of the HTML |
SHELL_SCRIPT_RATIO_MIN |
0.45 | script-dominated signal |
Tested in BOTH directions with realistic fixtures:
- shell-positive (
spa-shell-empty.html): empty#root, script-dominated, no data ->js-shell-suspected. - shell-with-embedded (
js-shell-next.html): empty#__next+ rich__NEXT_DATA__-> downgradedjs-shell-suspected-embedded-state-available, and the record is recoverable from the sidecar. - shell-negative (
content-rich-heavy-scripts.html): four real content paragraphs PLUS a heavy analytics payload and a JSON-LD Event -> NOT flagged, because prepared text clears the floor; the JSON-LD is still harvested.
4 Size caps#
All embedded values are JSON.parse-d and size-capped to bound memory on
pathological inputs; over-cap blocks are skipped with an
embedded-state-size-capped warning, never a throw: MAX_JSONLD_BLOCKS (25),
MAX_RAW_BLOCK_CHARS (256k, per-block parse guard), MAX_JSONLD_TOTAL_CHARS
(256k cumulative), MAX_NEXTDATA_CHARS / MAX_INITIAL_STATE_CHARS (512k each).
Malformed blocks are dropped with a typed embedded-*-parse-failed warning.
5 Consequences#
- New public surface:
EmbeddedStatetype,ExtractionResult.embedded,PreparedChunks.embedded, andprepareContent().embedded/.warnings; helper exportsharvestEmbeddedState,detectJsShell,inspectHtml, the shell codes/thresholds, andShellSignals. All additive. - One extra
linkedomparse of the raw HTML per HTML page (harvest must see the UN-pruned document). Accepted for correctness; the prep layer already parses, and a future optimization could share one parse. No new runtime dependency. - Traverse still never renders and still owns zero field vocabulary; mapping the sidecar onto domain fields remains the caller's job.