The rule every layer upholds code finds, Jev judges, the dashboard shows the probabilities.
Crawler
Promises: every number here is a count. Nothing in this file asks a question.
Invariants
  • Bounded three ways at once — page count, depth, and a wall-clock budget
  • Sitemap URLs seed the queue when one exists, else the homepage’s internal links
  • A second pass stamps inbound degree, because a page cannot know it mid-fetch
  • Jitter is not decoration — eight workers backing off identically retry in lockstep
Traps
  • SSRF. assertPublicUrl rejects loopback, private and link-local hosts, so a pasted URL cannot reach an internal service. No DNS step — which is also why it is easy to test.
  • ruleFindings cannot stream. The rule pass is site-wide and needs the finished link graph, so a per-page count at fetch time would be a lie.
  • Non-HTML is skipped silently. Not an error, and it does not count toward maxPages.
opencode session
Promises: every figure it reports came from a tool it actually called.
Invariants
  • The allowlist is built per run from the onboarding gate — no verified GSC, no gsc tool at all
  • Three gates, each fatal: schema-invalid, ungranted-tool, unsourced-number
  • Citations naming an uncalled tool are flagged, not fatal — a mislabelled name is smaller than a fabricated metric
Traps
  • A granted tool must be a real MCP server. TOOL_IDS, the allowlist, the name pattern, the description and .mcp.json are one fact in five places. A granted tool with no server is worse than a missing one.
  • google-trends was removed, not registered. No official API, and its “relative interest” index is the pseudo-volume this project refuses to show a paying customer.
  • Crawled page text is untrusted. A blanket write grant would let page-prompted HTML drop a file anywhere on disk. This is the entire prompt-injection surface.
Jev decision
Promises: every question was asked, and the state it saw is recorded.
Invariants
  • One request per item, always. A page is one item, a site is one item, a keyword is one item
  • The state is derived from the question set, never the reverse — irrelevant context measurably lowers accuracy
  • Each state carries a state_meta block saying what was trimmed; a judgement over half a page must be able to see that
  • Gap buckets are a pure function of five booleans — composed, so they can be checked and re-derived when a threshold moves
Traps
  • Jev has no search volume, no index, no backlink graph. There is no way to produce a monthly search count from this state. Opportunity is computed in code (opportunityOf); change coefficients there, never in a prompt.
  • Widening act is the easiest way to make this product wrong. It raises apparent coverage at the cost of every false positive on the page.
  • Undecided resolves toward the actionable side. A term we are unsure we cover is treated as not covered — writing a page wrongly costs an afternoon, assuming coverage means it never gets written.
Dashboard UI
Promises: renders before the run ends.
Invariants
  • The audit is a stream, not a request/response — one NDJSON event per line as it happens
  • Read with fetch + response.body.getReader(), because EventSource cannot POST
  • The three views — pages, keywords, competitors — are three lenses on the same stream
Traps
  • res.on(“close”), never req. A POST request's req emits close the instant its body is read, which is immediately — listening there tears the stream down after the first event.
  • Buffer-busting headers are load-bearing. Cache-Control: no-store, X-Accel-Buffering: no, flushHeaders(). Without them a proxy holds the whole stream and the UI looks broken while the server works fine.
  • A field can be absent from the stream and present in the report. Per-page ruleFindings is the current example. Render streamed fields as optional and reconcile from done.
Three primitives, nothing else
choicescorenoul
noul is a real name, not a typo — renaming it breaks the schema
Three bands
actreviewescalate
the grey zone goes to a “needs a human” pile, deliberately
Stream
NDJSONnot SSE
EventSource cannot POST