Brand Perception Check (Val Town)

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.

Naming

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.

What this measures, and what it must never claim

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.

  • Never a sentiment score. There is no number between 0 and 1 here and there must not be one. Nothing in this pipeline measures feeling, and a score would read as though something did.
  • 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.
  • Sources are "sources the answer engine attributed", typed as official / community / review / news / other. They are not "where opinions about this brand come from on the web": three answers cannot support that claim. The phrase may appear on the page only inside the tooltip that denies it, and render.test.ts enforces exactly that.
  • Concerns are the answer's own words. A concern is a short phrase the answer wrote as a drawback, verified against the prose by the same adjacent-complete-words rule the brand matcher uses. It counts once per answer.

Branded is a separate instrument, not a variant

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.

Boundaries

  • Keep the host Deno-native and web-standard. Do not import the Node Canonry server, Fastify, better-sqlite3, or the Vite dashboard.
  • The kit is the shared core; this Val is the product surface. @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.
  • This Val owns its own result schema, and the kit stores it opaquely. 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.
  • The browser never receives provider, Turnstile-secret, deployment, or signing credentials.
  • Anonymous work is bounded by human verification, atomic quotas, cache reuse, output clipping, and a hard provider deadline.
  • Never render data the Val did not measure. The landing page ships an empty state, not a fixture. A fabricated verdict about a real brand is a worse lie than a fabricated percentage, and a badge in the header does not qualify it for anyone who scrolls.
  • Never present the Val as the full platform. Bounds stay stated on the surface that shows the number.
  • The bundled skills are generated into the KIT (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.

One phase, and its budget is arithmetic

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.

A failed check says why, and an unmeasured one is not a zero

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.

UI

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:

  1. Verdict snapshot — a flat KPI row. The counts are UNBOUNDED, so there is no bar and no meter: "2 of 3" has no target to fill and a bar would invent one. The denominator is written under every number, not once at the top, because the number and the bound it rests on have to be read together. With nothing measured the cells read "Not measured" rather than printing four zeroes.
  2. Answers — one row per branded question: the question, the verdict badge, the answer's own first verified sentence (quoted), and the source count. A native <details> per row opens every verified sentence, the concerns, the typed sources, the searches the engine ran, and the full answer.
  3. Concerns raised — phrase plus "in N of M answers". An empty list is STATED, not omitted: "no concern was raised" is a finding, and a missing section reads as a broken page.
  4. Sources the engine attributed — by kind, counted once per answer, with the share and the unattributed remainder stated separately.

Other rules the tests hold:

  • Heavy explanation lives in tooltips, not inline prose. Each section heading carries a CSS-only 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.
  • Say it once. "Sources the engine attributed" is a section heading; the per-row disclosure labels its list "Sources". The section-order test finds the first occurrence of a heading string, so a duplicate phrase earlier in the page fails it — which is how the duplication was caught.
  • A numeric column is aligned once, on the column. 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.
  • No inline styles and no inline script, because the page CSP blocks both.
  • The waiting view has ONE track. The runner is genuinely sequential, but the server reports no phase, so naming three phases would tell the reader which one is running when nothing knows that. The bar is indeterminate for the same reason; elapsed time is counted, not guessed.
  • Copy the client rewrites lives in one constant. 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.

Styles: the kit's, extended rather than forked

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.

MCP endpoint

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.

  • Reads never spend. 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.
  • It blocks. Execution is request-bound (AbortSignal.timeout(45_000)); there is no background queue, so returning a check ID early would kill the work with the request.
  • Anonymous, on purpose. Everything reachable here is already public: /api/checks/:id serves any check without a credential, and the skills ship in a public npm package.
  • The skill INDEX is 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.
  • Every payload carries 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.
  • Two protocol eras. Revision 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.

Dependencies

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.

Two configs, because the kit is resolved from two different places

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.

Fixed public limits

  • A visitor may supply up to three questions; the planner generates only the remainder, and is not called at all when all three are supplied. A supplied question need not name the brand — the visitor chose it — while a GENERATED one must, and the planner drops any that does not. Supplied questions are IDENTITY, not tuning: they join checkFingerprint, so two callers asking different things about one brand never share a cached result.
  • One grounded Gemini planning call creates the questions the visitor did not supply.
  • Three Gemini answers run in one wave.
  • One bounded extraction call reads all three answers back for the position each takes.
  • The full request stops after 45 seconds.
  • Turnstile, daily quotas, one active job, and a 24-hour cache bound public use.

Local development

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.

Verification

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.

Production configuration

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.

Release order

Nothing here can deploy today, and both gates fail closed rather than shipping something that throws at the first request.

  1. Create the Val in Val Town and record its identity. .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.
  2. Publish the pinned @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.
  3. Create the production 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 check applies 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 0 to 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, --frozen resolves from the lock and the policy does not apply, so the deploy workflow needs no flag and no config.

  1. Regenerate the skill mirror with node scripts/sync-val-town-skills.mjs.
  2. Run the verification commands above, including deno task check:prod.
  3. Run vt push --dry-run and review the file plan.
  4. Run vt push only after approval.
  5. Request /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.

What is copied from the sibling Val, and what to hoist next

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:

FileDifference from the sibling
src/app/app.tsThe 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.tsSERVER_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.tsOne submit label.
src/ui/client.tsThe 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.tsFixtures 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

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.