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 agent trace, no feedback, no attachments), split by role like the signed-in one: a question carries only its is_edited marker, an answer only was_interrupted and its trimmed sources.
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.
Idle guest conversations and expired session rows are pruned by the daily guest_chat_cleanup schedule (cleanup_guest_conversations + clearsessions).
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 RATE_LIMIT_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 |
| End-to-end contract tests | campuscore_app/apps/main_app/tests/test_public_chat_api.py |