Continuous competitive intelligence over a SQLite snapshot store. Radar reads what has been observed about a set of competitors over time and reports three different things that are easy to confuse: what actually changed, what has gone stale, and what nobody looked at.
You start with one URL. Everything below that section is the machinery a workspace grows into; day zero is the only part every install meets.
npm install && npm run init # creates ./radar.sqlite3, empty
RADAR_DB=$PWD/radar.sqlite3 npm start # http://localhost:8094 — paste one URLAlready have a store:
./start.sh # http://localhost:8094
npm run verify # 1240+ checks: API, SQL cross-checks, static, rendering
npm run check:design # the design contract's nine binding rules, as gates
npm run check:preservation -- --base=<ref> # a styling wave moved styling, not data
npm run sweep # re-observe Tier 1, report, write nothing
npm run sweep:commit # ...and persist the result as a new snapshot version
node sweep.js --commit --report # ...and archive a weekly + daily digestMIT licensed (LICENSE). The three webfont families under public/fonts/ are
not covered by it: each is libre under the SIL Open Font License 1.1 and carries
its own terms, which public/fonts/LICENSE.md names file by file.
Those two pictures are the only committed screenshots. Every walk and browser
check under tools/ shoots its own into tools/screenshots/, which is ignored:
running the walk is evidence about this checkout, and a picture of how it rendered
on somebody else's afternoon is not.
Comments throughout the source argue against three review documents that were inputs to the build rather than part of it, and they are not included here:
| Cited as | What it was |
|---|---|
pop.md |
A pre-release review, item-numbered A1…/B1…. A citation like "pop.md B7" names the specific defect the code below it answers. |
cuts.md |
The subtraction pass — which numbers and screens were removed, and the rule each removal followed. |
DESIGN-SPEC.md |
The design contract, seven rules in prose, §1…§7. tools/design-rules-check.js is that prose turned into static gates, and it ships. |
Nothing is load-bearing on them: they are cited as provenance for a decision, and
each citation sits next to the code that implements it. npm run check:design
enforces the design contract without the document, which is the point of having
written the rules as checks.
If you arrived here because you are the agent installing the Competitive Radar workflow: this repo is the app those install answers configure. As of workflow v8 it asks the operator four questions and only the first is required — anything left blank is derived from the entered page during the build rather than typed. Here is where each answer lands, and what to do with it.
| The workflow asks | Where the answer is stored | What you do with it |
|---|---|---|
| What's your product? (No product? Paste a key competitor instead.) | workspace_entry_records.entered_url — lib/facts/entry.js |
The one required input, and the only one the rest can be derived from. It goes in the day-zero field. Do not build a config screen for the other three. |
| What market are you in, and who do you compete with? | the market as a workspace_config row keyed market_frame, via POST /api/market/frame; the rivals as candidates ruled on into candidate_verdicts — lib/facts/candidates.js, and board_pin_records — lib/facts/day-zero.js for a correction to an already-confirmed pin |
Blank is a real answer: both halves are read off the entered page instead. A hand-typed market seeds its own signal words — the operator's words are the evidence for the operator's label — and the route requires an author, because a market nobody named is nobody's market. Named rivals still arrive as unnamed ghosts with Track / Not relevant and no confirmation step, and a dismissal is keyed on the peer's host, so a re-run does not resurrect it. |
| Where do briefings land, and who must see urgent findings? | the cadence and destination in watch_schedules — lib/facts/cadence.js; the person in call_seats — lib/facts/calls.js |
One question, two of this repo's asks. Registering the cadence arms the self-rearming timer in the server process — that is what makes the install a product rather than a screenshot. Seating one colleague is what makes person two land on a board that already has an opinion. |
| What should this board be called? | an append-only workspace_config row keyed app_name, via POST /api/workspace/app-name — lib/facts/workspace.js |
The display name is workspace configuration, not a constant: ~220 server-side sentences resolve through lib/facts/app-name.js, so a fork prints its own name rather than this one's. author is required, there is no unset (returning to the default means naming the board the default, which reads as a decision in the history), and a rename appends rather than overwrites. A workflow-built workspace can carry it in the bootstrap contract's app_name field and never ask — lib/facts/bootstrap.js. |
workflow/radar.workflow.json carries a fifth ask the four published questions
do not cover, and it is the one with teeth:
What decision is this for? —
board-narrative·entering-a-segment·defending-on-price·general-watch
It stores to workspace_entry_triggers.trigger_key, and that column is what
artifacts.selectedBy reads to decide which artifact the board earns.
general-watch earns no artifact, by design. So an install driven only by the
four questions on the workflow page leaves the trigger unset, the first-week
follow-up has nothing to offer, and the handoff pack never arrives. Ask it.
| Variable | Required | What happens without it |
|---|---|---|
RADAR_DB |
yes | No store. Must be a path that survives a restart — never under /tmp, /run or /dev/shm. The workflow's default is /root/databases/radar-snapshots.sqlite3. |
BOLTER_NOTIFY_URL |
no | Chat delivery is off. Mentions, routed asks and the nudge are still recorded and still rendered in-app, and the log says delivery was off. The workflow listing promises "briefings posted to a shared chat automatically" — that promise needs this set. |
RADAR_PUBLIC_URL |
no | Links fall back to this host's own port and the message says the link is local. There is deliberately no default host. |
PORT |
no | 8094. |
RADAR_SERVICE_KEY |
no | The agent loop is not provisioned. GET /api/duties, POST /api/duties/prediction, POST /api/duties/:id/delivered and POST /api/bootstrap 404 with one line rather than serving an empty list, and so do the three routes an agent-research run answers through (POST /api/agent/market-proposal, POST /api/agent/peer-proposals, GET /api/agent/context/:refId). Set it and those machine surfaces are authenticated and nothing else is: it gates no page, so feeding the agent does not lock the board. This is the one to set on a fresh install. |
RADAR_ACCESS_KEY |
no | A fresh Radar defaults open — no shared-secret gate, banner included, and anyone who can reach the host can read the board. Set it and every path except /api/access, /api/health and the icon paths is gated behind one shared secret, the machine surfaces included. It controls what may be read, not who read it. |
MARKET_MODEL_V1 |
no | Off. One recorded model call may name a market when the page cannot be derived from and no shipped frame fits. Needs RADAR_MODEL_URL and RADAR_MODEL_KEY as well; with any of the three missing Radar says which and derives instead. |
AGENT_RESEARCH_V1 |
no | Off. On, Radar may file the two day-zero research questions as app requests and let an agent go and look. Needs BOLTER_APP_REQUESTS_URL, BOLTER_SANDBOX_TOKEN and RADAR_APP_SLUG as well; with any of the four missing Radar names which one and derives instead. It defaults off for a reason MARKET_MODEL_V1 does not have: it spends somebody else's rate limit. |
BOLTER_APP_REQUESTS_URL |
no | No door to file a research request against. Everything else about the board is unchanged. |
BOLTER_SANDBOX_TOKEN |
no | The door is configured but nothing can authenticate to it. A stale one reads as a 401 and is reported as a credential to refresh, not as breakage. |
RADAR_APP_SLUG |
no | A request could not say which app it is for, so none is filed. |
RADAR_AGENT_DEADLINE_MS |
no | 180000. How long a filed request may stay unanswered before the run states a deadline and falls through to the next provider. |
RADAR_DEV |
no | Off. On, ?state= becomes a rendering override and the page says one is in force. |
RADAR_SCHEDULE_SCALE |
no | Divides the schedule interval so a weekly cadence is testable in a minute. The stored interval stays the real one. |
| The listing advertises | This repo's duty | Entry point |
|---|---|---|
| Competitive sweep + new-entrant scan, daily 08:00 | 0 6 * * * UTC, cadence 7 days |
node sweep.js, alongside scripts/scan-new-entrants.js |
| The first seven days, daily 08:15 | the day-four nudge, 0 9 * * * from day three |
node scripts/evaluate-nudge.js |
| Weekly report generation, Mon 08:00 | 0 7 * * 1 UTC |
GET /api/reports/weekly |
There is a fourth mechanism that is not cron at all: the self-rearming re-read,
which fires off watch_schedules.next_due_at inside the server process, re-arms
from the store on every firing so a restart resumes instead of resetting, and is
the one the cadence answer above actually arms. A host that would rather run cron
POSTs /api/schedule/trigger — same function, same run row, trigger recorded as
external, so the two paths cannot drift.
The times do not agree. 08:00 / 08:15 / 08:00 on the listing against 06:00 / 09:00 / 07:00 UTC in the duty descriptors. Decide which side the deployment owns before wiring any of them up.
Two of the first-seven-days rules are not in this repo. "At most one
unprompted message a day" and "two ignored probes buy silence" are agent-side
behaviour; do not go looking for a daily cap in lib/facts/nudge.js, because
there isn't one. What the app owns is the gate, in lib/facts/nudge.js: two
conditions that must both hold — enough observations to fill the chosen artifact,
and at least one call made by a human — inside a window of FLOOR_DAY = 3,
DEEP_DIVE_DAY = 5, CEILING_DAY = 7. Day three is a floor rather than a
trigger: nothing is offered before it, gates open or not. Day five substitutes the
deep dive when the board is still quiet. Day seven is a ceiling in the strong
sense — past it the nudge fires whether the gates are open or not, so an agent
that assumes silence means the gates are shut will be wrong on day eight. And
idx_nudge_once on nudge_firings(board_key, artifact_kind) makes it once per
board per artifact kind: once ever, not once a day. The agent reads those gates
and decides whether to speak.
npm install
npm run verify # 1240+ checks, seeds its own throwaway store, needs no setup
npm run check:design # the design contract as static gatesBoth green on a fresh clone with no arguments. Then the install itself: an entered URL, a ruled-on peer set, a trigger, a registered cadence, one seated colleague and a name — six things, all of them stored, none of them a config file.
This is the first screen and it was the one screen with no documentation. Radar has a mature API, a diff engine and a report pipeline, and none of that is what a new install shows you: what it shows you is a field.
npm install
npm run init # creates ./radar.sqlite3, empty
RADAR_DB=$PWD/radar.sqlite3 npm start # http://localhost:8094Open it and paste one URL. A competitor's page, or your own product's — either works, and the second is only a preference. That is the whole of the setup.
1. Radar reads that page and names the market off it.
This looks like Issue tracking, read off
linear.app. Correct me.
The label is a phrase the page actually uses about itself — its own title, its own headings, its meta description, the anchor text of the comparison pages it publishes. The line under it says which of four things named it, because they are four different claims:
| Provenance | What it means |
|---|---|
| read off this page's own title and headings | Derived. The words are theirs and you can go and check them. |
| the market this workspace carries | You have named this workspace's market before, and this page matches it. |
| matched a market frame Radar ships | The page did not say enough to derive from, but it uses a known market's vocabulary. Six frames ship. |
| named by a model from this page | One recorded model call. Off by default — see the flag below. |
| proposed by an agent asked to research it | A research request filed against Bolter and answered through the app's own validated write-back. Off by default, and it never overwrites a market read off your page. |
"Correct me" is one click and it is meant to be used. The derivation is allowed to be wrong out loud precisely because disagreeing is cheap: pick a different frame, or type your own name for the market. Either writes a dated row with your name on it, and the new-entrant scanner follows the frame rather than staying pointed at the old one.
Where a page names no segments of its own, the frame borrows a second axis from a shipped frame that matched the same page — and says which one, in its own sentence. The market is read off your page; the bands are not, and one sentence claiming both would overstate the first.
2. Peers arrive as ghosts, and you rule on them while you watch.
Discovery reads three sources, and every candidate says which one found it:
- the comparison pages this vendor publishes (
/compare,/alternatives,/vs/…— up to four of them). A vendor comparing itself to somebody is that vendor's own dated statement that they compete. - outbound links on the entered page, at shallow depth.
- links whose own anchor text carries this market's vocabulary, admitted deeper than the depth filter allows because the words that let them through are the ones this market uses about itself.
Nothing arrives named. A candidate is a host until its own page has been fetched and read, and it renders as an unnamed ghost so nobody mistakes it for a finding. Each one carries two buttons — Track and Not relevant — and no confirmation step. That is the design: a wrong candidate costs one flick because it arrives while you are watching, and a modal asking whether you meant it puts the cost back.
A dismissal is keyed on the host, not on the entry that produced it. So a re-run does not resurrect it, and the board says how many were held back rather than quietly coming back thinner.
3. The comparison table.
Who charges what, the entry-price spread, who publishes no price. It needs one observation, which is why it is the only document available on minute three — everything else Radar produces needs a second reading to diff against.
Every cell is either a reading with the page it came from and the day it was read, or a named gap saying which page was read and what it did not say. There are no blanks: a blank price could mean free, unread, or an extractor that failed, and those three lead to different meetings.
4. The close: pick a cadence, and Radar registers a real schedule.
One observation gives you the table. Two give you a change. This is the step that decides whether an install is a product or a screenshot, so it is a step rather than a README line:
Radar will re-read every source on this board weekly, and the digest waits for
you here. Next read: 2026-08-18T09:00:00.000Z.
The timer lives in the server process and re-arms off the stored due time, so a
restart resumes instead of resetting and a changed cadence takes effect within
the hour. It never fires on boot. Hosts that would rather run cron POST
/api/schedule/trigger instead — the same function, the same run row, with the
trigger recorded as external.
The destination is the configured chat hook if there is one, and in-app only if there is not. "In app only" is a real answer, stated plainly, not a degraded version of something else.
- name a market off a hostname. Classification runs over fetched text only; "cursor.com" is not a developer tool because of the word "cursor".
- put a company on the board that nobody linked to.
- give a ghost a name, a price or a date before its own page has been read.
- invent pole labels for an axis. A derived frame has none, and the board says so.
- let a model-named market reach a measured claim. Market naming is framing;
assertFraming()inlib/facts/verify-pass.jsthrows if it tries.
The kernel above is the whole first session. Everything else in Radar is a rung, and a rung opens on a fact about your store rather than on a timer or a click count.
| Surface | Opens at | What it does |
|---|---|---|
| Segments | 12 tracked, or 3 segment bands already recorded over 6+ | Groups the roster; gives the board its full form |
| Gap column | you declare your own product | Every capability row becomes a gap against it |
| Ranked weekly | more than 3 verified changes in 7 days | Ranks the digest instead of listing every change |
Locked surfaces are visible, not hidden: they render grayed in the sidebar under a thin divider, capped at three with the rest behind "more to unlock", so the nav shows where the app is going without undoing the eight-destination cut.
Clicking one opens a teaser — one sentence about your store ("Radar could group your 13 competitors into segments") and a Build now button. The button is two different things and says which:
- unlock tier — the machinery already ships. It answers the probe's question and switches the surface on, in place.
- beyond code — Radar cannot build it. It files a real work item through the
handoff's routing, with the market, the product and the store-computed sentence
on it, so an agent can pick it up.
capability-layerandwin-lossare both here, and each carries the reason the code is not there.
Radar has one kernel and three ways in. Which one a workspace came through is a row in its store, not a build flag, and the other two are unaffected by it.
The bare fork. npm run init, boot, and the entry screen with one field. This
is the day-zero section above and nothing in the two doors below changes it.
What this board is called. The display name is workspace configuration, not
a constant: a fresh or forked store answers to Competitor Intelligence, the
live instance carries Radar as a row like any other value, a bootstrap
carries whatever its setup card said, and the day-zero flow offers a name once
after the market is named. Renameable from Settings, append-only and attributed.
See docs/wave-7-notes.md, which also covers the design
token set, the five binding design rules and how each is gated.
The adopted instance. A store that was collecting observations before
workspace_config existed adopts the values the code used to hardcode on boot.
See "Deploying onto a store that already has a corpus" in
docs/deployable-workflow.md.
The workflow door. A Bolter build agent sets the workspace up before this app exists — one URL with a role, a corrected competitor set, a destination — and hands the store one document:
npm run init
npm run bootstrap -- ./contract.json # or POST /api/bootstrap, gated by the key
RADAR_DB=$PWD/radar.sqlite3 npm start # boots straight into the revealIt is a birth certificate, not a config API: it applies once, to a workspace
nobody has entered, and every correction after it goes through an affordance that
is attributed and dated. An unknown field is refused with the accepted set rather
than dropped. npm run bootstrap:check validates without writing.
What that buys, and none of it is a second implementation of anything:
- the entry screen does not render. The document writes a grounded entry row, which is what the board reads its ghosts from and what the verification pass takes its work list from, so the store is past that state on its own.
- the pass starts itself on the first boot, once per entry, with the read log as the test for "once".
- attribution comes from the reader, so every "Recorded as" field on the page becomes a stated attribution. Nothing asked in Bolter is asked again here.
- the cadence and destination are armed through the same
cadence.registerthe kernel's closing card calls, including its honesty about a chat destination on a host with no hook.
Probe-ladder citizens, and each is a condition over the store rather than a step in a wizard — so the first three appear on all three doors, whenever their condition holds:
| Ask | Fires when | Answering writes |
|---|---|---|
| Which of these can hurt you? | three peers have been READ and nobody has ranked them | tier 1 through the board's own pin ledger, capped at five |
| What are you most interested in? | this board was bootstrapped, its card carried no interests, and it is still inside its day zero |
the one interests row step 2 of the bare fork's flow writes: chosen areas unlocked, the unbuildable ones filed as work, the rest grayed |
| I read your page: … Is that current? | an own-product page has been read and a reading on it is still Inferred | a confirm record, flipping the reference row to Observed with the machine grade kept underneath |
| Drop the daily? | observed change frequency and the registered cadence disagree over 30 days, with the schedule keeping its promise | a re-armed schedule at the cadence the store computed |
Each renders once. A dismissal is an answer, and every one of them has a standing door elsewhere in the app, so nothing is lost by never asking again.
The interest ask is the one that names a door in its condition, and the rule behind
it is the FTUE-ingestion rule: a step of the day-zero flow renders on the
bootstrapped door only if the setup contract did not answer it. interests was the
only optional field on that contract, so it was the one step that could arrive
unanswered — and it arrives as this single card rather than as a flow, because the
other four questions were answered in Bolter before this app existed. A contract
that carried its interests is honoured and never asked. See
docs/wave-8-notes.md.
market is optional too, and its absence is the setup card's Derive from URL
answer: the key is simply left out, and this app names the market itself rather than
the installer deriving one off the same page before the app exists. market: {} is
still refused, because an empty object is a field somebody meant to fill in. The
workspace is born unframed — the board titles itself "The landscape" and renders no
market headline — and the frame arrives from the first pass, which runs the same
ladder POST /api/entry runs against the anchor page. If the ladder refuses, the
board asks, as a one-field market-name ask that is first in the additive order
and is the only ask with no decline: every other question on the board is downstream
of the market. A market a person typed says so on the headline rather than claiming
it was read off a page. See
docs/deployable-workflow.md.
Those interest answers are what the sidebar's DEEP DIVES group is locked and
unlocked by, one wave later. lib/facts/deep-dives.js reads
probes.ladder().surfaces and takes unlocked off the row — it is a view of the
rungs, not a second registry — so a board that chose Pricing and Positioning shows
Pricing and GTM open and Features and Business padlocked, each with its own
store-computed teaser and the same two-tier Build now. See
docs/wave-9-notes.md.
GET /api/duties exists when either key is set; with neither it 404s with a single
line, because an empty list would be indistinguishable from "nothing is due", which
is the ordinary state most days. RADAR_SERVICE_KEY is the one to set: it
authenticates this feed and every other machine surface, and it gates no page, so
provisioning the agent does not lock the board.
It serves the day-0/day-7 arc's touchpoints computed from store state, each with a
deterministic, evidence-bounded draft. The app owns the discipline, not the
agent: a duty with no new content never becomes due, at most one unprompted
message goes a day, and two delivered-and-unanswered probes buy silence until the
reader does something. An agent holding those would hold them per process and per
restart. POST /api/duties/:id/delivered is how delivery is reported, and it is
required: reading the feed delivers nothing.
The day-1 bet is a call with kind prediction, scored into the day-7 weekly
against stored facts. Three answers, each mapped onto a field path; a week where
none of the three moved scores unscored, because "wrong" there would be scoring
a reader against a market that did not play.
Two keys, and they do different jobs. A fresh Radar defaults open: set
RADAR_SERVICE_KEY, leave RADAR_ACCESS_KEY unset.
RADAR_SERVICE_KEY authenticates the machine surfaces and only those: the agent
loop's four (GET /api/duties, POST /api/duties/:id/delivered, POST /api/duties/prediction, POST /api/bootstrap) and the three an agent-research run
answers through (POST /api/agent/market-proposal, POST /api/agent/peer-proposals, GET /api/agent/context/:refId). It is how the agent
loop is provisioned. It gates no page and opens no session, so nobody needs a key to
read a board that has one. Before the split, the feed existed only when the access
key was set, so standing up the agent put a key wall in front of every reader, which
is exactly what a live sandbox did to every visitor who opened it.
RADAR_ACCESS_KEY unset is the behaviour Radar has always had, banner included:
anyone who can reach the host can read the board. Set it and every path is gated
behind one shared secret, compared in constant time, carried as an HttpOnly cookie
derived from the key, and the machine surfaces are covered by it too. /api/access
and /api/health answer before the gate: a gate that gated its own sign-in endpoint
is a locked door with the handle inside, and a health check that needs a secret
reports every install as down. Set it when somebody decides this board holds
something that needs locking, not at build time. To open an install that is already
gated, unset RADAR_ACCESS_KEY, set RADAR_SERVICE_KEY=<value> and restart.
A caller that is not a browser presents its key in an x-radar-key header instead
of keeping a cookie jar; either key is accepted there, each compared in constant
time. That is what makes the cadence docs' "a cron job needs the key" true, and it
is what the bootstrap and the duties feed are reached with. The icon paths answer
before the gate too, so the sign-in page can draw its own mark.
This is not accounts. There is one secret and therefore no identity behind it, so it controls what may be read and not who read it. Rotating the key invalidates every cookie.
Five values that used to be hardcoded. GET /api/workspace serves them with
their provenance:
| Key | Was | Is |
|---|---|---|
own_product_name |
"Bolter" in five files |
a workspace row; null renders "gap", never a guess |
own_unit_cost |
"$0.64/1K" in the Pricing method note |
a workspace row; unset drops the figure, never invents one |
market_frame |
one of six hardcoded verticals | derived off your page, or one of the six as seed config |
entrant_vocabulary |
a hardcoded AI-agent term list | follows the market frame |
own_product_url |
— | what the own-product probe read |
npm run init creates the tables for these and writes no values into any of
them. It never touches an existing store.
A store that was collecting observations before these rows existed carries none
of them, and every surface that reads own_product_name would render the
unnamed form the first time this code ran against it. That is what
lib/facts/workspace-adopt.js is for, and the server runs it on boot, so a
deploy is the migration:
npm run adopt:workspace:dry # what would be carried, and whether it would be
npm run adopt:workspace # carry it, and print what the workspace now says
It writes nothing on a store that already carries any value of its own, nothing
on a store with no observations (a fresh one derives its market from the page you
point it at), and nothing on a corpus whose own recorded words classify as some
other market. Each refusal prints its reason. Every row it writes says
source: seed — it reproduces what the code hardcoded; it did not read anything
off a page.
The stored column feature_bolterGap is deliberately not renamed. What a page
calls it comes from featureAttrLabel(); renaming a live store's column to
improve a label is a migration nobody needs.
Off by default. MARKET_MODEL_V1=1 plus RADAR_MODEL_URL and
RADAR_MODEL_KEY allows one recorded call to name a market when the page
cannot be derived from and no shipped frame fits. Every call writes the page text
it was given. With the flag off — or the endpoint or key missing — Radar says
which of the three is absent and falls back to derivation plus asking you.
Radar answers "what market is this" and "who else is in it" from whichever of
three providers is available, in this order: agent research on Bolter files
the two questions as app requests when AGENT_RESEARCH_V1 and its three
environment variables are set, and it outranks the model because it can go and
search where a model call can only re-read the page it was handed; the direct
model endpoint (MARKET_MODEL_V1 plus RADAR_MODEL_URL and RADAR_MODEL_KEY)
is the fallback for a bare fork with no Bolter workspace behind it; and
derivation from the entered page's own words, then asking you, is always there
and is what a copy with neither flag set uses for everything. Every rung above
derivation is framing: it proposes, a person confirms, proposed peers enter as
unnamed ghosts, and nothing any of them says becomes a measured fact until Radar
has read the real page itself. See docs/deployable-workflow.md for how the three
research variables are passed, and note the sandbox that has to be recreated
before that door works at all.
A run answers back through this app's own validated operations rather than through
the door it was filed against, and those three are machine surfaces: set
RADAR_SERVICE_KEY and they answer, on a board nobody has to hold a key to read.
Radar reads five fields per competitor: url, description, pricing, tier,
segment. The capability layer — what each competitor can do, what a gap against
your product is, which capability is a kill shot — has no writer anywhere in
this repo. Five surfaces read feature_* and nothing fills it, so a fork gets
them empty forever unless it fills them by hand.
seed-ground-truth.js and scripts/seed-curated-fields.js are the hand-filed
path, and they are the honest one. The worksheet is:
# 1. Look at what the fields are and which competitors have none.
RADAR_DB=$PWD/radar.sqlite3 node -e "
const S = require('./lib/sections');
console.log(S.features().denominator);"
# 2. Fill them the way an analyst would: one competitor at a time, from their
# own pages, with the URL and the date you read it.
# scripts/seed-curated-fields.js is the shape to copy - it writes
# feature_* rows through the same snapshot path a sweep uses, so every
# value carries source_url and observed_at and shows up in the diff.
# 3. Re-read it back through the real surface, which is what proves it landed.
RADAR_DB=$PWD/radar.sqlite3 npm run verifyBuilding an extraction pass — reading capabilities off a marketing page automatically — is explicitly out of scope, and the Build now on the Capability layer surface is how you ask for it as a work item rather than guessing at it. A curated field is worth having; a scraped one that is wrong about what a competitor can do is worse than an empty column.
| Variable | Does |
|---|---|
RADAR_DEV=1 |
Honours ?state= and ?board_at= as rendering overrides, and shows the banner that says one is in force. Off by default: the banner was rendering above the entry question in production. |
RADAR_SCHEDULE_SCALE=n |
Divides the schedule interval by n, so a weekly cadence is testable in a minute. The stored interval is always the real one and the payload says it is compressed. |
Three commands, then a 90-second walk. The store is a demo seed: every value
in it is invented, and it carries two markers (run_type = 'fixture_seed' and
'demo_seed') so nothing downstream can mistake it for an observation. The
competitor names are real companies with fabricated values, which is the
convention seed:fixture already established.
RADAR_DB=$PWD/demo.sqlite3 npm run init
RADAR_DB=$PWD/demo.sqlite3 npm run demo:seed
RADAR_DB=$PWD/demo.sqlite3 npm start # http://localhost:8094demo:seed runs the fixture seeder and then stages the one thing the fixture
cannot show: a pattern. It reads its own store back through the real surfaces
afterwards and fails loudly if any beat does not render, so a green seed is a
rehearsed demo rather than a hopeful one.
One pass down the page, in the order the page renders. That matters: the Overview is six blocks over about 4,000px at a laptop height (five, on a board with no candidates waiting), the board is the last of them, and a script that sends you back up mid-demo spends its 90 seconds scrolling. Every "scroll" below is downward.
Since Wave 9 the metrics row is the first thing on the page rather than the fourth, so the walk opens on three market counts and then reads the hero sentence. That is the amended rule: the first readable content is a market number or a market statement, never telemetry or product chrome.
| What you do | What it should say | |
|---|---|---|
| 0–15s | Nothing. The Overview is the first paint. | Three market counts across the top — competitors tracked, verified moves in the last 7 days, tier 1–2 with verified activity — and then the hero sentence in display type: "Claude raises its Team plan to $30", with one line under it: "Read off claude.ai/pricing on 10 August 2026, changed from $25/mo read on 20 July 2026, 2 evidence records." That line is served, not assembled by the page. |
| 15–30s | Scroll once, to What changed — it starts immediately under the brief. | Six rows. Three of them are one market movement: Dust.tt, Lindy and Viktor all withdrew paid-free evaluation in the same sweep. The consequence is stated once — the Lindy row says "Same consequence as the Dust.tt row above" rather than repeating it, which is the same rule the whole page runs on. |
| 30–40s | Keep scrolling to Standing state (the metrics were at the top; there is nothing between). | The pattern as one sentence: "Dust.tt, Lindy and Viktor left that set this period, taking it to 4 from 7." Nobody assembled that. It is a count over the tracked set, computed at read time, and it is on screen because a fact in this window moved it. This is the "how did it know that" beat. |
| 40–50s | Keep scrolling to The landscape, the last block. | Ten pins, one per competitor, with a state you can read without hovering. This store renders two of them: eight confirmed, two past cadence (dimmed, and the pin says how many days). Radar draws two further states on a corpus that has them — an unadjudicated position, and a peer nothing has been read on — so don't name those here; there are none to point at. |
| 50–70s | Click the Claude pin, then "See the two page reads →". | The evidence view: the 20 July read and the 10 August read side by side, four plan lines, one of them highlighted, and $25/mo → $30/mo marked inside it. Both columns carry the url, the day and the snapshot row id. Under them, Radar's own confidence on the same pair — not instead of the pages, under them. |
| 70–80s | Click Edit — record a correction. | The correction form, scoped to Claude, saying what it would override: "Radar observed watch tier '1' on 2026-07-20. That row stays." Append-only, dated, signed. Close it without saving; the point is that the affordance is there, not that you change anything. |
| 80–88s | Still on the open pin, click the Claude battlecard chip. | A printable page opens in a new tab: positioning, pricing with its cohort context, capabilities against our own gap rows, fundraising, dated moves. Every sentence carries the page it was read from and the day. Where the store cannot support a claim — including which capability a gap row belongs to — the card says so in a named gap instead of guessing. Print it. |
| 88–90s | Back on the Overview, already at the bottom. | One line of coverage honesty. Once, small, at the end. |
If a beat does not render, npm run demo:seed will have said so before you
started. node tools/demo-walk.js drives the whole walk in a real browser and
shoots every beat, so the table above is checked rather than remembered.
| Path | What it is |
|---|---|
lib/config.js |
Field taxonomy, tier cadences, confidence weights, deck reference figures |
lib/db.js |
SQLite adapter — prefers better-sqlite3, falls back to built-in node:sqlite |
lib/model.js |
Raw rows → keyed records; feature blocks zipped back into features |
lib/analytics.js |
Semantic diff, decay, coverage, provenance, sweep-run integrity |
lib/sections.js |
GTM / pricing taxonomies derived from prose, feature inventory |
lib/reports.js |
Weekly digest, daily change alert, significance ranking |
server.js |
HTTP API + static host (no framework; http module only) |
public/index.html + public/app.js |
Dashboard SPA — five destinations plus a deep-dives group (see below) |
sweep.js |
Fetch → extract → diff → persist → log |
verify.js |
End-to-end verification |
Store path defaults to /root/databases/radar-snapshots.sqlite3; override with
RADAR_DB. Port defaults to 8094; override with PORT.
Fifteen, before Wave 2. The rule that produced the first five: a nav item is a question a reader arrives with. Everything cut was either a second answer to one of these, or a question about Radar's own internals that had a top-level item anyway.
| Destination | The question | Absorbed |
|---|---|---|
| Overview | What happened, and what should I care about | — |
| Competitors | Who are they, and what is the full record | The motion and funding columns and their drawer sections, the new-entrant candidate queue, the market-events register |
| Team | Who else should be looking at this, and what has gone out | Was Handoff. The gate, the drafted document, its audit and the workflow manifest are all behind the item each belongs to |
| Reports | What happened this week, in prose | The call queue — every item in it is a reading that scored below the threshold these bodies gate on, so the worklist sits under the documents it gates |
| Monitoring | Is Radar working | Coverage, Sweep History, Decay Metrics — three sections, one tab, plus the freshness line that used to be a strip on every screen |
| Settings (gear) | How should Radar behave | Three reader-facing knobs on top; how-it-computes and when-it-runs behind a closed disclosure |
Then a group head, DEEP DIVES, holding six. A deep dive is one axis of the market read across the whole board, which is a different kind of thing from the five questions above — and it is the kind of thing that can be locked, because a board that never asked to have its funding column filled should not be handed an empty page.
| Deep dive | Reads | Unlocked by |
|---|---|---|
| Pricing | What everyone charges, and who moved | interest area pricing |
| Features | What each one can do, and where the gaps are | interest area features |
| GTM | How each one reaches its market | interest area positioning |
| Business | Who raised what, and how each one makes money | interest area funding |
| Capability layer | What each one can do, extracted per page | nothing — no version of this app has the extraction pass |
| Win / loss | Why deals are won and lost | nothing — it is a CRM import, not a page read |
The lock state is the probe ladder's, not a second registry:
lib/facts/deep-dives.js reads probes.ladder().surfaces and takes unlocked off
the row. A store that has never been asked which areas matter carries no
interests row, so the four page-backed entries are open — which is why the
adopted live instance's sidebar shows the group fully open. A board that answered
the day-zero flow's step 2 gets exactly what it asked for and a padlock on the
rest, with the store-computed teaser on the padlock and the two-tier Build now
behind it.
The group head is a label. Clicking it does nothing, because a group that opened a panel would be a seventh deep dive with nothing in it.
Two things are reachable without a nav item, on purpose. The Change Feed is the
evidence layer: every insight and change row links into it, ?view=changes and
#changes resolve, and Monitoring keeps a door. It is the diff engine's own
console — a version-pair picker, four stat tiles and stacked disclosure notices —
which is a thing you go to from a claim, not a thing you browse. And the
day-zero entry screen is a state of the Overview rather than a route.
Nav badges: two. Reports counts reports, Competitors counts verdicts waiting on a
person. There were eleven, and each meant a different unit — x/y ratios of
different things, raw counts of different things, an inbox count, and "how many
settings are off default". A number whose unit you have to relearn on every click
is worse than no number.
| Endpoint | Returns |
|---|---|
GET /api/health |
Driver, store path, as-of date |
GET /api/stats |
Dashboard summary |
GET /api/overview/daily |
The whole daily briefing, curated (?changes=&events=&entrants=) |
GET /api/competitors |
All competitors + latest values (?tier=, ?state=) |
GET /api/competitors/:name |
Full profile: fields, provenance, confidence scores, value history |
GET /api/competitors/:name/battlecard |
A printable page, server-rendered: positioning, pricing with its entry-price cohort, features against our own gap rows, fundraising, dated moves, and the coverage line for that one competitor. Every sentence carries the row and the day it was read; absent data is a named gap. ?format=json serves the same document as data |
GET /api/changes |
Semantic diff (?from=&to=&type=&competitor=&extraction=clean&limit=) |
GET /api/evidence |
The two page reads behind one claim (?competitor= required, ?field= optional), with the line-level diff already computed, Radar's own reading of the pair, every call and correction on the record, and whether the day-zero pass can re-read the page. Feature attributes are excluded and the document says why |
GET /api/decay |
Fact decay, coverage decay, half-life, per-field turnover |
GET /api/coverage |
Per-competitor, per-field freshness (?state=) |
GET /api/sweeps |
Run history with integrity verdicts |
GET /api/snapshots |
Version summary, comparable pairs, recommended pair |
GET /api/gtm |
Competitors grouped by derived distribution model, segment and tier |
GET /api/pricing |
Competitors grouped by derived pricing model (multi-label) |
GET /api/features |
Feature inventory by category (?recommendation=&category=&killShot=) |
GET /api/reports/weekly |
Weekly digest — latest archived, or built on read |
GET /api/reports/daily |
Daily change alert, confidence-tagged |
GET /api/reports/archive |
Archived reports (?limit=) |
GET /api/reports/:id |
One archived report, verbatim |
POST /api/reports/generate |
Build and archive ({"type":"weekly"|"daily"|"both"}) |
GET /api/market/events |
Market events (?type=&impact=&limit=) |
POST /api/market/events |
File a market event by hand |
GET /api/new-entrants |
Discovery candidates — untracked products (?status=new|reviewed|added|dismissed|all&limit=) |
POST /api/new-entrants/:id/adjudicate |
Rule on a candidate ({"status":"added"|"dismissed"}) |
GET /api/calls/queue |
Judgments waiting on a person (?as=&owner=) |
POST /api/calls |
Record one call ({subjectType, subjectKey, verdict, by, note?}) |
GET /api/handoff |
The nudge gate and the drafted work behind it (?full=1). Still /api/handoff — Wave 9 renamed the destination to Team and left the API alone, because the rename is presentation and this route is one the preservation check compares byte for byte |
GET /api/handoff/artifact |
One draft in full, with the audit that walks its claims back to rows |
GET /api/team |
The Team page's three zones: what is worth sharing, who has been asked in, what has gone out. One line and one action per item; the documents are behind the links |
GET /api/deep-dives |
The sidebar's deep-dives group (a view of the ladder's rows) and the two verdict lines GTM and Business open on |
GET /api/workflow |
The deployable-workflow manifest |
These answer 410 Gone with a one-line note saying where their answer went. They are 410 and not 404 on purpose: a 404 tells a client it got the URL wrong, which is a bug hunt, and a 410 tells it the URL was right and is now retired, which is a one-line fix.
| Retired | Where the answer is now |
|---|---|
GET/POST/DELETE /api/mentions* (all 6) |
Team judgement is a call — POST /api/calls — which changes what the report says. A mention changed nothing downstream |
GET /api/business |
GET /api/pricing, as a column on each competitor row |
GET /api/confidence |
GET /api/stats, which already returned the same confidenceModel() call |
GET /api/changes/overrides |
GET /api/changes carries each row's calls and edits. The writes are untouched: POST /api/changes/{suppress,edit} still record, DELETE /api/changes/overrides/:id still undoes |
GET /api/market/sources |
GET /api/market/events. Nothing ever polled the register — every last_checked was null |
GET /api/entry |
GET /api/overview/brief, as board.entry. POST /api/entry still writes |
These are the places where the data is ambiguous and Radar takes a position rather than quietly picking a number.
"No change" and "nobody looked" are different findings. Before any value is compared, competitors are partitioned into compared, not sampled and newly covered. The same rule applies one level down: if a field name appears nowhere in the later version, that run did not sample the field — a targeted sweep that re-reads three fields did not delete the other seventeen. Absence across a whole version is evidence about the run, not about the competitor.
Ordering is by snapshot_version, not created_at. These disagree in the
store: v1 was written at 15:08, after v2 (14:56) and v3 (15:05). Two sweeps
claimed version numbers out of order. Radar orders by the explicit column and
surfaces the disagreement on Sweep History instead of guessing.
The Overview is a briefing, not a diagnostic — and Wave 2 enforced it. This paragraph used to end by listing what the Overview carried instead: deep-dive triggers, new entrants awaiting a verdict, four numbers and dismissable warnings. That list was itself the problem. The doctrine was written, applied once, and then violated block by block, because the narrative engine landed and nothing was removed to make room for it — so on any day the engine had nothing to promote, which at this corpus size is most days, the page was the entire pre-narrative diagnostic stack plus a paragraph apologising for the silence.
What the enforcement actually removed, so the next person can tell whether it has drifted back:
- Fifteen Overview blocks to five, and Wave 9 settled their order: one metrics
row of market numbers only (first, three tiles, total tracked leading), the
Brief lead, What changed (provisional items inline as flagged rows), the
new-entrant candidates when there are any, standing insights, the board.
Wave 9 also removed the on-page title and subtitle from this page and from every
other one: the side menu already names the selection, so a page whose first
element is its own name spends the top of the viewport saying which link you just
clicked. The subtitles' words moved to the foot of each page, because several are
caveats rather than decoration; the document
<title>now names the destination and the board, which is more than the single static string it replaced. - The day-zero fill order left the built board in Wave 9. On a board that is built the landscape is built, and a numbered list of the order it filled in is a description of finished work printed above the thing it produced. It survives as the guided flow's floating rail. The probe ask and the invite that were rendered inside that block are not about fill order and both stayed.
- The board below the brief and visually quieter, captioned with the segment-crowding standing statement so a coverage grid reads as the evidence under a market claim. It used to be the page's primary object by explicit design comment while the brief was styled down so it would not compete with it.
- The metrics row is market numbers only. Coverage % and attention anomalies are
the instrument's vital signs; they are classified
subject: 'radar'on the payload and they live on Monitoring. - The coverage confession once, as one line at the bottom. It printed up to five times on one screen — a footnote under each of four tiles, then an amber card. Five printings of one disclaimer does not make a reader five times as careful; it teaches them the page apologises and can be skimmed.
- The global freshness strip, gone from every screen. "LAST SWEEP 36h ago / NEXT DUE Aug 16 / DATA FRESHNESS 16 fresh / 0 stale" was the literal first readable text on all fifteen tabs, and entirely about Radar's own scraping cadence. It came back twice and was removed twice: as three header readings above the page title ("as of 2026-08-10", "v3 · 1,034 values", "updated 09:19:52") and as three more in the page title's own deck. Both are asserted absent. The Live control stays, because it starts and stops the poll — a control is not a diagnostic.
- One claim, one rendering. The lead's headline, sentence and why-it-matters
paragraph were printed by the Brief lead and again by What Changed's first row;
the two supporting headlines were rows 2 and 3 restated above themselves. The
surface flags the row the lead is about (
feed.items[].led) and the client prints each claim once, with the lead carrying the before/after pair of the move it is about. Report bodies cite their evidence — fact id, competitor, field, observed day, source link — rather than reprinting the values the prose states. - Five blocks means five headings. Three sections used to ride along without the attribute the count was asserted on: the call queue, the drafted-work nudge and "Latest market events". A reader counts headings, so the check does too.
- A quiet day renders a short page. When nothing crossed the threshold, What Changed and the metrics row do not render at all: an empty feed card and a row of zeroed tiles each footnoted "no comparison" are the apology stack, not information. The lead is the strongest standing statement, with the quiet stated in the deck line under it.
The doctrine is enforced by verify.js, not by this paragraph: the Overview's five
blocks are asserted in order, the ten cut blocks are asserted absent, and the
lead's visual weight against the board's is asserted as a class relationship. A
re-added block fails the suite.
Significance is an editorial ranking, not a measurement. dailyBriefing()
in lib/analytics.js weights which field moved (a price or a funding line
outranks a reworded tagline), how much of it moved, and whether the reading was
directly observed — then demotes anything that looks like scraped page
furniture or a scraper re-point. Curation is server-side so the dashboard, a
report and any other consumer read the same ranking. No single field may hold
more than two hero slots: when a sweep first populates fundraising for
everybody, a straight top-N is five identical cards and the reader learns one
thing instead of five.
"What moved" is dated, not just ranked. The change feed widens to the
cumulative baseline→latest window when the newest two versions are
value-identical, and it should — it is answering "what differs from where we
started". The hero is answering "what happened lately", so it keeps that pair's
contents and narrows the dates to the newer of two cutoffs: the start of the
last sweep window, and seven days before the store clock. Recency also nudges
the ranking (+15 inside a week, −20 past a month), so an old high-weight field
cannot outrank a fresh one purely on its weight. When nothing clears the
cutoff, the panel says no new movement this sweep and names how many older
changes the window still holds — the failure mode being fixed was a hero
presenting a 2024 funding line, first captured in the baseline, as today's
news. totalChanges and the movement breakdown still report the full window;
the hero, the deep-dive cards and the warnings report the recent slice.
A warning on the briefing is always a claim about the market. Snapshot write ordering and sweep self-report integrity are real problems and they are on Sweep History. Putting them in front of someone catching up on competitors is how a warning pill becomes wallpaper. Dismissals are keyed to a hash of the warning's own text, so one stays gone across reloads but returns the moment the underlying claim changes.
The change feed does not default to the latest two versions. v2→v3 is value-identical — that run persisted a copy of the baseline. Defaulting there would show an empty feed and hide the only real change data in the store, so Radar picks the most recent pair with actual movement, prefers one written by a run the store can vouch for, and says plainly why it moved off the default.
A change can be corrected or hidden, but not deleted. Changes are derived on every request by diffing two snapshot versions, so there is no row to delete. Delete records a suppression against the entry's coordinates and the feed filters on it after the diff has run: the changed/added/removed totals still count what actually differs, and a banner says how many entries are hidden with a control to show them. Edit is the other half — the captured reading really was wrong, so it rewrites the value in the later version and the next diff is computed from the correction. Both confirmations carry their own Undo, and undoing an edit puts the captured value back rather than leaving a correction in the store with no record of it.
Self-reported change counts are not trusted. sweep_runs.changes_found is
whatever a run believed at the time. Radar recomputes from the rows each run
actually persisted and shows both. In this store they disagree badly: two runs
logged hundreds of changes while writing a verbatim copy of the baseline, and
two more logged changes while writing no rows at all.
Raw and quality-filtered rates are both reported. A nav bar captured into
pricing is a diff, not a change. An extraction heuristic flags the obvious
tells — values opening mid-sentence, unbalanced quotes, boilerplate, pricing
with no price. Flagged entries are labelled in the feed, never silently dropped,
because the gap between the raw rate and the filtered rate is the finding.
Half-lives are refused on short windows. t½ = d·ln(0.5)/ln(1−p). Every
sweep version in this store carries the same observed_at, so those windows are
degenerate; Radar declines to quote a half-life under a 7-day window rather than
multiplying the noise. The formula does reproduce the analyst's hand-derived
figures on the real 21-day window, which is what makes them computable rather
than quoted.
Coverage gaps and provenance gaps are counted separately. A stale or unsampled competitor is a cadence problem; a bare-domain citation is an extraction-quality problem. Rolling 633 of the latter into one "647 gaps" headline would bury the 14 competitors nobody re-sampled.
Freshness is judged per tier. Tier 1 weekly, Tier 2 biweekly, watchlist monthly — against each competitor's own cadence, never one global threshold. And "now" is the newest observation in the store, not wall clock, because judging a fixed corpus against today reports everything as stale for reasons that have nothing to do with the sweep.
Ragged feature blocks are left ragged. Features are a denormalised repeating
group zipped back together by ordinal position. Some competitors have 6 features
but only 4 feature_whyItMatters rows. Trailing features get no value for that
attribute; Radar emits nothing and reports the raggedness rather than inventing
an alignment.
GTM and pricing groups are derived, not stored. distribution and pricing
are free prose — 33 competitors produce 33 distinct strings, so grouping on the
raw value tells you nothing. The taxonomies in lib/config.js are applied at
read time, classification is multi-label ("Free + $20/mo Pro + API usage" is
three models at once), and the matched substring travels with every label so a
reader can audit any grouping back to the text it came from. Negations are
stripped first: a competitor stating "No per-seat" is not filed as per-seat.
A value that fails the extraction heuristic is not classified. Eleven of the
33 pricing values in the store are scraped page furniture — JSON-LD
fragments, nav bars. Reading a pricing model out of "price":"0","priceCurrency"
would be inventing a finding, so those competitors are listed as unclassified
with the captured text shown. The number Radar cannot price sits on the same
page as the ones it can.
Section pages fall back to last-known values; the coverage tab does not. Fourteen of the 33 competitors are absent from the latest snapshot. On the coverage tab that is the finding. On a GTM or pricing table it would blank two thirds of the rows and read as "no GTM motion" rather than "nobody looked", so those pages show the newest value each competitor has and mark the row with the version it came from.
"Viral" is a definition, not an adjective. It means a growth motion that adds users without adding sales headcount: platform-bundled, ecosystem-surface, or open source. Direct SaaS and enterprise sales are not viral under that rule, and the rule is printed on the page next to the count.
Kill shots are counted twice because the store disagrees with itself.
feature_isKillShot = True and feature_recommendation = "KILL SHOT" do not
cover the same features. Both counts are reported and the disagreements are
flagged rather than reconciled by quietly preferring one.
Feature scores are a composite Radar computes. feature_score is stored as
{"userValue","strategicFit","buildCost"} — there is no scalar to sort on. Net
score = userValue + strategicFit − buildCost, and the three parts are always
shown beside it.
Reports carry their caveat inside the stored body. A digest is exactly where
a hedged number turns into a fact, so REPORT_CAVEAT is written into the report
JSON, not added by the UI — it survives being read back out of the archive
months later. Top-5 ranking is a stated formula (field importance × tier weight
× edit magnitude, halved for failing extraction) whose inputs travel with each
row, and it is spread across competitors: straight ranking handed all five slots
to one competitor's feature block.
The daily alert counts value changes only. Of the 466 diff entries on the current pair, 410 are fields the later run did not re-capture for a competitor it did otherwise sample. That is a coverage event, not a competitor changing its mind, and headlining "441 changes" off the back of it would be the exact over-count the decay panel exists to argue against.
Auto-refresh cannot make the store fresher. The header's "updated 12:04:31" is about this browser tab; "last sweep 1.2d ago" is about the data. They are separate lines because a dashboard that showed only the first would look live while reporting a month-old store. Field freshness is judged against the store's own newest observation; sweep overdue-ness is the one number measured against wall clock, and each says which clock it used.
Market Watcher is quarantined from the maths. It tracks industry events —
model launches, funding, benchmarks — in its own tables, entered by hand. No
decay, coverage or change-rate figure reads from them, so a typed-in event can
never inflate a computed number. Sources are configured feeds, not a running
pipeline: lastChecked stays null and the API says so.
Reports and market events get a writable handle; the corpus does not. The API's connection to the snapshot store stays read-only on purpose — it should not be able to rewrite an observation it is reporting on. The operational tables are written by the app itself, so they get their own handle rather than loosening the one guarding the store.
There used to be an @ button on every card, a Mentions tab reading the same
rows two ways, six endpoints, a Bolter delivery path, and an 0.08 term called
mention_support in the ranking formula. All of it is gone.
The numbers are the argument. One live row against a sequence of 34 — thirty-three
mentions were created and deleted during development. derive.js said in its own
comment that mention_support "returns 0 for nearly everything today", which
means 8% of every ranking on every surface was a coefficient on a number that was
almost always zero, and the one time it was not, the signal was a colleague being
tagged on a card. GET /api/mentions/directory served a file mapping real
colleagues to their Bolter user ids over no authentication at all.
What replaced it is the call. A call is a stored, dated, attributed judgment
on a specific row — this move matters, this peer is not a competitor — and it
changes what the report says. That is the difference: a mention was a pointer plus
a note that moved nothing, and a call outranks the machine's own reading without
erasing it. GET /api/calls/queue is the queue, it rides the board on the
Overview, and lib/notify.js still reaches a person in Bolter — but only for the
two messages that carry a decision somebody has to make: a routed ask, and the
handoff nudge.
The mentions table is not dropped. A fresh store no longer creates one and
nothing reads or writes it, but a store that already has rows keeps them: this is
an expand-only schema, and destroying the only copy of somebody's notes to tidy up
a retired feature is not a trade worth making.
sweep.js re-observes Tier 1 (ChatGPT, Claude, Gemini, Copilot, Lindy) from
their own pages. Two rules govern what it will write:
- A failed fetch is not "no change." If a page cannot be read, that competitor is left out of the new version entirely and shows up as a coverage gap. Copying the previous value forward would manufacture exactly the false signal that makes two existing runs in this store untrustworthy.
- A value that fails the extraction heuristic is not written. It is reported as rejected-at-write instead.
Re-observations are persisted alongside changes — "we looked again and it still
says X" resets the confidence-decay clock and keeps the next diff honest.
changes_found is set to the count of values that genuinely differ, so
re-diffing the persisted rows reproduces the run's self-report exactly.
Because rule 1 means the sweep writes partial snapshots by design, and the new
version becomes "latest", it will not write unless you pass --commit. Point it
at a scratch copy with --db ./scratch.sqlite3 to try the cycle safely.
npm run verify boots the real server on an ephemeral port and runs 1240+ checks:
every endpoint, filters, POST validation, 404/405/traversal handling, static
assets — and it cross-checks the API's headline numbers against direct SQL
against the same store, so hardcoding "33 competitors" would fail. It also
renders every dashboard panel in-process behind a DOM stub, which catches
panel errors that would otherwise only appear on click.
That command needs nothing but npm install. Every tools/ check that drives a
real browser needs a driver this repo does not depend on, because a headless
browser is a few hundred megabytes and the suite above does not want one:
npm i --no-save puppeteer # the walks and most checks
npm i --no-save playwright && npx playwright install chromium # the day-zero checksEleven tools use puppeteer (bootstrap-walk, kernel-walk, demo-walk,
handoff-walk, overview-browser-check, reports-browser-check,
states-parity-check, changes-browser-check, wave9-shots, wave11-shots,
wave12-shots) and four use
playwright (dayzero-check, dayzero-walk, dayzero-panels-check,
reports-unread-check). Without them those files fail on Cannot find module,
which is a missing driver and not a finding about the app.
Each browser tool also wants a particular shape of store, and pointing one at the wrong shape produces a page of failures that say nothing about the app. Two known ones, so you do not spend the afternoon on them:
tools/dayzero-check.jsneedsRADAR_DEV=1on the server, because it drives the five board states through?state=, which is ignored without it.- that same file does not currently pass on either store shipped here: against the
production-shaped fixture it reports ~41 failures (the states never engage), and
against an empty
npm run initstore the state checks pass and it then dies on a null deref looking for a pin in an empty grid. It wants a store between the two and the repo does not carry a seeder for that shape. Treat a red run here as unfinished scaffolding rather than a regression you introduced.
node tools/changes-browser-check.js covers the change feed's Edit and Delete
the same way: it corrects a value and takes the correction back, suppresses an
entry and restores it from the toast, and re-renders the panel between clicks
so a listener bound once per render would show up as one click firing several
requests. It leaves the store exactly as it found it and fails if an override
row is left behind.
node tools/reports-browser-check.js http://localhost:8094 drives the Reports
tab and asserts the Wave 5 report template in a real browser: that the weekly
prints its six slots and the daily its five, in order; that the verdict is the
first readable text and comes from a pattern, the dominant move or a standing
statement rather than a count; that slot 3 stops at three cards; that the trend
line is drawn or its absence stated; that every colour resolves in both themes;
and that 390px does not scroll sideways. Run it against any store; pass
--shots to write the wave5-* pictures, which are shot against the scenario
store below because a quiet week is a legitimate report and a useless screenshot
of a template. Shots are written, never committed.
The store that shows every part of the template is not the main fixture —
node scripts/seed-wave5-scenario.js builds one with eight periods of history, a
field read three times inside one week, six verified moves across four move
sources, a curated field arriving for the first time, and two spellings of one
caller's name. verify.js builds it in a throwaway store for the Wave 5 gate;
point RADAR_DB at a scratch file to browse it:
RADAR_DB=$PWD/.wave5.sqlite3 npm run init
RADAR_DB=$PWD/.wave5.sqlite3 node scripts/seed-wave5-scenario.js
RADAR_DB=$PWD/.wave5.sqlite3 PORT=8195 npm start
node tools/reports-browser-check.js http://localhost:8195 --shots--curated-gap builds the same corpus with one difference: the curated funding
line is filed, then absent for the versions nobody re-typed it, then re-filed with
a new round. That absence is what makes the later diff report added, and it is the
production shape of a second curation pass — the one that used to be reported as a
baseline arriving rather than as a $9M-to-$45M change. verify.js builds it
separately, because the default store's counts are what five acceptance items assert
against.
An archived report is also a document on its own. GET /api/reports/run/<id>.html
serves the stored body of a run verbatim, with its own stylesheet and its own print
rules, so it reads off a disk with no Radar behind it and prints on paper rather
than as a black page. The report card in the app links to it. A run written before
Wave 5 carries no template and answers 409 with a pointer to the run itself.
node tools/overview-browser-check.js http://localhost:8094 --quiet-base=http://localhost:8095
does the same for the Overview, against two stores: it reads the five-block
structure off the rendered DOM, asserts none of the ten cut blocks is present,
asserts the lead's computed shadow against the board's (the weight inversion is a
claim about a stylesheet, so it is checked against one), and screenshots both
states in both themes under tools/screenshots/wave2-*.
Two servers, because the two states are two stores. npm run seed:fixture -- --quiet
builds the second one: full standing state and no movement, which is what a
quiet day actually is — you had moves last month, nothing this week. It writes the
curated baseline and stops before the three sweeps, so there is no adjacent version
pair, so no diff, so no change facts, while every standing statement still computes.
A quiet day is not an empty store, and it is not a query parameter.
npm run walk:kernel is the fresh-machine fork walk, and it is the one check here
that builds its own world. Everything else drives a store that already holds
something; this cannot, because the claim under test is that the kernel runs end
to end on a machine that has only ever run npm run init. So it makes that
machine: a store from a real init subprocess (so "init works" is half the
assertion), a six-host fixture market on IPv6 loopback — *.localhost resolves to
::1, so it needs no /etc/hosts edit and no root — and a Radar with no access
key, no model endpoint and no chat hook, because a fresh machine has none of them.
Then it does what a person does. Opens the page, pastes one URL, and answers five
questions while a floating rail ticks them off. Nine beats, each shot to
tools/screenshots/wave8-*:
| Beat | What has to happen |
|---|---|
| 1 | init makes a complete empty store; it boots with no configuration; one field and no grid |
| 2 | step 1 — the market is named off the page, the recognition sentence reads the page's own description whole (apostrophe and all), each proposal carries its own one-liner in the linking page's words, 8+ peers are proposed, and the site's own properties never appear among them — including the same brand on a second registrable domain, which is the trap only this wave's rule refuses |
| 3 | step 1 — four peers are picked, the close watch is recorded, and the Wave 6 watch-tier card does not ask the same question again |
| 4 | step 2 — interest areas seed the ladder: chosen ones unlock, unchosen ones stay grayed with their own teaser, and an area whose machinery does not exist says so before it is picked |
| 5 | step 3 — a cadence and a destination are registered by pressing the button a person presses, the flow advances itself with no reload, the card states its timezone, and a timer is armed |
| 6 | step 4 — two invites are recorded, served on the duties feed as invite-requested, and the bare fork says out loud that nothing was emailed |
| 7 | step 5 — five name suggestions, none of them the name already in force, the board is named, and only then is the build offered — promising the number of pages it is about to read |
| 8 | the build: pins land in the order the pages answer; a peer ruled on while its own page is still being read never reaches the board, the table or the coverage gaps; already-picked peers are not asked about again; and the comparison table and the summary appear when the pass completes — never during the flow, with a hero that is not a coverage confession even for a reader who did not force a rebuild |
| 9 | the overlay ticks 1-5 in order and docks to a top strip at 390px, with no horizontal scroll |
| K | the kernel's own claims, which outlive any flow: a dismissal a re-run does not resurrect, a timer that fires on its own and finds a real price change, a digest on the page with the trigger that produced it, and a ladder that says what it cannot build |
Beat K is the one worth being suspicious of, so it is done the hard way: the fixture's own page starts quoting a different price mid-walk, and the assertion is that the schedule re-read it and the diff engine noticed. Nothing is written by the harness — a fabricated snapshot row would test neither half. The timer is compressed through an explicit interval (dev-flagged) rather than by waiting a week, and the payload says it is compressed.
npm run walk:agent walks the agent-research rail against a stub app-requests
door and a store it builds itself. Eighteen movements, 147 beats: which of
the four things is missing when the rail is unavailable, the caps refusing an
oversize body before anything is posted, the six-a-minute throttle on an injected
clock, two requests filed and page text riding neither, a double fire answered with
the original request id, all four poll outcomes including a deadline reached through
the real backoff ladder in a millisecond of simulated waiting, both write-back
gauntlets with every refusal itemised, and the ghosts that result carrying
"proposed by , request , ". The last movement goes through
the real server: an attributed run writes back over HTTP, the ghost appears on the
step mid-flight with its provenance rendered, and the step is served in
milliseconds because it never waits on the door. It owns its store because it
writes: entries, requests, proposals and a market frame. The stub restates the
door's caps rather than importing them, so it can catch the app disagreeing with
the contract, and both take their port as an argument.
node tools/wave9-shots.js --base=<url> --seeded=<url> --empty=<url> shoots Wave
9's acceptance set and nothing else: the rail in both lock states, in both themes,
at 390px; the Team page populated, with one disclosure open, and empty; the
Overview's amended block order on both stores; and the two de-merged deep dives.
Three stores because three of the claims are about three stores — a live-shaped one
that answered no interest question, one that answered step 2 and therefore renders
padlocks, and one nobody has shared. Given fewer it shoots what it can and says
which shots it skipped rather than inventing them.
npm run seed:team layers the Team page's own scenario onto a seeded fixture: an
unresolved split, a pending invite, a delivered duty. It is deliberately not
part of seed:fixture, because that store is the byte-for-byte baseline
check:preservation compares against and every row here moves a byte on
/api/handoff.
node tools/bootstrap-walk.js walks the third front door on a machine that has
only ever run npm run init: it hands the store a contract, boots, and asserts the
eleven beats of a workflow-built workspace, shooting each into
tools/screenshots/. The last two hand it a contract with no market, on two
different anchor pages: one the first pass can read a market off, and one that names
only itself, so the board has to ask.
node tools/three-door-check.js is the preservation check. It git archives
main into a temporary tree and serves the same live-shaped store from both, then
compares five API surfaces byte for byte. Two keys are excluded and both are
clocks; the check demonstrates one of them moving under a single unchanged tree
rather than excusing it.
npm run check:self-reference boots the same fixture market against a store it
builds, names the board "Northern Property Portal Watch" on purpose, drives the
five-step flow, and counts how many times each block on the rendered day-zero page
says that name. The budget is one per block: at "Radar" a third self-reference reads
as slightly repetitive, and at five words it reads as a chant. It is a DOM count
rather than a grep, because the name is resolved at render time and the blocks are
assembled from fragments. npm run check:self-reference:self-test injects a second
mention and fails if the gate stays green.
Two things about that gate are worth knowing, because it was green over a card that
named the board five times. It counts case-insensitively, since innerText
applies text-transform and the lead's kicker is uppercased by the stylesheet; and
it asserts which blocks it looked at, because a gate that cannot say that cannot
be trusted to have looked at anything. The budget itself is spent at render time, per
block, on the day-zero states only: most of the repeated prose is composed
server-side on routes check:preservation compares byte for byte, and the rule is a
property of the assembled block rather than of any one sentence.
node tools/cadence-probe-check.js builds six stores with a month of firings and a
known number of moves each, because the cadence probe's value is its arithmetic and
the alternative is waiting thirty days.
node tools/demo-walk.js http://localhost:8099 drives the 90-second walk above,
beat by beat, against a demo store: it clicks the pin, opens the evidence view,
checks that exactly one diff line is highlighted and that the words inside it are
marked, opens the correction form, reads the pattern rows, loads the battlecard as
a page and counts its citations, and shoots every beat to
tools/screenshots/wave3-*. verify.js proves the payloads carry the beats; this
proves a browser renders them. It fails on any console error, because a walk that
half-works is a walk you find out about in front of somebody.
node tools/reports-unread-check.js http://localhost:8200 is the end-to-end
account of the Reports badge: it generates reports and watches the badge rise,
opens them and watches it fall, reloads to prove the reads were stored, and runs
two named readers side by side to confirm one reading does not clear the other.
It also checks the unnamed visitor's browser-local fallback writes no row to the
store. It generates reports and records read state, so point it at a scratch
store, never at a board anyone is using.
The empty board gets two checks of its own, because it is the state nobody
develops against. node tools/dayzero-panels-check.js http://localhost:8095
walks every tab in a browser and then clicks every control each panel draws,
re-entering the tab between clicks, and fails if any of them lands on the
"Could not render this section" boundary — it hooks the caught error out of
console.error on the way past, so a failure names the panel and the line
rather than the boundary text. node tools/dayzero-render-sweep.js covers the
view states a walk cannot reach, crossing each panel with the search, sort,
filter and drawer state that changes what it builds, in-process behind the same
DOM stub verify uses. Both are green today; they exist so that a renderer
written against a populated store cannot quietly stop being.
node tools/overview-render-check.js is the cheap one to reach for while
editing: it drives only the panels the briefing touches, against a Radar
already running, and finishes in about a second — enough to catch a template
that interpolated undefined without paying for a full verify.
Some checks are about honesty rather than correctness: that a "No per-seat" value is not classified as per-seat, that the caveat survives a round-trip through report storage, that a retired endpoint answers 410 with a forwarding note rather than 404, and that the top-5 digest changes are spread across more than one competitor.
The harness writes a market event and two reports, then deletes them, so a verification run leaves the store exactly as it found it.
{ "version": 1, "app_name": "Issue Tracking Radar", // optional: the card's naming step is skippable "market": { "label": "Issue tracking" }, // optional: omit the key entirely and this app names the market "competitors": [{ "name": "Gridline", "url": "https://gridline.example.com/" }], "anchor": { "url": "https://you.example.com/", "role": "own-product" }, "reader": { "name": "Dana Reed", "bolterUserId": "7f3c1a90" }, "destination": { "kind": "chat", "ref": "#radar-issue-tracking" }, "interests": ["pricing", "new-entrants"], // optional: absent means the board asks once "cadence": "daily", "timezone": "Europe/London" }