Public access (guest chat)¶
Signed-out visitors can chat with the assistant about explicitly published knowledge, when the school turns the feature on. This document says what "published" means, how the gate works, what a guest session is, and what bounds the cost of an open endpoint. The motivating user is a parent or prospective student asking about the university without an account.
The two publish switches¶
Nothing is publicly readable by default; publishing is always an explicit admin action, and each of the two content kinds has its own switch.
A workspace folder becomes public through the folder dialog's third visibility choice, "Everyone, including public visitors" (WorkspaceFolderVisibility.scope="public", stored as KnowledgeFolder.Visibility.PUBLIC).
Public is a fourth visibility tier above workspace: signed-in users keep access, and signed-out visitors gain it.
Demoting the folder to scoped or everyone revokes public access on the next query.
A scrape source becomes public through the Scraping Manager's per-config "Make public" toggle (ScraperConfiguration.is_publicly_visible).
Scraped files and chunks carry a stable scraper_configuration stamp written at ingest by DocumentWriter (and backfilled for pre-existing content); the volatile flag is read off the config at query time, so flipping it needs no re-indexing.
A campus file with no stamp - a folder-less import, or a scrape the backfill could not attribute - is never publicly readable.
KnowledgePolicy's Anonymous branches grant exactly these two sets and nothing else; the full access model lives in knowledge-access-control.md.
The school opt-in¶
cc_public_access is a deployment-scoped feature flag (see the scope axis in rbac-and-feature-flags.md): it evaluates from is_available AND is_enabled for any caller, carries no role scope, and ships available-but-off, so each school opts in from Feature Management.
The anonymous bootstrap reports it as public_access_enabled, which is what shows the sign-in screen's "Get Public Access" entry.
Server-side, the whole guest surface sits behind the auth="public_access" guard mode: while the flag is off every /api/public/ endpoint answers 404.
The guard runs before a streaming response begins, which is what makes a real 404 possible on the stream - an in-generator refusal would arrive as a 200 with an error frame.
campus_core/api/tests/test_anonymous_endpoint_set.py pins the exact set of endpoints reachable without a session.
The guest session¶
A guest's identity is their Django session, created by POST /api/public/session when the visitor clicks "Get Public Access".
The handshake exists because a streaming response cannot set the session cookie (its headers are computed before the generator runs); the stream requires the session and never creates one.
The cookie is a browser-session cookie (set_expiry(0)), so conversations survive reloads and die with the browser.
Guest conversations are Conversation rows with session_key set and user NULL - a check constraint forbids both owners on one row - and every read filters on (session_key, user IS NULL), so guest and signed-in conversations are mutually unreachable whatever ids a caller guesses.
The guest transcript endpoint returns a deliberately narrow shape (no feedback, no attachments), split by role like the signed-in one: a question carries only its is_edited marker, and an answer carries was_interrupted, its trimmed sources, its worked_ms and the guest's view of its trace.
The agent run itself is the normal AgentCore loop bound to an Anonymous principal via create_guest_agent: six tools (search, find_entity, read_file, describe_knowledge, list_folder_files, current date), no attachment or connector tools, and a dynamic context without the connected-services, whole-corpus inventory, and user-identity sections.
The guest system prompt is its own audience variant (build_agent_system_prompt(audience="guest")): guests are institution-questions-only by prompt contract - general knowledge, writing help, and coursework requests get a one-sentence decline pointing at sign-in - and the max-iterations fallback prompt restates the restriction, because the fallback swap replaces the system message the restriction lives in.
Guest conversations are kept indefinitely: losing the browser session makes a thread unreachable from the browser, which is a question of access rather than a reason to destroy what was said, and the deployment's own history of what visitors asked is client data.
Nothing unattended deletes one, and the guest surface carries no delete control at all: the only way a guest thread loses content is the visitor editing their own question and re-running it, which drops the answers after it.
What is pruned is the django_session rows guest traffic mints, by the daily session_prune schedule running prune_expired_sessions (a wrapper around Django's clearsessions).
What a guest receives¶
A guest sees the same timeline as a signed-in person, with less in it, live and after a reload.
guest_view in services/chat/turn_trace.py is the one home of that rule, and every event class must be named there before it can stream.
ChatService applies it to each frame of a guest's live stream, and guest_trace applies it to a saved turn, so the guest transcript's GuestTrace holds what the live stream held.
- A tool call keeps its row label and kind, and loses its arguments and the record it names.
- A tool result keeps its label, outcome, count and time, and loses the model's result line, the record name and the names behind its count.
- A note, a round line and the answer text arrive as they are.
- A reasoning title and a held connector write are never sent, because a guest has no connectors and a held write's parameters are arguments the model chose.
GuestTrace carries events and rounds only; a guest's sources stay in their trimmed GuestSource form.
A row label can quote the search text the model chose, as the signed-in row does.
The same chat, on the school's own website¶
The embeddable website widget is this surface delivered as a chat bubble on the school's site, behind a second flag (cc_website_widget) layered on this one.
It answers the same questions from the same published knowledge with the same agent, and its conversations are Conversation rows scoped the same way; what differs is that its visitor is identified by a signed header rather than a Django session, because the widget's page runs in a cross-site iframe where this deployment's cookies never arrive.
website-widget.md has the whole picture.
Cost and abuse posture¶
The stream is rate limited at 10 requests per minute per client, the reads at 60.
Client identity behind the ALB comes from the trusted tail of X-Forwarded-For, controlled by TRUSTED_PROXY_HOPS (see environment-configuration.md); the setting is cloud-required, so a deployment that misses it fails at boot rather than silently merging every visitor into one bucket.
Two honest limits of this posture: the in-app limiter fails open during a cache outage (an outage must not take the endpoint down), and the WAF's rate rule - 2000 requests per 5 minutes per IP - is the only bound in that state. The school-level kill switch is always available: turning the feature off makes the surface 404 again immediately.
Where to look¶
| Question | File |
|---|---|
| What may an anonymous caller read? | campuscore_app/apps/main_app/services/access/policy.py |
| The guest endpoints | campuscore_app/apps/main_app/apis/public_chat_api.py |
| The guard mode | campus_core/api/auth.py (require_public_access) |
| The guest agent and its tools | campuscore_app/apps/main_app/services/agent/agent_core.py (create_guest_agent), tools/base.py (create_guest_registry) |
| The chat-user boundary | campuscore_app/apps/main_app/services/chat/chat_user.py (CurrentChatUser) |
| The guest UI | campuscore_app/web/src/components/guest/GuestApp.tsx, web/src/routes/RequireAuth.tsx |
| The transcript shape both signed-out surfaces serve | campuscore_app/apps/main_app/apis/guest_transcript.py |
| What a guest receives of each event | campuscore_app/apps/main_app/services/chat/turn_trace.py (guest_view, guest_trace) |
| End-to-end contract tests | campuscore_app/apps/main_app/tests/test_public_chat_api.py |