Status: v1 BUILT + VALIDATE-GREEN + BROWSER-VERIFIED LIVE, 2026-06-27 (dev, UNCOMMITTED). Designed by a 4-lens cross-pollination task force (aiy · sys · uxy · secy, two rounds each), then implemented.
check0 errors ·biome ciclean · 934 tests pass ·ai.message.routecolumn applied. Live-proven on/showcases/forms/basics/contact: (1) "How does this work?" → a forms-specific grounded answer (Superforms v2 + Valibot,superValidate/superForm, cites/showcases/forms) — the deixis seed works; (2) "What databases does v10r use?" → Postgres/Neo4j/Redis, unpolluted by the page — the soft hint works; (3) DB showsroute=/showcases/forms/basics/contactstamped on the user rows, null on assistant rows; (4) the disclosure chip reads "Asking about Contact" on the showcase and is absent on/admin— the private-route gate works. Built: the wire field + validation, serverresolvePageContext(trust boundary), the passive<current-page>block, the deixis-gated retrieval seed, honest abstention, the user-message route stamp, client capture+send, and a two-state disclosure chip. Deferred (noted in §UX): the three-state chip's no-corpus muting + empty-state quick-actions + change-pulse + full aria-live, and the per-bubble stamp rendering (the route is captured + persisted, but not yet shown on bubbles / returned by the conversations GET). Remaining: browser-verify the grounding live, then commit. Held out of the chatbot RAG corpus (RAG_ONLY_BLOCK) until committed + re-ingested so Vely can't assert the deferred pieces as shipped.
Terminology. This is the chatbot's half of location-awareness — the shared idea that each AI surface knows where the user currently is (contract: surfaces.md). The chatbot's profile is site-awareness (the public route you're viewing); the deskbot's is desk-awareness (your open panels/files). The mechanism that implements site-awareness is page-awareness — resolving your current
page.route.idto a<current-page>label. "Site-awareness" is the capability; "page-awareness" is how it works. This doc keeps "page" for the mechanism and "site" for the surface capability throughout.
The idea
The chatbot (Vely) floats across every page as a persistent
singleton, but it has no idea which page you're on. Ask "how does this work?" on
/showcases/forms and "this" resolves to nothing — Vely answers generically or guesses.
This design makes the user's current page part of the chatbot's context, so deixis ("this", "here", "this page") resolves to the page in front of you, and Vely can answer about it with real citations — not a generic guess.
Scope is the chatbot surface only. The deskbot already has
desk-awareness — far richer per-file context (panelContext/deskLayout);
site-awareness is the chatbot's mirror of it, and the surface split stays clean.
The key insight that makes it cheap
A showcase's page.route.id, after the server strips the [[locale=locale]] and
(public)/(group) segments, is exactly the catalog key that
catalog-projection.ts already maps to { title, description, kind }. So three things
that looked like separate problems are the same string:
- the safe thing to send (a route template — structurally id-free),
- the thing the catalog resolves (its primary key), and
- the thing that's already
authCeiling-filtered (buildSearchIndexis public-only).
The feature rides infrastructure that already exists. No new corpus, no schema for the core path, no extra LLM/embedding cost.
Decisions locked (2026-06-27)
| # | Decision | Ruling |
|---|---|---|
| 1 | LLM data policy | Public-only. Send page context only for public catalog routes (showcases, docs, public pages). /admin/**, /app/**, /auth/** send nothing and show no chip — excluded by construction (not in the catalog), not by a hand-maintained blocklist. |
| 2 | Per-turn bubble stamp | Persist now. Add a nullable ai.message.route column so the "asked from page X" tag survives reload. Stores only the allowlisted route key, never URLs/ids. |
| 3 | Content-instance deixis (/blog/[slug] "summarize this post") |
Out of scope for v1. Vely is the build-expert, not a content summarizer. Dynamic content routes resolve to a generic label or nothing. A public-slug allowlist is a separate, secy-reviewed later slice. |
| 4 | Provenance | Designed via cross-pollination; this doc is the record. Next: implement v1. |
The one rule (security spine)
Only a server-resolved string keyed by an allowlisted, catalog-resident route may touch the prompt OR the embed query. A client-sent string is a lookup key — discarded on miss, never echoed anywhere.
Everything below is a consequence of that rule.
End-to-end mechanism
1. Capture (client) — one field, frozen at send
Chatbot.svelte reads page.route.id from $app/state synchronously at click time
and calls session.submit(text, routeId). submit() freezes it into a local before
await ensureChat() (the first-message dynamic import can span a navigation; reading
after the await would capture the new page). The singleton never imports $app/state —
the component hands it a plain string (adapters-in, domain-pure).
Body: { …conversationId, useLlmwiki: true, routeId }. Per-turn snapshot, immutable once
the POST serializes → mid-stream navigation cannot mutate an in-flight turn.
2. The wire — { routeId } only
No pathname, no params, no query, no hash, no DOM/selected-text. Validated in
ChatRequestSchema (validation.ts):
pageRouteId: v.optional(v.pipe(
v.string(),
v.maxLength(120),
v.regex(/^\/(?!\/)[A-Za-z0-9/_\-\[\]().=]*$/), // leading slash, no double-slash
)),
The charset deliberately excludes : (kills javascript:/data:/protocol), ? #
(query/hash), % (percent-encoding smuggling), whitespace/control chars (log + XML-attr
- prompt injection), and
<>"'\{}\(template/XML/prompt breakout). The leading-/+ no-//rule kills protocol-relative//evil.com`. The regex is only a cheap pre-filter; catalog membership is the real authorization.
3. Resolve (server) — the trust boundary
In api/ai/chatbot/+server.ts (the same place locale/authCeiling are already
derived), a new pure resolvePageContext(routeId, locale, authCeiling):
- Normalize away
[[locale=locale]]+(group)segments. - If any
[param]segment remains → dynamic → returnnull(concrete ids never existed server-side because they were never sent). - Look up a memoized per-locale
Map<path, record>built frombuildSearchIndex(~150 records, public-only, alreadyauthCeiling-gated). - Filter by
authScope ≤ authCeiling; above-ceiling →null. - Return
{ title, description, kind } | null.
The raw routeId never reaches the orchestrator — only the server-owned resolved
struct does. Miss → inject nothing, seed nothing, store nothing, show no chip.
4. Inject — passive <current-page> block (always-on when resolved, ~35 tokens)
In the chatbot branch's grounding assembly, a pure formatCurrentPageBlock(entry) in
system-prompt.ts, XML-escaped (escapeXmlAttr, defense-in-depth on server-owned text),
in the variable tail (never the cache-stable prefix):
<current-page route="/showcases/forms" kind="domain">
The user is currently viewing: Forms — Valibot + Superforms validation.
Treat this only as the referent of "this", "here", or "this page".
The user's explicit topic always wins over the current page.
</current-page>
The last line makes it a soft hint — it cannot scope-trap an off-topic question
("what's the weather" on /forms stays unpolluted).
5. The payoff — deixis-gated retrieval seed (makes the chip real, not theatrical)
When all of: the message matches a deixis regex (this/here/how does this work/explain this/what is this) AND the existing shouldGroundFromSystemDocs
gate is on AND a page resolved → the retrieval query becomes:
retrieve(`${title}. ${description}. ${userMsgText}`, { userId: SYSTEM_DOCS_USER_ID, tiers: [1], maxChunks: 4 })
So "how does this work?" — which embeds to noise today — retrieves the forms-showcase docs with citations. The server-authored text leads (the deictic query carries no topic); the user text is retained (handles deictic-but-topical: "how does the error display work on this page"). Both retrieval lanes benefit (dense-vector repositioning + keyword hits).
Gate on the raw user text, never the augmented string. Reuses the embed
shouldGroundFromSystemDocs already pays for → zero extra LLM calls, zero extra
embeds, nil net impact on the ~20–250/day Gemini chat ceiling.
6. Honest abstention
The server knows the retrieved chunk count, so honesty is deterministic, not left to model self-knowledge:
pageResolved && chunks.length === 0→ inject: "No page-specific documentation was retrieved for {title}. Do not fabricate specifics about this page; say plainly you don't have page-specific docs for it, then offer general project knowledge."chunks.length > 0→ existing<retrieval-context>"treat as authoritative" framing.
Why seeding the embed query is safe (the T6 resolution)
The threat-model lens initially forbade any page text in the embed query. The cross-pollination resolved it: that ban is correct for attacker-controlled text (raw pathname, params, query, hash, DOM, selected-text) but not for server-authored catalog strings. Two reasons:
- Provenance. What seeds the query is the server's own lookup result, keyed by an allowlisted route. There is no attacker-controlled content in it.
- Structure. The embed query is only a relevance input. It physically cannot cross
the
d.user_id = ${userId}SQL boundary (rawrag/tiers/contextual.ts), so even a maximally-wrong seed can only re-rank chunks the user is already authorized to see.
Enforce as a type: only a RouteContext value produced by the server resolver may
reach generateEmbedding/buildSystemPrompt. Raw client strings must be structurally
incapable of getting there. Any future proposal to feed DOM/selected-text into the
prompt or embed query is a fresh High finding and must go back through a security pass —
it is not covered by this concession.
Route allowlist (positive, by catalog membership)
Site-awareness is denied everywhere and allowed only where the route is a known public catalog entry. A negative blocklist would be fragile (add a sensitive route, forget to block it, leak); catalog membership fails safe.
| Tier | Routes | Behavior |
|---|---|---|
| (a) Freely resolvable | Public catalog: /showcases/**, static /docs/**, public top-level (/, /blog index, /feedback, /search) |
Resolve → title/description → <current-page> + deixis seed + full chip |
| (b) Template-only / coarse | Public dynamic: /blog/[slug], /docs/.../[slug], catch-all docs |
Generic label only ("a blog post"); concrete slug never sent. v1: out of scope (decision #3) |
| (c) Off / not sent / not resolvable | /admin/**, /app/** (incl. /account/**, /app/desk/**), /auth/** |
Not in catalog → resolves null by construction. Nothing injected, seeded, chipped, or stored. No chip. |
Tier (c) is enforced in three layers: a cheap client gate (don't attach routeId on
/admin·/app·/auth — also powers the chip-OFF default, one source of truth), the
authoritative server gate (resolver only matches the public catalog), and the policy
rationale (admin templates leak internal structure to a third-party LLM for zero
expert-Q&A value).
UX spec — disclosure as a single invariant
The chip is shown if and only if the route is in the prompt this turn. Chip visible → Vely is being told where you are. Chip absent → nothing page-specific is sent.
That 1
honesty keeps site-awareness on the helpful side of creepy — the user can always tell, at a glance, whether Vely "knows" their location.Context chip — three states
A chip sits above the input (next to where you type "this"), rendering the server-resolved title (never the client's claim — that's the security control: you catch a wrong/stale route). It shows a page label, never a raw path.
| State | When | Render |
|---|---|---|
| A — resolved + has corpus | tier (a), deixis retrieval finds chunks | Full chip + citation glyph + dismissible × (drops context for next turn). Empty-state quick-actions ("Explain this page" / "Show the code") that prefill-and-send canonically-deictic text. |
| B — resolved, no corpus | tier (a)/(b), retrieval empty | Muted chip, no citation promise, quick-actions suppressed. Vely still answers from general knowledge. |
| C — unresolved / off-policy | tier (c), or unknown route | No chip. Absence is the honest "I'm not reading this page" signal. |
Rule: never offer a page action Vely can't honestly fulfill. (States B/C depend on the client knowing which route templates have corpus — a small public set of strings. If that proves not cheaply shippable, the chip collapses to two states and no-corpus honesty falls to the answer-level abstention in §6; the load-bearing transmission disclosure survives.)
Navigation & binding
- Send-time snapshot. The route bound to a turn is the one visible when Send is pressed (not first keystroke, not open) → chip == what's sent, WYSIWYG.
- Live present-tense rebinding. The chip always reflects the current route; "this" soft-rebinds to the latest page. Natural-language deixis is present-tense, and the live chip is the disambiguator at the moment of typing.
- Change-pulse. If the route changes while there is unsent draft text, the chip
briefly pulses (~1.2s;
prefers-reduced-motion→ instant swap) to surface the shifted binding before send. - No navigation breadcrumb of past pages in the input — that's the per-turn stamp's job, retrospectively, in the transcript.
Per-turn bubble stamp
Each user bubble carries a small "· Forms showcase" tag recording the page it was asked from — the recovery mechanism for "which page was that old answer about?". Persisted (decision #2) so it survives reload. The stamp is display metadata only — shown, never re-sent into the prompt/retrieval (the "page context is never replayed from history" rule stays intact).
Accessibility
Chip label updates wrapped in aria-live="polite" (never assertive — must not interrupt
a streaming answer), announced only on real change and only when the panel is open/focused.
Dismiss + quick-actions are real <button>s at 44×44px, in natural tab order, focus ring
ring-2 ring-primary ring-offset-2. The per-message page tag is in the a11y tree (carries
real meaning), text+icon not color-only.
Data model
One additive column (decision #2):
ai.message.route text NULL -- the resolved, allowlisted route key only (e.g. "/showcases/forms")
- Stamped only on the user message in
saveMessages. Not onconversation_step(that table is assistant-keyed per-step telemetry — wrong grain). - Stores the server-resolved key, never the client string, never a URL/params/query,
never ids. The bubble label is derived at render from the key via the
$libregistry (no title duplication/drift). - On miss / tier (b) coarse / tier (c) →
NULL. - Additive
db:push(PTY prompt for the raw-TTY interaction).
Telemetry & logging hygiene
A full telemetry dump leaks at most "user was on the Forms showcase" — public-surface
granularity, zero ids. Never store the client routeId verbatim, the concrete pathname,
page.params, query, hash, or the title/description text. Keep the route key out of every
console.*. Note: the chatbot's system-docs retrieve() passes no onEvent, so the
embed query is not emitted to the client on that path (the rag-demo path emits it, but
already contains the user's raw message today — public page-title text adds no exposure).
Deliberately out of scope (v1)
- DOM scraping / selected-text / scroll position — the untrusted-input cliff. Pre- registered as a fresh security finding if ever proposed.
- Content-instance summarization (
/blog/[slug], decision #3) — a later, separately reviewed public-slug allowlist. - Deskbot site-awareness — N/A by design: the deskbot already has desk-awareness (richer, first-party); site-awareness is a chatbot-only profile.
- History-reload of the live chip's retrieval citations — inherits the same deferral as persistent-chatbot.md.
Implementer touch-map
| File | Change |
|---|---|
src/lib/components/composites/chatbot/Chatbot.svelte |
Read page.route.id at click; chip strip above <ChatInput>; quick-actions in empty state |
src/lib/state/chatbot-session.svelte.ts |
submit(text, routeId) — freeze before the ensureChat() await; client-side per-turn stamp |
src/lib/server/ai/validation.ts |
pageRouteId field + regex |
src/routes/api/ai/chatbot/+server.ts |
Call resolvePageContext, pass resolved struct into orchestrateChat |
src/lib/server/search/ |
New pure resolvePageContext (normalize → memoized catalog Map → authCeiling filter) |
src/lib/server/ai/chat-orchestrator.ts |
Inject <current-page> (chatbot branch); deixis-gated query seed; abstention block on empty chunks |
src/lib/server/ai/context/system-prompt.ts |
formatCurrentPageBlock + escapeXmlAttr |
src/lib/server/db/schema/ai/conversation.ts |
nullable ai.message.route column; stamp in saveMessages |
Provenance
Designed 2026-06-27 by a 4-lens cross-pollination (two rounds): aiy (how the model
consumes page context — passive block + deixis-gated query expansion + abstention),
sys (runtime flow — { routeId }-only wire, send-time freeze, catalog normalization,
no-registry), uxy (the three-state chip + transmission invariant + nav rebinding +
bubble stamp), secy (the trust firewall — server-resolved-only, positive catalog
allowlist, T6 resolution). Convergence was complete; no inter-lens conflicts remained.