This directory is the exact source mirrored to the public canonry/Brand-Perception-Check val. Git is authoritative; do
not make production-only edits in Val Town because vt push removes remote-only files.
The Val is Brand Perception Check, not Canonry. It puts 3 branded questions to one engine, once, where Canonry
tracks four engines on a schedule with no such limits. Calling this "Canonry" would tell a visitor, or an agent reading
serverInfo, that they had the platform when they have a sample of it. So the product name, the page title, the
wordmark, and the MCP serverInfo.title all say Brand Perception Check.
serverInfo.name is the slug brand-perception-check, matching the sibling Val's ai-visibility-check. MCP's name
is the identifier a client keys off and title is where a human reads the display name; the shared contract wrote the
display name into name, and splitting the two is the deviation this file records.
Attribution runs the other way, and deliberately: every surface points back at the open-source project. The header
byline, the footer (with the install command), the MCP initialize instructions, the self_host tool, and the bundled
skills all name Canonry and link to it. Keep that; a sample that does not lead anywhere is just a toy.
A verdict, carried by the answer's own sentences. Each branded answer is read back for the position it takes —
recommends, cautions, mixed, none — and that verdict survives only when sentences copied WORD FOR WORD out of
the answer carry it. The model proposes and exact matching disposes; the kit's verifyVerdict is the arbiter, and a
verdict with no surviving evidence collapses to none. The page then QUOTES those sentences. That is the whole product:
a reader who does not believe a verdict can read the line it came from.
none is a finding; null is not. "Took no position" says the answer described the brand and took none.
null says nobody read a position out of it — a failed probe or a failed extraction — and it leaves EVERY
denominator, not just the verdict counts. Collapsing the second into the first turns an outage into a result.render.test.ts enforces exactly that.Every question here NAMES the brand, so the engine was always going to discuss it. A mention rate over this basket would read near 100% and mean nothing. AI Visibility asks NON-BRAND questions, where placement is actually decided.
The two never share a denominator, a table, or a rate, and the scope travels with every number: scope: 'branded' plus
a scopeNote on every MCP payload, and the branded framing in the section tooltips on the page. The repository's own
"Branded vs non-brand" rule is where this comes from — measured on a real basket, pooling the two inverted the ranking.
This is also why the two Vals cannot share a cache. CHECK_FINGERPRINT_NAMESPACE is perception-v1, and the kit's
checkFingerprint(namespace, domain, userQueries) takes it as a required argument with no default: a namespace two
products shared would hand each of them the other's result and report it as a cache hit. Bump THIS constant when THIS
Val's measured signal set changes, and never another Val's.
better-sqlite3, or the
Vite dashboard.@canonry/val-kit owns the perception instrument
(branded planner, verdict extraction and its arbiter, source typing, aggregation, the Gemini adapter); the URL and
Turnstile guards; both CheckStore implementations; check-record identity and the generic admission/lease machinery;
MCP framing and the generated skill resources; and the design tokens and mark. What stays here is what this Val IS:
its HTTP routes and response policy, the check runner's phase and every sentence a visitor reads, its MCP tool
surface, and its HTML. Moving one here is not a fork: delete the local copy and import the kit, or the two drift and
only production finds out which one shipped.PerceptionCheckResult and
CHECK_FINGERPRINT_NAMESPACE live in src/runtime/check-result.ts. The kit sees them only as the TResult of
CheckRecord<PerceptionCheckResult> — written as JSON, handed back untouched. The parameter is named once, at
new ValSqliteCheckStore<PerceptionCheckResult>(sqlite) in main.http.tsx, and every record downstream is typed from
there; a cast on the result path means the generic was dropped somewhere above it.packages/val-kit/src/mcp/skills/), not into this directory. Edit
skills/<name>/ at the repository root, then run node scripts/sync-val-town-skills.mjs and commit the result. CI
runs it with --check. Never hand-edit a generated module.There is exactly ONE phase: createGeminiValPerceptionProbe(...).probe(...). No site crawl, no second track. Inside it
the work is strictly SEQUENTIAL — plan, then one probe wave, then one verdict extraction — so the worst cases ADD
rather than overlap, and there is no second phase to hide a slip behind:
plannerTimeoutMs (10s) + 1 wave x probeTimeoutMs (20s) + verdictTimeoutMs (12s) = 42s
<= PUBLIC_CHECK_WORK_BUDGET_MS (45s)
Four numbers across two packages, and nothing but perception-budget.test.ts connects them to the job ceiling. It
asserts the RELATIONSHIP, not the numbers: raising maxProbeCalls without raising probeConcurrency silently
reintroduces a second wave, and the cost shows up as a shorter per-probe deadline rather than as an error. It also pins
verdictTimeoutMs === VERDICT_EXTRACT_LIMITS.timeoutMs, because two constants that happen to match are two constants
and the budget stops being true the first time only one moves.
The whole check is five provider calls: 1 planner + 3 probes in one wave + 1 verdict extraction. That number is meant to be noticed when a feature adds a call — it is the check on what a public visitor can make this Val spend.
publicProbeError translates each of the runner's closed-set failure strings into visitor wording ("answer engine",
not "provider") and falls back to a generic sentence for anything it does not recognize, so a sanitizer never trusts
its input. perceptionPhaseError does the same for a failure thrown before any probe ran — a phase that produced no
row still has to say why, or the check is unexplainable from every surface.
A planning failure gets its own sentence: "Branded questions could not be generated for this brand." That is a fact about the BRAND, not about the engine, and the difference between "try again" and "this will not work".
perception-runner.test.ts drives the REAL probe runner into each failure and then asserts none of them lands on the
generic fallback. That is the guard: a new failure mode added upstream without a translation fails the suite instead of
silently erasing its own reason.
Status is decided from what was measured. complete when every answer produced a verdict, partial when some did
not, and failed when successfulChecks === 0 — a report where nothing was measured has a zero in every denominator,
which is not a result with empty sections but a check that found nothing. The evidence rows survive a failed record,
so the page can still say why each answer was not measured. There is no configured ceiling here, so reaching one is
never the reason for a partial.
The UI reads the same evidence rather than the stored flag. record.status is frozen when the check runs, so
correcting the rule behind it never reaches a record already written; hasFailedWork looks at the rows instead, and a
stored partial with nothing failed reads as ready.
Match the Canonry dashboard's restrained evidence-first hierarchy: compact header, tables, native disclosure controls,
visible focus. Four sections, in this order, and render.test.ts pins the order:
<details> per row opens every verified sentence, the concerns,
the typed sources, the searches the engine ran, and the full answer.Other rules the tests hold:
info-tip: a real
<button> whose aria-label is the copy, opening on :hover and :focus-within. The page CSP allows no inline
script, so a control that only works after a file loads is broken on first paint, and :focus-within only fires for
something focusable.is-numeric goes on the header AND every cell in that column,
and one kit rule keys off it. The test walks every rendered table and asserts they agree. Note the lookahead in its
header regex: <th[^>]*> also matches <thead> and silently shifts every column index by one.client.ts rewrites the query hint's whole line on every
keystroke, so a sentence added only to the server markup is on the page until the visitor types and then silently
disappears. QUERY_HINT_SUFFIX is declared once in client.ts and imported by render.ts; that direction avoids a
cycle, since render.ts already imports the script.src/ui/styles.ts concatenates canonryDemoStyles with the handful of rules this product needs — the verdict row, the
quotation lists, the concern list, and .signal-negative (the kit's signal vocabulary has no negative, because the
sibling Val has no negative signal). They are written in the kit's own token vocabulary, so the two Vals read as one
surface. The kit is NOT edited to hold them: a rule no other Val can use is not a shared value, and the seam says a
product's own surface stays with the product. A literal hex here instead of a token would be the first step to two
design systems, and a test pins the tokens.
POST /mcp serves Model Context Protocol over Streamable HTTP so an agent can read this Val's checks directly. Five
tools: get_check, get_brand_perception, start_check, self_host, read_skill.
get_check and get_brand_perception resolve through store.get and store.findReusable
only. They must never reach admit or dispatch; a test asserts an uncached domain lookup leaves the store
untouched and spends no quota.start_check is the only tool that spends, and it runs the host's full admission path. It skips Turnstile, which
an agent cannot solve, and spends from its own mcp: quota subject with its own daily limit. It keeps sharing the
global daily cap and the single execution lease: the global cap is what bounds the bill, so MCP may widen who spends
the budget but never how large it is. Disable with CANONRY_MCP_START_CHECKS=0.AbortSignal.timeout(45_000)); there is no background queue, so returning
a check ID early would kill the work with the request./api/checks/:id serves any check without a
credential, and the skills ship in a public npm package.resources/list, not a tool. read_skill reads one document and its description says where
the index is. The sibling Val also ships list_skills for clients with no resource support; this one does not, which
is the second deviation from the shared contract's tool list — it enumerated five tools and list_skills was not
among them.scope: 'branded' and a scopeNote, and a test asserts no numbered payload leaves without
one. The summary is passed straight through from the record and never recomputed on the read path: deriving it a
second time is how a tool payload starts disagreeing with the page.2026-07-28 removed sessions and the GET stream; every shipping client still opens
with initialize. Both work. Keep the strict mirrored-header checks keyed off the version the caller declared, or
legacy clients break.Every dependency is fully qualified at the import site (npm:hono@4.12.25, npm:@canonry/val-kit@0.2.0/jobs,
https://esm.town/v/std/sqlite/main.ts). Val Town resolves the module graph from esm.town and does NOT apply a pushed
deno.json import map, so a bare specifier deploys and then throws not a dependency and not in import map at the
first request. deno.json therefore excludes the no-import-prefix lint rule, which enforces the opposite convention.
The kit pin is therefore repeated in every file that imports it — N places to drift instead of one manifest key — and
packages/canonry/test/val-kit-dependency-contract.test.ts asserts each Val's inline specifiers collapse to exactly one
version equal to packages/val-kit/package.json. It iterates apps/vals/*, so this Val was covered the day it landed.
deno.json is PUSHED. It is the production graph and must describe only what Val Town can see: the public registry. A
links entry there would point at a workspace no deployed Val has, and a shared lock would be rewritten with a
resolution production cannot reproduce. So it carries no links, no nodeModulesDir, and no lock key, and the
contract test fails if one appears.
deno.dev.json is LOCAL, and .vtignore keeps it (and deno.dev.lock and node_modules/) out of the push. It
duplicates compilerOptions, lint, and fmt — Deno has no extends — and adds links: ["../../../packages/val-kit"],
nodeModulesDir: "auto" (linking an npm package REQUIRES a node_modules directory; without it Deno refuses outright),
and its own lock: "deno.dev.lock". That is what lets one PR change the kit and this Val together, before the kit is
published. deno.dev.lock is committed, because --frozen needs it and CI checks out fresh.
There is no production deno.lock yet, and there cannot be one. It is generated by resolving the production graph
against public npm, and @canonry/val-kit@0.2.0 is not published. Creating it is step 3 of the release order below.
Build the kit first. Deno consumes an npm package as built JavaScript, and the kit's exports point at dist/, so
pnpm --filter @canonry/val-kit build has to run before any Deno task here. The check, test, and dev tasks guard
on dist/index.js and say so rather than failing inside a resolution error. Deno re-syncs its copy of a linked package
whenever that dist/ changes, so rebuilding is the whole refresh step.
checkFingerprint, so two callers asking different things about one brand never share a cached result.deno task dev # http://localhost:8788 — stub runner, no network GEMINI_API_KEY=… deno task dev # real Gemini planner, probes, and verdict extraction
In-memory storage and no Turnstile, so the UI and /mcp both work without credentials. The offline stub composes its
summary with the REAL summarizePerception, so it cannot hand the page a headline its own rows do not support — the
one thing a fixture must never be allowed to do here. It also always includes one unmeasured row, so the
null-is-not-none contract is visible in every hand test. npx @modelcontextprotocol/inspector against
http://localhost:8788/mcp drives the endpoint as a real client would.
Run pnpm --filter @canonry/val-kit build from the repository root, then deno task check, deno task lint, and
deno task test from this directory, plus node scripts/sync-val-town-skills.mjs --check from the root. Those three
tasks validate the DEV graph, which is what CI's vals matrix job runs.
deno task check:prod validates the PRODUCTION graph instead (plain deno.json, --frozen). It cannot pass until the
pinned kit version is on public npm — before that it fails with npm package '@canonry/val-kit' does not exist — which
is the same gate the deploy workflow applies, on purpose.
Set in Val Town: VAL_TOWN_ENV=production, GEMINI_API_KEY, CANONRY_QUOTA_SALT, TURNSTILE_SECRET_KEY,
TURNSTILE_SITE_KEY, TURNSTILE_ALLOWED_HOSTNAMES. Optional: CANONRY_MCP_START_CHECKS=0 removes the MCP write tool;
CANONRY_MCP_PER_CLIENT_DAILY_LIMIT sets its daily per-caller allowance (default 2).
Public checks are disabled when the production quota salt or Turnstile configuration is absent, so an unconfigured deployment serves reads and skills but refuses to spend.
Nothing here can deploy today, and both gates fail closed rather than shipping something that throws at the first request.
.github/workflows/deploy-brand-perception-check.yml ships
with VAL_TOWN_EXPECTED_VAL_ID and VAL_TOWN_EXPECTED_BRANCH_ID set to 00000000-0000-0000-0000-000000000000, and
its first step refuses to run while either is that placeholder. Paste the real IDs into the workflow, and set the
repository variable VAL_TOWN_BRAND_PERCEPTION_HEALTH_URL to the deployed /healthz URL. Those two IDs are the
whole deployment target: .vt/state.json is gitignored (apps/vals/*/.vt/), so the workflow generates it from them
with node scripts/write-val-town-state.mjs apps/vals/brand-perception-check rather than expecting a file on the
runner. Nothing else needs provisioning.@canonry/val-kit version with the Publish @canonry/val-kit workflow
(.github/workflows/publish-val-kit.yml). Until then the production graph cannot resolve, and both check:prod and
the deploy workflow fail closed.deno.lock with deno check --allow-import main.http.tsx (plain config, no --frozen)
and commit it. This is only possible after step 2. deno.dev.lock is separate and is not touched by this.Deno blocks a freshly published version for 24 hours.
deno checkapplies a minimum dependency age policy (default 24h) to reduce supply-chain risk, so the step above fails with "blocked by the minimum dependency age policy" if the kit was published minutes ago. Pass--min-dep-age 0to generate the lock anyway — defensible for a first-party package this repo just built and published from a reviewed commit. The override is only needed to CREATE the lock: once the lock pins the version with its integrity hash,--frozenresolves from the lock and the policy does not apply, so the deploy workflow needs no flag and no config.
node scripts/sync-val-town-skills.mjs.deno task check:prod.vt push --dry-run and review the file plan.vt push only after approval./healthz and confirm {"ok":true}.vt push makes the Val match this directory. It deletes remote-only files, so do not edit production in the Val Town
editor.
There are two Vals now, which is the point at which "copied" becomes "duplicated". These files are identical or nearly
identical to apps/vals/ai-visibility-check/, and are the next hoist into @canonry/val-kit:
| File | Difference from the sibling |
|---|---|
src/app/app.ts | The result type parameter and two import paths. Nothing else. The strongest hoist candidate: routes, CSP, capability URLs, Turnstile, admission/quota/lease flow, body limits, and /healthz are product-agnostic already. |
src/mcp/server.ts | SERVER_INFO and INSTRUCTIONS. The dispatch, the two protocol eras, the body cap, the origin check, and the error envelope are identical. A kit createMcpHandler({ serverInfo, instructions, tools }) would take all of it. |
src/ui/check-form.ts | One submit label. |
src/ui/client.ts | The sibling's tab code is absent and the fallback submit label differs. The query-hint rewrite and the waiting-view enhancement are the same. |
src/jobs/*.ts (the lease/claim/renew/finalize preamble) | Identical up to the phase itself. The sanitizers and the copy are genuinely per-product and stay. |
src/ui/render.ts (escaping, safeUrl/safeHref/safeHost, assetUrl, renderNotice, renderInfoTip, formatTime) | Identical helpers. The sections are per-product and stay. |
test/backend/app.test.ts, test/backend/mcp.test.ts | Fixtures and payload names. The admission, quota, capacity, CSP, and protocol cases are the same suite twice. |
Two things deliberately did NOT move: the check runner's phases and every sentence a visitor reads. The admission test is mechanical — a module that references a perception phase, string, or this product's own offering is not generic, whatever else is true of it.
README.md is rendered on the public val.town page, so it is marketing copy for a stranger who has never heard of
Canonry — not a contributor document. Keep implementation detail, limits rationale, and operational procedure here in
AGENTS.md instead.