Assistant grounding
Generated from docs/ai/grounding.md. Edit that file, then regenerate: python docs/portfolio/_build/generate_reference_pages.py.
Status: Proposed — 2026-08-12 Owner / reviewer: CTO (D5 of docs/plans/20260811_documentation_system_execution.md) Applies to: every AI engine reachable through the gateway Related: CONTRACT-CDC-001 (event contract) · CONTRACT-API-001 (access) · docs/ai/charters/ (what each engine is for)
Why this document exists
Four artifacts describe the assistant: its charter says what it is for, the tool catalog says what it can call, CONTRACT-CDC-001 says what it may render, and the KAB fixtures say how it must behave. None of them says what the model actually receives, what is written down afterwards, and what leaves the building.
That is the question a security review asks first, and until now the answer existed only as code. Everything below was read out of the code on 2026-08-12; where a thing could not be verified, it says so instead of guessing.
Re-review when any of these change:
ai/src/kavai/gateway/app.py routing and the proxy
ai/src/kavai/gateway/scope.py scope enforcement
ai/src/kavai/gateway/sandbox.py per-user pods, T2 memory, wiki
ai/packages/kavai-agent-contract/ the request envelope
ai/src/kavai/systems/*/runtime.py model selection and turn assembly
supabase/migrations/*message_traces* retention
Turn assembly — what reaches the model
One turn is an AgentRequest (kavai_agent_contract.types):
| Field | What it carries |
|---|---|
message |
The user’s text |
thread_id / run_id |
Conversation and run identity |
state |
Client-held run state |
tools |
Tool definitions offered for this run |
context |
Caller-supplied context items |
dataset_scope |
WorkspaceScope — organization ids, dataset slugs, campaign ids, focus image ids, and the strict flag |
Alongside it the host injects a RunContext: caller identity, the same scope, a trace context, and capabilities — request-scoped functions the engine calls for data. The contract states the boundary in its own docstring:
Hosts inject caller-scoped capabilities (read-only data access, state store) here; Agent Systems must not import host configuration, load host
.envfiles, or receive service-role credentials.
That is the property everything else rests on: an engine never holds a credential of its own. It receives the caller’s reach, and nothing wider.
Where the turn is executed
The gateway routes on system_type (gateway/app.py):
dataadk,orion,argus→ the ADK runtime (:50052).kawa→ the caller’s personal sandbox pod when one is reachable, otherwise the static adapter (KAWA_ADAPTER_URL,:8082). Reachability is a health probe, and pod IPs “resolve everywhere but only route in-cluster” — so in local and non-cluster environments this is always the static adapter.
This routing decision changes what is in the turn, which is why it belongs in this document rather than only in the runtime chapter. See Per-user memory.
Model
kawa defaults to gemini-2.5-flash (systems/kawa/runtime.py), overridable per request via body["model"]. Inference is Google’s; nothing runs locally.
Not verified here: the model each ADK-backed engine selects, and whether any engine’s default differs by environment. Stated as a gap rather than assumed.
Per-user memory — the part most likely to be misread
A personal sandbox pod is configured with three durable stores, all enabled by default (gateway/sandbox.py):
| Store | Env | Default |
|---|---|---|
| Per-user wiki + on-disk mirror | KAP_SANDBOX_WIKI_ENABLED, KAP_SANDBOX_WIKI_MIRROR |
1 |
T2 memory — transcript (hermes_session_state) and home memory (hermes_user_memory) |
KAP_SANDBOX_T2_ENABLED |
1 |
| Home sync | KAP_SANDBOX_HOME_SYNC |
1 |
The code records that “both migrations are live on the shared DB, so sandboxes enable them by default.”
A correction, recorded because the wrong version was written down twice.
hermes_user_memorywas previously described — including indocs/proposals/20260811_ai_assistant_documentation.md— as merged but dormant, gated off. That is not what the code says. The migrations are live, the sandbox controller enables T2 by default, and akawaturn served by a reachable sandbox pod therefore has durable per-user memory in scope.What remains true is that this applies only to the sandbox path. A turn served by the static adapter has no per-user memory. So the honest statement is conditional, not “off”: whether the model sees prior-session memory depends on whether the caller has a running sandbox pod — which is an environment property, not a product setting the user can see.
Open question for review: a user cannot currently tell which path served their turn, and the two have different memory properties. That is a disclosure question as much as a UX one.
The tenancy boundary
Two independent mechanisms, and it matters that they are independent:
- RLS, under CONTRACT-API-001. The engine acts as the caller; the database decides what that caller may see. An engine holding no service-role credential (above) cannot exceed it.
- Scope enforcement at the gateway (
gateway/scope.py), which is not tenancy — and is routinely misread as such.
Scope is advisory by default: a selected dataset grounds an ambiguous prompt (“show images”) while a broad one (“list my datasets”) must still answer across everything the caller’s JWT can see. Hard filtering happens only when the request carries dataset_scope.strict. In strict mode every outbound CUSTOM event is validated as it streams — out-of-scope DATASET_LIST rows and IMAGE_GALLERY images are dropped, counts kept consistent, and an event whose rows are all out of scope is dropped entirely rather than rendered hollow.
Scope narrows; RLS protects. A scope failure shows a user rows from another of their own campaigns. Only an RLS failure crosses a tenant. The feature matrix’s scope row measures the former.
What is written down
message_traces — AG-UI event traces including conversation payloads.
- RLS enabled, no permissive policy: service-role only, reachable by no client.
- No tenancy key, by design. The 2026-08-01 decision was to keep it closed and bound it by time rather than scope it, “because a retention window is a smaller thing to get wrong than a policy over conversation content.”
- Retention: 7 days, via
delete_old_message_traces()on a dailypg_cronjob at 03:00. - Adding a client policy means revisiting that decision, not just writing a policy.
Worth knowing as history: this table was readable by anon — RLS was never enabled — until 20260801160000_close_public_read_paths.sql. 311 rows of conversation payload were exposed. It is closed now; it is recorded here because a grounding document that only describes the current state teaches nothing about how the state was reached.
chat_sessions — user-owned, with per-user RLS policies (auth.uid() = user_id for insert/delete and the rest). This is the durable conversation list a user sees and controls.
Not verified here: whether chat_sessions has a retention policy. Nothing found in the migrations. If the answer is “none”, that is a decision to take rather than a fact to record.
Egress
- Inference leaves KAP. Prompts, conversation history and tool results reach Google’s Gemini API. There is no local model.
- No redaction layer was found between turn assembly and the model call. Anything a tool returns into the conversation — equipment tags, findings, storage-resolved metadata — goes with it.
- Media is UUID-only across the skill boundary (ADR-006): skills receive prepared bytes through a capability, never a storage path or signed URL, and CONTRACT-CDC-001 §7.4.1 forbids a URL reaching the user or the model.
Open question for review: the absence of a redaction step is stated because it was looked for and not found, not because it was decided against. Whether one is needed is a question for the reviewer named at the top.
What this document does not cover
- Prompt content. Instructions live in code today; WP-11 moves them to versioned files, at which point the composition of a turn becomes readable without reading Python.
- Per-engine differences beyond routing and model selection.
- The KavApps
kavai_serverdeployment, which answers the same contract with its own grounding.