Appearance
Market
Module id: market (frozen) · Mounted at: /api/modules/market · Empty settings row (the shell-module convention). Built 2026-08-22 from plans/market-module.md (the authority — five engines on one weekly sync). The page is pure data and visualization by design: the battle plan this module computes materializes ONLY as signal cards on the board.
Purpose
The workspace's competitive field, measured weekly and stored: who the rivals are (discovery from real keyword overlap), who wins which searches (ranked-keyword head-to-head + share of voice), what rivals are building (the site atlas — sitemap + ranked-URL evolution), where demand is going (12-mo volume trends carried inside the payloads), and what to do about it (the battle plan: priority = value × winnability, honestly diagnosed write / refresh / authority).
SSOT & seams
- Competitor NAMES live in the profile settings key
competitors(domain strings — the same list ai-visibility matches against and the writer excludes).market_competitorsholds only the machine layer (tier, locale, evidence, lifecycle);competitors.jsis the ONE write path (skill + routes + sync bootstrap) and syncs the profile key after every mutation. - AI-answer share is composed at read, never copied: ai-visibility's snapshots already roll up per-rival mentions; the tool/page point there. No cross-module join.
- Two fields, two questions (2026-08-23). Share of voice counts a rival's WHOLE presence in the country — a French agency really does own that attention in Belgium, and filtering it out would flatter us. The battle plan and the gap cards are bounded by the languages the workspace publishes (
isOffLanguage), because that is the work this site can actually do. Same rows, two reads. - Share of voice is VISIBILITY-weighted (
visibilityAt: #1 ≈ 0.28 of a search's clicks, #11 ≈ 0.015, decaying to ~0 by #100), not a top-10 count. The binary rule scored every domain in Luniq's Belgium at exactly 0.0% — a number that could never move however good the work got. It is also null, never 0%, when fewer than two domains rank in the field: a share of a set containing only yourself is not a measurement. The field size rides inmarket_snapshots.rollups.field_keywordsso the share is always read with its base. - Shared payload math lives in
core/services/keyword-field.js(extracted from lead-audit when market became its second consumer):pickComparableRivals(size-ratio cap onfull_domain_metrics, ≥2 intersections), brand-token exclusion,rankedKeywordRow,volumeTrendOf, andansweredByPath(the already-answered guard, moved from gsc — gsc re-exports it asalreadyAnsweredBy).
The weekly sync (sync.js, in-flight dedupe)
A MARKET IS A COUNTRY (2026-08-23, measured): the vendor language parameter does NOT separate languages — hln.be (a Dutch newspaper) queried in Belgium/FRENCH returns Dutch keywords; lesoir.be queried in Belgium/DUTCH returns French ones. Selling "Belgium · Dutch" and "Belgium · English" as two markets billed twice for one measurement. So a market is a country, the query language is fixed per country (marketLanguageFor, the country catalog's first official language) and never shown, and KEYWORD language is decided by us (keywordLanguage in keyword-field.js: function words + the ranking URL's slug/locale segment, abstaining when unsure). Stored sets from the old (country, language) model normalize and dedupe on read.
THE SET IS BUILT FROM SETTINGS: markets.js owns which COUNTRIES a workspace monitors — stored as the module's own markets settings key (config, not measurement; competitors are a table because they carry evidence and lifecycle, markets carry neither). reconcileMarkets(ws) is the ONE path from settings to markets: it reads serviceAreas through core's resolveBusinessMarkets, keeps only the declared ones, and calls syncDeclaredMarkets. Both the weekly sync AND the /overview route call it, so cron and page can never disagree, and "Where you sell" (moved from Content settings to Settings → Workspace → Localization on 2026-08-23 — it is workspace identity, not a writing preference) takes effect on the next page load rather than the next Monday. A market row carries source: declared rows follow the setting and are DROPPED when the country is dropped (the page shows what the workspace is set up for); manual rows — added by hand, the country being scaled into — are never swept. First run: the overview route calls requestFirstSync when any monitored market has no snapshot; that fires runMarketSync in the background (single-flight, one trigger per workspace per 6h on top of the vendor daily cap) and returns syncing: true, which the page renders as "Measuring <market>…" and polls every 20s. Adding a market by hand does the same. Cap 5 (~$0.50/wk each). getMarketFrame(ws, { market }) resolves the read's market from the monitored set (a 2056-nl key); only monitored markets are addressable. STANCE is derived, never stored: established (own ranked keywords ≥ ENTRY_THRESHOLD 5) vs entering — a market graduates on its own. The probe-based single-market pick it replaced is gone; resolveBusinessMarkets still returns each published language's biggest market as an UNDECLARED candidate, for the add-market surface.
Per workspace × monitored market (syncMarket, atlas cached across markets — a rival tracked in three markets has its sitemap read once): (1) first-run bootstrap seeds the profile key's domains as WATCH (the Luniq soak seeded three zero-overlap agencies into paid slots — a business rival is not necessarily a search rival); (2) discovery — one labsCompetitors call → suggested rows with evidence (cap 10, stale suggestions pruned), then promoteProvenProfileRivals: profile-sourced watch rows with measured intersections ≥ PROMOTE_MIN_OVERLAP (2) move into tracked while slots are free, strongest first — never demotes, never touches manual rows or suggestions; (3) keywords — own domain + tracked rivals × (labsDomainRankOverview + labsRankedKeywords limit 500), branded keywords excluded field-wide (incl. compound brands via compact match), prev_* shifted on write, replaced wholesale except pinned tracked scoreboard rows; rows of domains that left the tracked set are deleted (snapshots stay as history); (4) authority — ONE backlinksBulkRanks call (new wrapper, /v3/backlinks/bulk_ranks/live); (5) atlas — readSitemap per tracked+watch rival ∪ their ranked URLs → market_pages (first_seen/last_seen/removed_at is the evolution record; removal stamped only after a complete read — the sitemap reader's contract); (6) snapshots — per-domain weekly row: counts, visibility-weighted SoV over the union field (null with <2 ranking domains), authority, atlas totals, demand index. Cost ≈ $0.50/wk per market, attributed in dataforseo_usage.
Whose demand, whose rival (identity curation, 2026-08-23)
A country's field is the COUNTRY's — vendor-correct and commercially useless on its own. Luniq (B2B web agency, publishes nl/en, sells in BE) had "imprimerie ciney" in its Belgian field and two French agencies in its paid tracked slots. Three gates, each at the cheapest place it can be decided:
- Keywords —
identity.js+core/services/keyword-relevance.js.businessContext(profile)renders the workspace's own words (companyDescription, industry, products, idealClient, visibilityTargets, languages, serviceAreas;''below two facts, which no-ops every gate).splitByRelevancejudges the field's top 200 keywords by volume in 40-keyword Haiku batches and the verdict is stored ONCE on our own snapshot'srollups.off_topic, so every reader applies it for free:getKeywordTableannotatesoffTopicper row, filters unlessscope: 'all', and always reports the count;classifyGapsskips them (viaoffTopicOf(snapshots, ownDomain)), so no card proposes work nobody wants. Measurement is untouched — share of voice is computed over the whole field, because that is what a share IS. The judge is shared with lead-audit (which builds its context from a prospect's homepage instead —lead-audit/services/relevance.js) and is best-effort: any failure keeps the mechanical rows. - How rivals are FOUND at all —
discovery.js(2026-08-23, replaced the rival-of-rival chain). "Who ranks for the same keywords as you?" answers nothing when you rank for one keyword, and the chained fallback ("who competes with THEM?") filled Luniq's Belgium with French agencies three hops from anything it sells. So the question changed: search the demand we want and read page one.seedKeywordspicks up to 4 searches by ALTERNATING two sources — the profile'svisibilityTargets(2–6 words, never our own brand;focusAreasretired 2026-08-28) and our keywords ranked in THIS market (right country, right language, past the relevance verdict) — with keywords from other markets filling any gap. Neither source can go first alone, and both failure modes were seen live: rankings-only searched "sentinelone vs crowdstrike" for Luniq (demo articles from its test-bed days) and found Gartner and Reddit, while Matubu — a coffee roaster with excellent declared targets — spent its whole budget on other companies' brand names ("jura e8", "kaffabar") because four ranked keywords filled it first. Declared-only would miss the local phrasing a site has proven it can rank for.discoverByDemandruns each seed throughserpOverview(now returningorganicDomains, parsed from the response we already pay for), drops platforms (PLATFORM_HOSTS: Reddit/YouTube/G2/Clutch… — never anyone's competitor) and foreign ccTLDs, and counts who keeps appearing. A domain earns a TRACKED slot when it wins 2+ of our searches or holds the top 5 of one (qualifiesAsRival), is measurable in this market, and is withinsizeCapFor(ourCount)— the same ceiling the vendor-sourced rivals answer to. Everything else lands as a suggestion withserp_hits/serp_keywordsevidence. Runs ONLY when no qualified rival holds a tracked slot: ≈4 SERP reads (~$0.03) per market per week, and nothing at all once the field is healthy. Live across four configured workspaces, three industries, four countries: Luniq's Belgium ended on digidreams.be + webit.be (Belgian B2B web agencies) and its UK on axongarside.com; Matubu (coffee roaster, BE) on javry.com + javacoffee.be + mokabon.be; HRTH (recruitment, BE) on hays.be + recru.be; Neue World (agency, US+GB) on coalitiontechnologies.com/redantler.com/graphite.io and how.studio/madebyshape.co.uk/makebuild.studio. Every suggestion list came back as same-industry companies. - What the candidate IS —
core/services/business-identity.js(2026-08-23). Ranking together is not competing. The first search sweep offered webflow.com, forbes.com, dribbble.com, optimizely.com and globy.com to a B2B web agency; all five rank for "website optimization" and none is a company a client would hire instead.readBusinessContext(domain)fetches the homepage and flattens it (title/H1/meta/body excerpt — the same reader lead-audit uses on prospects, moved to core),judgeRivalry(ourBusiness, theirs)classifies the relationship in one Haiku call:competitor|platform|publisher|directory|marketplace|other|unknown. A PLATFORM IS NOT A RIVAL — it is what our buyer might use instead of hiring anyone (Webflow, Optimizely, Siteimprove), and a card telling an agency to out-rank Webflow is noise. The verdict is about the COMPANY, so it is stored on the row (rival_kind,rival_what,rival_checked), read back across every market byknownVerdicts, and each domain is judged exactly once. It gates three things: only a verifiedcompetitoris auto-promoted to tracked, only companies enter the suggestion list, and a tracked domain verified as a non-rival is REMOVED from the set (never demoted to watch — watch still means "a rival we don't share searches with").unknown(unreachable homepage) is honest and inert: it stays, it is never promoted, and the language/relevance rule remains its backstop.manualrows are annotated, never touched. Live: optimizely.com (platform) and globy.com (B2B marketplace) left Luniq's Belgian field; digidreams.be and webit.be — actual Belgian B2B web agencies — took the slots; every profile-named rival came backcompetitorwith a one-line description. - Rivals, before they cost anything —
tldLocationCode(core/dataforseo). A candidate whose ccTLD names another country is not a local rival (hubspot.fr, digeek.fr as "Belgian competitors"); generic TLDs (.com/.io/.agency…) claim nothing and stay eligible. Applied to both discovery paths insync.jsvialocalToMarket. - Rivals, once measured — the demotion loop in
syncMarket. After the field is built: of everything a tracked rival ranks for here, how much is in our published languages AND not off-topic? Zero over a whole measured field → back towatch(evidencerelevant_keywords,field_keywords), and it stops seeding entry discovery. Never applied tomanualrows (a person's statement outranks the measurement) and never on an empty read (no data is not a verdict). Live on Luniq: netween.be and bluepixel.be demoted on the first run, share of voice 0% → 4.7%.
recordDiscovery is now called ONCE per market with both paths' candidates — the second call used to prune the first's suggestions.
Tiers
tracked (cap 5/market — the measured field) · watch (cap 15 — site atlas only) · suggested (discovery evidence, no monitoring). A human promotes, always; caps refuse with a reason.
Routes
Every GET takes ?market=<locationCode>-<langCode> (default: the workspace's primary).
| Method | Path | Purpose |
|---|---|---|
| GET | /:workspaceId/overview | Scorecard: standings + deltas, SoV trend, competitor set, the monitored markets with stance |
| GET | /:workspaceId/keywords | Head-to-head table (state/competitor/min_volume filters, enveloped) |
| GET | /:workspaceId/moves | Move log + atlas sections + per-domain provenance |
| GET/POST | /:workspaceId/markets | The monitored set / { action: add|remove|primary, market: '2056-nl' } (409 on cap) |
| POST | /:workspaceId/competitors | Curation: { action: track|watch|remove, domain } (409 on cap) |
Crons
- Weekly sync —
0 7 * * 1(Mondays 07:00, jittered — after backlinks at 06:00 so the authority read is fresh),schedulelane (it spends), gated at the scheduler chokepoint. - PAUSED since 2026-08-23 (Leon).
scheduler.jsregisters no job unlessMARKET_SYNC_ENABLED=true: the page is behind a "Coming soon" flag in production (frontend/src/lib/features.ts), and a fleet-wide weekly sync spends ~$0.50–1.50 per workspace per market on a surface nobody can open. Paused by DEFAULT, not by an env var somewhere, so no deploy resumes it by forgetting a setting. The DELIBERATE paths still work and are how testing continues: the operator Run-now trigger, and the/overviewroute's first read for a market that has never been measured (requestFirstSync, one per workspace per 6h).test-module-crons.mjsreportsmarket×0while it is paused. - Operator trigger
marketSync("Market sync (competitive field refresh)").
Agent tools & skills
- Tool
get_competitor_landscape— one stored-data read, six views:scorecard(standings + set + SoV trend),gaps(THE battle plan, scored + diagnosed),movement(won/overtaken crossings),keywords(the raw table),content(the move log with provenance — atlasComplete=false suppresses removal claims),atlas(section structure/velocity). Zero API cost; every list enveloped{rows, total, truncated}. - Skill
edit_competitors(chatOnly, incremental) — track/watch/remove; the one write path, profile key kept in step.
Entering a market
A market where we rank for nothing is the one that needs the picture most, and three fixes make it possible — each found by running it against Luniq (2026-08-23):
- Cold-start discovery.
labsCompetitorsasks "who is similar to US?", which finds nothing where we rank for nothing. The sync therefore asks the same question about a rival that DOES rank here (we-r.co in BE·NL → six real Belgian agencies) and records the answer assuggested— weak evidence never takes a slot. Two guards: a small agency's keyword set is mostly its OWN BRAND, so the chain returns name-alikes (groovedigital.agency → groove.cm, groovelit.com, groovetechnology.com), killed by a 5-character shared-root test; and seeds are only rivals with measured presence here, strongest first, max 2. - Three-way qualification for gaps (
rivalQualified): measured overlap with us, measured presence in this market (PRESENT_MIN_KEYWORDS20), or a person's explicit track. The overlap floor alone — right for the "v0 logo" case — silenced every entry market, where our overlap with anyone is zero by definition. - A gap is OUR absence, not their strength (
keywordState): requiring a top-10 rival read a rival at #96 as "contested" while we ranked nowhere and contested nothing. Their position feedsbattleScore's winnability instead, which is where it belongs — a market whose best rival sits deep is the cheapest one to enter. The card copy follows: a deep-rival gap leads with the demand, never with "ranks #96".
Cards: market:entry-beachhead (the cheapest winnable cluster, ONE card per market — entering a market is one decision) and market:entry-transfer (a page already ranking in a same-language market, against the same search here; exact keyword match only — cross-language equivalence would be a guess).
Signals (agent-signals.js — pure classifiers in classify.js, asserted in test-autopilot-safety.mjs)
Five detectors over stored rows, the keyword-side ones PER MONITORED MARKET with market-scoped fingerprints ( — the same keyword in two markets is two fights); the atlas detectors run once per workspace over domain-keyed fingerprints (a rival's site is one site), zero marginal cost: market_movement (won/overtaken crossings ≥50/mo with refresh_article how when we own the page; SoV shift ≥3pts; demand shift ≥15%), market_gaps (gap topics ≥100/mo through the banking gates — the rival must have measured overlap ≥ GAP_MIN_RIVAL_OVERLAP with us (their demand is not ours otherwise — the "v0 logo" lesson), brand filter, already-answered catalog guard, one-gap-per-rival-page dedupe with pre-collapse presence, primary-collection stamp, write_article how, evidence.recommends_content so the sweep's territory gate judges it; authority-limited gaps cluster into ONE evidence card, never a write), market_atlas (section launches ≥5 pages/28d, counter-moves on own brand tokens with a counter-page how, slot-opens against genuinely-removed rival pages, weekly new-content roundups), market_rivals (suggested rivals ≥30 intersections — human promotes). Fingerprint families all carry family plans (board/plans.js); market joined LOCALIZED_SOURCES (cards quote the market's own keywords). Crossing/slot cards measure on query_series (the proven outcome rails; a dedicated keyword_position measurer over market_keywords history is the planned upgrade per OUTCOME_LOOP_PLAN.md).
Data
Writes market_competitors, market_snapshots, market_keywords, market_pages (all RLS-on/no-policies, service-role only — migration market_module_v1); reads workspaces, the profile settings row, spine site_pages + blog_articles (catalog guard), collections (stamp). Rival URLs live ONLY in market_pages — never in the workspace's own site tables (the corpus split's URL uniqueness).
Key files
index.js (manifest), sync.js (the one data write path), competitors.js (the one curation write path), markets.js (the monitored set + reconcileMarkets), identity.js (the workspace's business, in the judge's words), discovery.js (finding rivals by searching our own demand + the identity check), classify.js (every threshold, pure), queries.js, routes.js, scheduler.js, agent-tools.js, agent-skills.js, agent-signals.js. Frontend: frontend/src/pages/orbit/market/ (Market/Scorecard/Keywords/Moves — zero action surfaces beyond curation).