feat(collect): competitor fee/liquidity watch into raw notes ingest #14
Labels
No labels
agent:implement
agent:ready
ready
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
code/cl8y-research#14
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Add a scheduled competitor-watch collector that fetches a committed allowlist of public competitor fee and liquidity pages (JSON preferred, HTML only with a per-page extractor) and upserts them as raw notes into the existing ingest/search store. This is an inbox, not a blog run: no plan / outline / draft / editor / emit, no auto-publish to
CL8Y-web.Bundle (do not split):
SourceKind::CompetitorWatch— notonchain_authoritative. Postgresparse_kindmust recognize it (must not fall through toRepo).https://host + path (+ optional query) insrc/invariants.rs. Operator-controlled. No crawl, no sitemap, no Tavily second-hop.watchCLI — collect → wrap untrusted → upsert store → writeruns/watch/<id>/{notes.json,gaps.md}. Fixture-first. Live is explicit (--live-watch).unsourced()for CL8Y DEX indexer claims and never passlintas invented CL8Y fee percents.INDEXER_HOSTS. Size/time caps. No JS runtime.Related (do not merge into this ticket):
dex.cl8y.com/bridge.cl8y.comHTML. Own-protocol stats stay there.collect → plan → outline → draft → editor → emit.cl8y.com.Current codebase
cl8y-researchis the dedicated Rust worker soCL8Y-webstays a static Vite SPA. Collectors today are fixture JSON. Live indexer GET exists but is unused byweek.Collect is first-party sources only
src/collect.rsmodule comment: never scrape dapp HTML.collect_from_fixturereadsfixtures/*/sources.jsonintoSourceRecords:RepoDexIndexerGET {DEX_INDEXER_BASE}/api/v1/overviewBridgeIndexerTelegramRecentPostThere is no competitor kind, no fee/liquidity page table, no HTML extractor.
FixtureBundlekeys:repos,dex_overview/dex_error,bridge_*,telegram,recent_posts,ssrf_url. Happy fixture:fixtures/happy-week/sources.json.Fetch helper is indexer-only
fetch_allowlisted_jsoncallscheck_indexer_url→INDEXER_HOSTSonly (indexer.dex.cl8y.com,indexer.bridge.cl8y.com). HTTPS, 8s timeout, no redirects, thenresp.json(). It cannot fetch a competitor host without failing the allowlist. Architecture: “Live HTTP never follows redirects and never fetches user-controlled URLs.”Inbox exists as ingest/search, not as a watch feed
CLI (
src/main.rs):week— full pipeline (--dry-run= collect + plan).ingest— fixture collect → in-memoryMemoryStore(prints a count; drops on process exit).search— re-collects the fixture each call; does not read a durable inbox.migrate— Postgres when--features postgres.MemoryStore::ingest_sourcesupserts non-emptytextby(source_kind, source_id). Postgresparse_kindmaps unknown strings toRepo(#7 already calls this wrong). A new kind that is not in the match arm would be stored as a git remote — unacceptable for competitor HTML.There is no
watchsubcommand, noruns/watch/artifact layout, no schedule that ingest-only competitor pages.Numbers and lint treat fee percents as invented CL8Y copy
src/numeric.rsunsourced()flags body numbers not present inNumericClaims (DEX overview volume is the usual claim).src/lint.rsrejects\d+% … feeas invented fee percent and invented tier names. Competitor notes that mention “0.3% fee” must be cited competitor claims in the inbox; they must not leak into MDX as CL8Y fee copy, and they must not launder a fake CL8Y volume throughunsourced().Authorship / wrapping
wrap_untrustedwraps collected JSON for the model. Competitor HTML/JSON is more hostile than our indexer.onchain_authoritativeis onlyDexIndexer | BridgeIndexer | Repotoday — competitor pages must stay off that list.Week does not need competitor pages to emit
run_weekalways loadssources.jsonandcollect_from_fixture. Watch must not become a hidden seventh pipeline stage. Optional later: week may cite already-ingested competitor notes as untrusted context; out of scope to require that for this ticket.Why the new implementation is needed
DexIndexerwould poison citations andonchain_authoritative.ingestis not durable. A raw-notes inbox means upsert into the store (Postgres whenDATABASE_URLis set; memory +notes.jsonartifact always) on a schedule, queryable bysearchwithout runningweek.This is collect + store. No contract change, no DEX indexer endpoint, no marketing-site scrape, no model/SKU/host choice.
Constraints / guardrails
COMPETITOR_WATCH_PAGES: id,httpsURL (no userinfo, no explicit port, no punycode, no open-redirect query keysurl|redirect|next|return|goto), extractor id,Accept(application/jsonortext/html).cl8y.com.attacker.tldmust not match. Adding a page is a code change + tests; env vars must not accept arbitrary URLs (SSRF).fee_bps/fee_note,liquidity_or_tvl,volume_24hif present,as_of). Missing selectors → gap, not a guessed number.eval, no executing<script>. GET body only. Charset UTF-8 / Latin-1 best effort. Body cap (e.g. 512 KiB); oversize → gap.<a href>,<iframe>, CSS, images, or URLs inside JSON. Do not follow HTTP redirects (same as indexer client).dex.cl8y.com,bridge.cl8y.com,cl8y.com,ust1cmm.comHTML. Our numbers stay #4 / indexer JSON.SourceKind::CompetitorWatch.onchain_authoritative() == false. Plan/MDX must not treat competitor TVL as CL8Y volume.NumericClaim.source_idprefixcompetitor-watch:.unsourced()for week emit continues to use indexer/repo claims only (filter competitor claims out ofall_claimsused for MDX, or tag claims with kind). Lint still rejects unsourced CL8Y fee percents in posts.wrap_untrusted. Prompt-injection strings in pages stay in notes; they must not appear inpost.mdxbecause watch does not emit MDX. If week later cites notes, wrap still applies.notes.json.User-Agentidentifyingcl8y-research competitor-watch+ contact hostcl8y.com. 429/503 → gap that page, continue others. Do not retry-storm.watchfrom the existing worker cadence (same binary asweek --dry-run). Do not add a second service or pick infrastructure. Live watch is--live-watch; CI stays on fixtures.allowlist.rs). Fail closed on pin failure once available.robots.txtdisallows the exact path, skip with a gap (fixture covers a disallowed path). Do not scrape authenticated or paywalled fee schedules.watch.CL8Y-webMDX from this ticket.Initial allowlist (product intent, implementer verifies HTTP 200 before commit):
Seed from venues already named in-tree, not a web crawl:
docs/competitor-watch.md, do not invent an HTML scrape of a random marketing site).If fewer than three URLs verify, ship the mechanism with fixtures + one live URL and document the rest as gaps. Do not block the ticket on a large crawl.
Relevant files
src/store.rsSourceKind::CompetitorWatch;as_str;onchain_authoritative; Postgresparse_kindarm (unknown must not becomeRepofor this kind)src/invariants.rsCOMPETITOR_WATCH_PAGEStable; caps; User-Agent constantsrc/allowlist.rscheck_competitor_watch_url(exact URL / host+path, notINDEXER_HOSTS)src/collect.rscheck_indexer_urlsrc/main.rswatchsubcommand;ingest/searchcan read watch artifactssrc/numeric.rssrc/lint.rssrc/config.rs/.env.exampleCOMPETITOR_WATCH_LIVE=1equivalent via CLI flag onlysrc/pipeline.rsall_claimsmust ignore competitor kind if week is run on a mixed collectionfixtures/happy-week/orfixtures/competitor-watch/tests/issue_plan.rstests/competitor_watch.rs)docs/invariants.mddocs/architecture.mddocs/competitor-watch.mdskills/cl8y-research-search/SKILL.mdMakefilewatchfixture targetRecommended direction
CompetitorPage { id, url, format: Json | Html, extractor }.check_competitor_watch_url(url)must equal the committed string (or host + path + exact query), not “same host any path.”WatchNote { page_id, citation, as_of, fee, liquidity, volume_24h, raw_excerpt }. Null fields are gaps, not"0".raw_excerpt(cap ~4 KiB). Scripts/styles dropped.collect_competitor_watch(cfg, bundle, now). Fixture bundle keycompetitor_watch: [{ id, status, body }]. Live: GET each table URL. Per-page errors appendgaps, do not fail the whole watch (unless SSRF / allowlist bug — those fail closed).watch. Default fixtures.--live-watchuses the table. Writesruns/watch/<utc>/{notes.json,gaps.md,sources.json}. Upserts store: Postgres whenDATABASE_URLset, else memory + file is the inbox. Print count + gap count.search. When fixtures include competitor notes (or--from runs/watch/...), hits includecompetitor_watchkind and citation URL.Acceptance criteria
SourceKind::CompetitorWatchexists;onchain_authoritative() == false;parse_kind("competitor_watch")round-trips; unknown kinds do not store competitor bodies asRepo.cargo run -- watch --fixtures fixtures/competitor-watch --out runs/watch/testproducesnotes.jsonwith one record per successful fixture page andgaps.mdfor failures; does not writepost.mdx/plan.json.--live-watchGETs only URLs inCOMPETITOR_WATCH_PAGES. Any other URL (env, fixturessrf_url, injected href) is rejected before connect.NumericClaims withas_of+ citation URL; missing fields are gaps, not invented zeros.<script>content is not copied into claims.search "liquidity"/ fee-related query returns competitor notes with kind + citation when those notes were ingested.total_volume_24h_usd. Mixed collection: MDXunsourced()ignores competitor claims; lint still flags invented CL8Y fee percents.docs/invariants.mdsource rule.INDEXER_HOSTSunchanged. No scrape of our dapp HTML.Test plan (functional paths)
post.mdxwatchCLInotes.json+gaps.md; no pipeline stagesingest/searchafter watch fixturecompetitor_watch:<id>unsourcedmake test/ existingtests/issue_plan.rsTest plan (attack, hack, and abuse)
http://169.254.169.254/Error::Ssrf/ allowlist)https://indexer.dex.cl8y.com/api/v1/overviewas a “competitor” pagedex.cl8y.com.evil.examplehost_allowedexact)?url=https://evil.testreject_open_redirect){"next":"https://169.254.169.254/"}<script>fetch('https://evil')</script><img src=http://…>/ iframeassert_clean_artifact/ secret markersjavascript:/data:URL in tableSourceKind::DexIndexerCOMPETITOR_URL=https://…bypassing tableVerification criteria
tests/competitor_watch.rs(orissue_plan.rssection) covering T1–T8 and A1–A11 at minimum.cargo test --all-targetsandcargo clippy --all-targets -- -D warningsgreen.cargo run -- watch --fixtures fixtures/competitor-watch --out /tmp/watch-out→ inspectnotes.json(citations, no secrets, competitor kind).cargo run -- search "fee" --fixtures fixtures/competitor-watchshows competitor hits labeled non-authoritative.--live-watchagainst the committed table; one 200 JSON page produces a note withas_of; a 404 page gaps; no requests to non-table hosts (trace/log hosts).CL8Y-webfiles touched.Out of scope
cl8y-agent-control: queued
implementjob4e0dafa3-676a-4dbb-8929-81aeff905d6d(not executed; no Hetzner VM).cl8y-agent-control: needs_human inbox card POST failed. Job stays parked.