For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the launchpad verify whether a claim is actually true (not just "is this post real"): add a RealWorldEvent verifier (reason 1) that takes a free-text claim + sources, has validators independently search the web, and reaches consensus on a TRUE / FALSE / NOT_YET verdict — minting only on TRUE.
Architecture: A self-run search proxy (Supabase edge function, holds the search-API key
server-side, exposes a keyless endpoint) gives GenLayer validators real web search via
gl.nondet.web.request. A new _decide_realworld handler in ClaimVerifier.py gathers evidence
(user sources + independent search), then judges the claim under gl.eq_principle.prompt_comparative
so validators agree on the verdict label, not exact wording. Built on the generalized Phase-1
rails — no new contract changes on the Base side.
Tech Stack: Supabase edge function (Deno/TypeScript) + a search provider (Tavily by default), GenLayer Python intelligent contract (genlayer-test direct mode + studionet), Next.js/wagmi web app.
Reference: docs/design/2026-06-03-verified-claim-consensus.md — §2 principles, §6 the subjective
pipeline, §9 the search proxy. Phase-1 rails: docs/design/2026-06-03-verified-claim-phase1-plan.md.
- A search-provider API key. Default: Tavily (
https://api.tavily.com/search, AI-search, clean JSON, free tier). Swappable behind the proxy. The user providesTAVILY_API_KEY. - Supabase Management token already in gitignored
.envasSUPABASE_ACCESS_TOKEN(projectpeeqjjqpomjpdmnpogfs). - GenLayer CLI on
studionet, accountdefaultfunded (as in Phase 1).
- Verdict labels (from the LLM judge):
TRUE,FALSE,UNVERIFIABLE,NOT_YET. Map to the existing verdict status:TRUE→VERIFIED(0),FALSE/UNVERIFIABLE→REJECTED_EXTERNAL(1),NOT_YET→FAILED_TRANSIENT(2). Gate A (reject vague/future/opinion) is folded into the judge as theUNVERIFIABLElabel — one consensus call, not two. - Consensus mechanism:
gl.eq_principle.prompt_comparative. The generation function does the nondet web work (fetch user sources + call the search proxy) and asks the LLM for a strictLABEL|one-sentence-summary. The comparative criteria makes validators agree on the LABEL and that the summary is faithful — tolerating wording differences. If validators can't agree, the principle fails → caught →NOT_YET(honest "no consensus"). - Evidence: user
sources(fetched withgl.nondet.web.get, canonical URL only, no shorteners) PLUS the proxy search results. The user's link is never trusted alone. - First-party provenance: handled inside the judge prompt — the LLM is told that an official first-party source can stand alone only if the search corroborates the domain is the entity's official channel.
- Reason 1 only. ViralPost (reason 0) keeps its own handler unchanged. The other subjective reasons (GitHubRelease, SportsResult) are later plans reusing this pipeline.
| File | Responsibility | Action |
|---|---|---|
supabase/functions/search/index.ts |
keyless search proxy over the provider key | Create |
contracts-genlayer/ClaimVerifier.py |
_decide_realworld, _judge_claim, search-proxy call, identity |
Modify |
contracts-genlayer/tests/direct/test_claim_verifier.py |
direct tests for reason-1 verdicts | Modify |
apps/web/src/lib/reasons.ts |
flip real_event to live: true |
Modify |
apps/web/src/components/launch/LaunchWizard.tsx |
claim + sources inputs for non-ViralPost reasons | Modify |
apps/web/src/lib/launch.ts |
wizard validation for the general claim flow | Modify |
No Base-contract changes (the generalized launcher already carries reason, claim, sources).
RESULT: GO. The proxy (
supabase/functions/search) is deployed; a throwaway spike contract on studionet called it viagl.nondet.web.postand a validator stored 5 real source URLs, with consensus holding (ACCEPTED/AGREE) viaprompt_comparative. The spike contract was deleted; the proxy is committed. Verified API:gl.nondet.web.post(url, *, body: str|bytes, headers: dict)—bodyMUST be a JSON string (not a dict), response has.status+.body(bytes). Theweb.request(method=..., body=...)form also works butbodyis str/bytes either way.Purpose (kept for reference): prove a GenLayer validator can reach an external endpoint and get results stable enough for consensus before building the pipeline.
Files:
-
Create:
supabase/functions/search/index.ts -
Step 1: Write the minimal proxy
// Keyless search proxy. Validators POST {query}; we call the provider with the server-side key
// and return a compact, normalized result list. Holds TAVILY_API_KEY as a Supabase secret.
Deno.serve(async (req) => {
if (req.method !== "POST") return new Response("POST only", { status: 405 });
const { query } = await req.json().catch(() => ({ query: "" }));
if (!query || typeof query !== "string") return new Response(JSON.stringify({ results: [] }), { status: 400 });
const key = Deno.env.get("TAVILY_API_KEY");
if (!key) return new Response(JSON.stringify({ error: "no key" }), { status: 500 });
const r = await fetch("https://api.tavily.com/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: key, query, max_results: 5, search_depth: "basic" }),
});
const data = await r.json().catch(() => ({}));
// Normalize to a tiny, stable shape (title + url + snippet), capped, so validators converge.
const results = (data.results ?? []).slice(0, 5).map((x: Record<string, unknown>) => ({
title: String(x.title ?? "").slice(0, 200),
url: String(x.url ?? ""),
snippet: String(x.content ?? "").slice(0, 500),
}));
return new Response(JSON.stringify({ query, results }), {
headers: { "Content-Type": "application/json" },
});
});- Step 2: Set the provider key as a Supabase secret
Run (token + ref from .env):
set -a; source .env; set +a
curl -s -X POST "https://api.supabase.com/v1/projects/$SUPABASE_PROJECT_REF/secrets" \
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '[{"name":"TAVILY_API_KEY","value":"<USER_PROVIDED_KEY>"}]' -w "\nHTTP %{http_code}\n"Expected: HTTP 201.
- Step 3: Deploy the function
Use the Supabase MCP deploy_edge_function (slug search, the file above), or the Supabase CLI:
supabase functions deploy search --project-ref $SUPABASE_PROJECT_REF.
Record the function URL: https://<ref>.supabase.co/functions/v1/search.
- Step 4: Smoke-test the proxy directly (HTTP)
Run:
curl -s -X POST "https://$SUPABASE_PROJECT_REF.supabase.co/functions/v1/search" \
-H "Content-Type: application/json" -d '{"query":"Anthropic confidential S-1 SEC filing"}' | head -c 600Expected: JSON { "query": ..., "results": [ {title,url,snippet}, ... ] } with ≥1 result.
Files:
-
Create (throwaway):
contracts-genlayer/spike_search.py -
Step 1: Write a minimal IC that calls the proxy from a nondet block
# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" }
from genlayer import *
import json
PROXY = "https://<ref>.supabase.co/functions/v1/search"
class SpikeSearch(gl.Contract):
last: str
def __init__(self): self.last = ""
@gl.public.write
def search(self, query: str) -> str:
def gen() -> str:
resp = gl.nondet.web.request("POST", PROXY,
headers={"Content-Type": "application/json"},
body=json.dumps({"query": query}))
data = json.loads(resp.body.decode("utf-8"))
# Return only the result URLs, sorted — a stable surface for consensus.
urls = sorted([r.get("url", "") for r in data.get("results", [])])
return json.dumps(urls)
out = gl.eq_principle.prompt_comparative(
gen, "Two lists of source URLs are equivalent if they overlap substantially."
)
self.last = out.get() if hasattr(out, "get") else out
return self.lastNOTE: confirm the exact
gl.nondet.web.requestsignature against the write-contract skill before running — loadgenlayer-dev:write-contract. If the signature differs (arg order, headers kwarg,.bodyvs.text), adjust here; that is the point of the spike.
- Step 2: Deploy to studionet and call it
Run:
genlayer network set studionet
genlayer deploy --contract contracts-genlayer/spike_search.py --args
# then, with the deployed address:
genlayer write <ADDR> search --args "Anthropic confidential S-1 SEC filing"
genlayer receipt <tx> --stderr-
Step 3: GO/NO-GO decision
-
GO if: validators reached consensus (ACCEPTED, no stderr fault) and
lastholds a non-empty URL list. Deletespike_search.py, proceed to Part B. -
NO-GO if:
web.requestis blocked, times out, or validators can't agree on results. STOP. Record the failure mode and revisit §9 (options: SearXNG self-host returning a deterministic top-k; or pass search results in from the relayer instead of in-contract). Do not build Part C. -
Step 4: Commit the proxy (only after GO)
git add supabase/functions/search/index.ts
git commit -m "feat(search): keyless search proxy for validator web-search (spike GO)"Files:
-
Modify:
supabase/functions/search/index.ts -
Step 1: Make results stable and bounded (validators converge better on stable output)
Add to the function: sort results by url before returning; lowercase/trim the query server-side;
cap snippet to 500 chars (already), cap to 5 results (already); return { query, results } only (no
provider-specific fields). Add a RELAY_SECRET-style optional shared header check is not needed
(the endpoint is intentionally public/keyless), but add a basic query.length <= 300 guard.
- Step 2: Redeploy + re-smoke-test
Redeploy via MCP/CLI; re-run the Task 1 Step 4 curl. Expected: results sorted by url, ≤5.
- Step 3: Commit
git add supabase/functions/search/index.ts
git commit -m "feat(search): stable sorted bounded results for validator consensus"Files:
-
Modify:
contracts-genlayer/ClaimVerifier.py -
Test:
contracts-genlayer/tests/direct/test_claim_verifier.py -
Step 1: Write failing direct tests (append; use the harness web/LLM injection)
REAL_CLAIM = "Anthropic confidentially filed a draft S-1 with the SEC"
REAL_SRC = "https://www.anthropic.com/news/sec-filing"
def _rwe(claim=REAL_CLAIM, sources=REAL_SRC, **kw):
return _request(reason=1, claim=claim, sources=sources, **kw)
def test_rwe_true_claim_mints(direct_vm, direct_deploy, direct_owner):
c = _deploy_phase2(direct_vm, direct_deploy, direct_owner)
direct_vm.mock_web(r".*anthropic\.com.*", {"status": 200, "body": "Anthropic has confidentially submitted a draft registration statement (S-1) to the SEC."})
direct_vm.mock_web(r".*functions/v1/search.*", {"status": 200, "body": '{"results":[{"title":"Anthropic files S-1","url":"https://reuters.com/x","snippet":"Anthropic confidentially filed a draft S-1 with the SEC."}]}'})
direct_vm.mock_llm(r".*VERDICT.*", "TRUE|Anthropic confidentially filed a draft S-1 with the SEC.")
d = c.decode_verdict(c.preview_verdict(_rwe()))
assert d["status"] == 0 # VERIFIED
assert d["name"] != "" and d["symbol"].startswith("$")
def test_rwe_false_claim_rejected(direct_vm, direct_deploy, direct_owner):
c = _deploy_phase2(direct_vm, direct_deploy, direct_owner)
direct_vm.mock_web(r".*", {"status": 200, "body": "no such event"})
direct_vm.mock_web(r".*functions/v1/search.*", {"status": 200, "body": '{"results":[]}'})
direct_vm.mock_llm(r".*VERDICT.*", "FALSE|No credible source supports this claim.")
d = c.decode_verdict(c.preview_verdict(_rwe(claim="SpaceX landed humans on the moon yesterday")))
assert d["status"] == 1 # REJECTED_EXTERNAL
def test_rwe_vague_claim_unverifiable(direct_vm, direct_deploy, direct_owner):
c = _deploy_phase2(direct_vm, direct_deploy, direct_owner)
direct_vm.mock_web(r".*", {"status": 200, "body": ""})
direct_vm.mock_web(r".*functions/v1/search.*", {"status": 200, "body": '{"results":[]}'})
direct_vm.mock_llm(r".*VERDICT.*", "UNVERIFIABLE|This is an opinion, not a checkable fact.")
d = c.decode_verdict(c.preview_verdict(_rwe(claim="This project is going to be huge")))
assert d["status"] == 1 # REJECTED_EXTERNAL (gate A)
def test_rwe_unreachable_is_not_yet(direct_vm, direct_deploy, direct_owner):
c = _deploy_phase2(direct_vm, direct_deploy, direct_owner)
direct_vm.mock_web(r".*", {"status": 503, "body": ""})
direct_vm.mock_web(r".*functions/v1/search.*", {"status": 503, "body": ""})
direct_vm.mock_llm(r".*VERDICT.*", "NOT_YET|Sources were unreachable.")
d = c.decode_verdict(c.preview_verdict(_rwe()))
assert d["status"] == 2 # FAILED_TRANSIENT- Step 2: Run to verify they fail
Run: .venv/bin/pytest contracts-genlayer/tests/direct/test_claim_verifier.py -k rwe -v
Expected: FAIL — reason 1 currently returns FAILED_TRANSIENT for all.
- Step 3: Implement the handler in
ClaimVerifier.py
Add the proxy URL constant near the top:
SEARCH_PROXY = "https://peeqjjqpomjpdmnpogfs.supabase.co/functions/v1/search"
REASON_REALWORLD = 1Add the verdict-label map and handler:
def _decide_realworld(self, claim_id: bytes, reason: int, claim: str, sources: str,
name_ovr: str, sym_ovr: str, desc_ovr: str):
# dedup-bypass guard (same generalized formula as ViralPost)
if claim_id != self._expected_claim_id(reason, _primary(claim, sources)):
return (REJECTED_EXTERNAL, "", "", "")
label, summary = self._judge_claim(claim, sources)
if label == "TRUE":
identity = _claim_identity(claim, summary)
name = name_ovr if name_ovr != "" else identity["name"]
symbol = sym_ovr if sym_ovr != "" else identity["ticker"]
description = desc_ovr if desc_ovr != "" else summary[:280]
return (VERIFIED, name, symbol, description)
if label == "NOT_YET":
return (FAILED_TRANSIENT, "", "", "")
return (REJECTED_EXTERNAL, "", "", "") # FALSE or UNVERIFIABLE
def _judge_claim(self, claim: str, sources: str):
"""Comparative-consensus verdict over {claim, fetched user sources, independent search}."""
src_urls = [u for u in (sources.split("\n") if sources else []) if u != ""]
def gen() -> str:
# 1. fetch user-supplied sources (canonical only)
evidence = []
for u in src_urls[:3]:
try:
r = gl.nondet.web.get(_canonical(u))
if int(getattr(r, "status", 200) or 200) == 200:
evidence.append("SOURCE " + _canonical(u) + ": " + r.body.decode("utf-8")[:1200])
except Exception:
pass
# 2. independent web search via the proxy
# VERIFIED API (spike, 2026-06-03): gl.nondet.web.post(url, *, body: str|bytes, headers).
# `body` MUST be a str/bytes (json.dumps), NOT a dict; response has `.status` and `.body`.
try:
sr = gl.nondet.web.post(SEARCH_PROXY,
body=json.dumps({"query": claim}),
headers={"Content-Type": "application/json"})
for item in json.loads(sr.body.decode("utf-8")).get("results", [])[:5]:
evidence.append("WEB " + str(item.get("url", "")) + ": " + str(item.get("snippet", "")))
except Exception:
pass
bundle = "\n".join(evidence) if evidence else "(no evidence retrieved)"
prompt = (
"You verify whether a CLAIM is true using only the EVIDENCE.\n"
"Reply EXACTLY 'VERDICT=LABEL|<one factual sentence>' where LABEL is one of:\n"
"TRUE (evidence clearly supports the claim as written),\n"
"FALSE (evidence contradicts it, or no credible source supports a checkable claim),\n"
"UNVERIFIABLE (vague, opinion, prediction/future, or not objectively checkable),\n"
"NOT_YET (evidence sources were unreachable right now).\n"
"A claim may not exceed its evidence: a teaser is not a confirmation. "
"An official first-party source counts alone only if the search corroborates the domain "
"is that entity's official channel. Do not assume; judge only from EVIDENCE.\n\n"
"CLAIM: " + claim + "\n\nEVIDENCE:\n" + bundle + "\n"
)
out = str(gl.nondet.exec_prompt(prompt)).strip()
return out
try:
res = gl.eq_principle.prompt_comparative(
gen,
"Equivalent if the VERDICT label (TRUE/FALSE/UNVERIFIABLE/NOT_YET) matches and the "
"one-sentence reason is consistent with the evidence; ignore wording differences.",
)
raw = res.get() if hasattr(res, "get") else res
except Exception:
return ("NOT_YET", "")
return _parse_verdict(str(raw))Add module-level pure helpers:
def _parse_verdict(raw: str):
body = raw.split("VERDICT=", 1)[-1].strip() if "VERDICT=" in raw else raw.strip()
label, _, summary = body.partition("|")
label = label.strip().upper()
if label not in ("TRUE", "FALSE", "UNVERIFIABLE", "NOT_YET"):
label = "NOT_YET"
return (label, summary.strip())
def _claim_identity(claim: str, summary: str) -> dict:
base = (summary or claim)
words = [_clean_title_word(w) for w in base.replace("\n", " ").split(" ")]
words = [w for w in words if w != "" and w.lower() not in _STOP_WORDS][:3]
name = " ".join(words) if words else "Verified Claim"
return {"name": name[:42], "ticker": _ticker_from_name(name)[:8]}Wire the dispatch (in _decide, add the branch before the transient fallback):
if reason == REASON_REALWORLD:
return self._decide_realworld(claim_id, reason, claim, sources, name_ovr, sym_ovr, desc_ovr)- Step 4: Run to verify they pass
Run: .venv/bin/pytest contracts-genlayer/tests/direct/test_claim_verifier.py -k rwe -v
Expected: PASS (4 tests). Then run the full direct suite: .venv/bin/pytest contracts-genlayer/tests/direct -v → all green (ViralPost untouched).
- Step 5: Lint + commit
Run: ~/.local/bin/genvm-lint check contracts-genlayer/ClaimVerifier.py → checks pass.
git add contracts-genlayer/ClaimVerifier.py contracts-genlayer/tests/direct/test_claim_verifier.py
git commit -m "feat(verifier): RealWorldEvent (reason 1) — evidence + comparative-consensus verdict"Files:
-
Modify:
apps/web/src/lib/reasons.ts,apps/web/src/components/launch/LaunchWizard.tsx,apps/web/src/lib/launch.ts -
Step 1: Write/extend the validation test
In apps/web/src/lib/__tests__/launch.test.ts:
it("real_event flow requires a claim and at least one source", () => {
expect(isLaunchClaimValid({ reason: "real_event", claim: "", sources: "" })).toBe(false);
expect(isLaunchClaimValid({ reason: "real_event", claim: "Base teased an airdrop", sources: "" })).toBe(false);
expect(isLaunchClaimValid({ reason: "real_event", claim: "Base teased an airdrop", sources: "https://docs.base.org/x" })).toBe(true);
});
it("viral_post flow still validates the post URL only", () => {
expect(isLaunchClaimValid({ reason: "viral_post", claim: "https://x.com/a/status/1", sources: "" })).toBe(true);
expect(isLaunchClaimValid({ reason: "viral_post", claim: "not a url", sources: "" })).toBe(false);
});- Step 2: Run to verify it fails
Run: cd apps/web && pnpm vitest run src/lib/__tests__/launch.test.ts
Expected: FAIL — isLaunchClaimValid not defined.
- Step 3: Implement validation + flip the reason live
launch.ts — add a per-reason validator:
export function isLaunchClaimValid(s: { reason: string; claim: string; sources: string }): boolean {
if (s.reason === "viral_post") return isValidTweetUrl(s.claim);
// general subjective claim: need a non-trivial claim + at least one http(s) source
const hasSource = s.sources.split("\n").map((l) => l.trim()).some((l) => /^https?:\/\//i.test(l));
return s.claim.trim().length >= 8 && hasSource;
}reasons.ts — flip real_event to live:
{ key: "real_event", label: "Real-world event", blurb: "A public event that actually happened.", live: true },LaunchWizard.tsx — step 1 renders the post-URL input for viral_post and a claim textarea +
sources textarea for other live reasons; canNext for step 1 uses isLaunchClaimValid(s); the
claimId query uses s.sources for non-viral reasons. Concretely:
{step === 1 && s.reason === "viral_post" && (/* existing X post URL input bound to s.claim */)}
{step === 1 && s.reason !== "viral_post" && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="claim">The claim</Label>
<Textarea id="claim" rows={2} value={s.claim} onChange={(e) => set({ claim: e.target.value })}
placeholder="e.g. Base announced an airdrop on their official docs" />
</div>
<div className="space-y-2">
<Label htmlFor="sources">Source URLs <span className="text-ink-faint">(one per line)</span></Label>
<Textarea id="sources" rows={3} value={s.sources} onChange={(e) => set({ sources: e.target.value })}
placeholder="https://docs.base.org/…" />
</div>
<p className="text-xs text-ink-faint">Validators check these AND search the web independently before agreeing it’s true.</p>
</div>
)}And replace the step-1 canNext branch with return isLaunchClaimValid(s);.
- Step 4: Run tests + typecheck
Run: cd apps/web && pnpm vitest run && pnpm tsc --noEmit
Expected: all green.
- Step 5: Commit
git add apps/web/src
git commit -m "feat(web): enable RealWorldEvent flow — claim + sources inputs, per-reason validation"- Step 1: Redeploy
ClaimVerifier.py(now with the reason-1 handler) to studionet
Run:
genlayer network set studionet
genlayer deploy --contract contracts-genlayer/ClaimVerifier.py \
--args 0xce87655D60dCa6CA76183DEDc8582766e5DE4e57 \
0xcfBD25E80e075e7F34E79C7DaCbA2EAbD6Aca22f \
0xe008003cD89852149e6e28BCAe7d372a71B13e8CVerify genlayer schema <addr> + genlayer receipt <tx> --stderr. Record the new verifier address.
- Step 2: Re-point the launcher + flip Phase 2
set -a; source .env; set +a
PK="$BASE_SEPOLIA_PRIVATE_KEY"; case "$PK" in 0x*) ;; *) PK="0x$PK";; esac
cast send 0xe008003cD89852149e6e28BCAe7d372a71B13e8C "setClaimVerifier(address)" <NEW_VERIFIER> \
--rpc-url https://sepolia.base.org --private-key "$PK"
genlayer write <NEW_VERIFIER> set_phase2 --args trueVerify cast call 0xe008…3e8C "claimVerifier()(address)" == new verifier.
- Step 3: Record + commit addresses
Update deployments/base-sepolia.json claimLoop.claimVerifier (+ _prev). Commit.
- Step 1: Submit a TRUE claim (reason 1, real claim + real source)
cast send 0xe008003cD89852149e6e28BCAe7d372a71B13e8C \
"submitClaim(uint8,string,string,string,string,string,uint256)" \
1 "Anthropic confidentially filed a draft S-1 with the SEC" \
"https://www.anthropic.com/news" "" "" "" 1000000000000000000 \
--value 0.02ether --rpc-url https://sepolia.base.org --private-key "$PK"Record claimId = cast call … "claimIdForClaim(uint8,string,string)(bytes32)" 1 "<claim>" "<source>".
- Step 2: Watch to mint (~10–15 min; poll
claims(claimId)for status 2 andtokenForClaim)
Expected: status Launched, a token whose reason() == "RealWorldEvent" and whose
name/description came from the verified claim. This is the flagship working live.
- Step 3: Submit a FALSE claim and confirm rejection
cast send 0xe008003cD89852149e6e28BCAe7d372a71B13e8C \
"submitClaim(uint8,string,string,string,string,string,uint256)" \
1 "SpaceX landed humans on the Moon yesterday" \
"https://example.com/fake" "" "" "" 1000000000000000000 \
--value 0.02ether --rpc-url https://sepolia.base.org --private-key "$PK"Watch: status should become Rejected (3), no token minted. This proves the verifier says NO —
the anti-spam core. (A Retryable (4) is acceptable if sources were transiently unreachable.)
- Step 4: Record both proofs + push
Append a realWorldEventProven block to deployments/base-sepolia.json (true→token, false→rejected,
claimIds, txs). Commit and git push.
- §9 search proxy → Part A (spike) + Part B (harden). The GO/NO-GO gate is explicit. ✓
- §6.2 pipeline (fetch user sources + independent search + judge) → Task 4
_judge_claim. ✓ - §2 principles: sources required + gate A (UNVERIFIABLE), independent search, three verdicts, reasonable-reader, claim-can’t-outrun-evidence, first-party provenance → all in the judge prompt + label mapping (Task 4). ✓
- §6.3 consensus on a judgment →
gl.eq_principle.prompt_comparativeagreeing on the LABEL. ✓ - §8 dedup → reused generalized guard (
_expected_claim_id) in_decide_realworld. ✓ - Built on Phase-1 rails, no Base contract change → confirmed; only the verifier + web change. ✓
- Proven live, both directions → Part E (true mints, false rejected). ✓
Out of scope (later plans): GitHubRelease + SportsResult (reuse this pipeline, new prompts/sources),
and the Objective family (Onchain gl.eth spike, Price feed, first real DEX graduation).
Hard-risk note for the implementer: the make-or-break is the judge prompt resisting LLM
agreement-bias — it must confidently return FALSE/UNVERIFIABLE. The four direct tests pin the label
mapping, but before trusting it live, run a handful of adversarial claims (opinion, future,
unsupported, true-but-fresh) through preview_verdict on studionet and eyeball the labels.