Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Radar

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.

The Overview tab: the brief, the diff that produced it, and the evidence behind it

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 URL

Already 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 digest

The Reports tab: the weekly digest, six slots, every claim dated and sourced

MIT 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.

Three documents the comments cite and this repo does not carry

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.

Finishing this from the Bolter workflow

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.

The question none of the four asks

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.

Environment

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 three standing jobs, and where they live here

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.

What done looks like

npm install
npm run verify        # 1240+ checks, seeds its own throwaway store, needs no setup
npm run check:design  # the design contract as static gates

Both 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.

Day zero: one URL

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:8094

Open 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.

What happens, in the order it happens

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.

What day zero will not do

  • 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() in lib/facts/verify-pass.js throws if it tries.

The ladder: upgrades are earned

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-layer and win-loss are both here, and each carries the reason the code is not there.

Three doors, one kernel

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 reveal
{
  "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"
}

It 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.register the kernel's closing card calls, including its honesty about a chat destination on a host with no hook.

What the app asks after that

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.

The duties feed

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.

Forking this

Auth

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.

What your workspace carries

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.

Upgrading a store that predates this table

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.

The model call

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.

The three providers, in order

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.

Features, kill shots and the STEAL NOW layer

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 verify

Building 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.

Dev flags

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.

Run the demo

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:8094

demo: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.

The walk

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.

Layout

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.

Five destinations, then the deep dives

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.

API

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

Retired endpoints

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

Judgement calls worth knowing about

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.

Where @mentions went

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.

The sweep

sweep.js re-observes Tier 1 (ChatGPT, Claude, Gemini, Copilot, Lindy) from their own pages. Two rules govern what it will write:

  1. 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.
  2. 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.

Verification

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 checks

Eleven 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.js needs RADAR_DEV=1 on 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 init store 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages