The React SPA and the Typed API Framework¶
This doc covers the user-facing web app and the API layer it talks to.
It replaced the earlier server-rendered Django-templates + HTMX product UI.
Legacy Django-rendered HTML now survives only for the operator admin tools (/cc_admin/), the first-run setup wizard, and allauth pages.
The two halves¶
- Frontend: a React 19 + TypeScript + Vite single-page app in
campuscore_app/web/. - Backend: a typed JSON/SSE API defined by a small decorator framework in
campuscore_app/campus_core/api/, with endpoint modules incampuscore_app/apps/main_app/apis/*_api.py.
The contract between them is a generated one: the backend emits an OpenAPI document, and the frontend generates its TypeScript types from it. A Pydantic model change becomes a TypeScript compile error, and CI fails if the two ever drift.
Frontend (campuscore_app/web/)¶
Stack and conventions:
- Server state is owned by TanStack Query, with a per-module key factory (
conversationKeys,folderKeys,fileKeys, ...). Never duplicate server data into local component state. - UI state (drawer open, and the panel width each conversation was dragged to) lives in a small Zustand store (
src/lib/uiStore.ts) and nothing else; the store writes none of it to the browser, so a reload returns every conversation to the width its content wants. - Routing is react-router in data mode (
src/routes/router.tsx). There is no SSR server; the router runs entirely in the browser and Django serves the same shell for every SPA path. - Generated types live in
src/types/generated/api.tsand are reachable only through the single doorsrc/lib/api/schema.ts. An ESLintno-restricted-importsrule forbids importing the generated file directly, so a schema rename is a one-file change. - TypeScript is strict (
strict,noUncheckedIndexedAccess,exactOptionalPropertyTypes);no-explicit-anyis an error.
Directory shape under src/:
routes/- one component per route, plusRequireAuth(the gate every route sits behind) and the router config.components/-chat/,panel/,settings/,knowledge/,layout/,ui/(shared primitives).lib/api/- one module per resource; each wrapsapi(the fetch client) and exposes typed hooks.lib/streaming/- the SSE client for the agent chat stream, and the live-turn store the turns themselves live in.lib/upload/- direct-to-S3 upload (presign -> PUT -> confirm) and its hooks.lib/render/- DOMPurify sanitization,safeHref, and the HTML-to-text helper for copy.
Route-param gotcha (read before "fixing" a layout component)¶
AppShell is a pathless layout route that renders ConversationSidebar and ConversationPanel as siblings of <Outlet/>.
Both read the active conversation id with useMatch("/chat/:chatId"), not useParams().
useParams() would in fact also work at that depth - React Router shares one params object across every match in a branch - but useMatch states the intent ("which chat route is open") and returns null off /chat/:chatId without a separate check.
Do not "fix" a layout component that reads a child route's param on the assumption that it must be broken; verify in the browser first.
A turn does not belong to the screen that started it¶
/chat and every /chat/:chatId render one ChatRoute instance, so a turn held in that component would paint one conversation's answer under another - and would die whenever the person opened Knowledge, Artifacts or Settings, which unmounts the route.
So a turn lives in lib/streaming/liveTurns.ts, outside the React tree: the connection, the read loop, the animation frame that writes streamed document text, the record of which artifact calls already opened the panel, the question as it was sent, and the refetch that runs when the turn ends.
useChatStream is the subscriber - it starts turns and reads the one belonging to the conversation it was asked about, so leaving a chat leaves its answer alone and coming back finds it still arriving.
Turns are addressed by a client-made TurnId, because a brand-new conversation has no id until the server names it mid-stream.
The store admits one turn per conversation and one unnamed turn, so turnFor(turns, chatId) can never be ambiguous, and at most MAX_CONCURRENT_TURNS answers at once; refuseTurn is what a composer asks before offering Send.
The guest page and the embedded widget deliberately do not get continuity - neither has a sidebar to come back through - so each ends its turn explicitly when its surface goes away.
A turn's record outlives the stream that filled it.
When the answer stops arriving the record stays in the store with what it holds, and something has to discard it.
On the chat route that is the post-turn handler, which refetches this conversation's transcript, awaits it, and discards the record only when the transcript it got back holds the turn.
transcriptHoldsTurn (lib/streaming/turnHandover.ts) is that rule, and both chat surfaces read it: the read must have started after the turn settled, so it cannot be a checkpoint of an answer still being written, and the rows after the question have to hold at least what the screen does.
Every transcript read is stamped with the moment it began (stampedRead in lib/streaming/transcriptRead.ts), because without that stamp a read already in flight when the Stop landed would replace the answer the reader watched with a fraction of itself.
The guest page follows the same rule from an effect rather than from the handler: its post-turn handler only invalidates, and the turn ends when the transcript the query holds passes transcriptHoldsTurn.
A turn that failed or was stopped never reaches that handler, so its record stays on screen until the next send replaces it.
That send refetches the transcript first, through onReplace, and waits for it, so the question and whatever streamed of the answer step from the record into history without leaving the screen.
A stopped turn keeps its block whatever streamed, so its notice is there to read even when nothing did.
A turn whose stream was lost is the exception, because the server may have finished the answer after the connection went.
The store calls onLost on exactly three endings: a body that closes with no done frame, a connection that goes silent past the stall limit, and a read that fails after the server answered.
A Stop, a request the server never answered and a done that reports an error do not call it, because each of those already says what happened.
Each surface's onLost reads the transcript once, outside its query, and takes the read only when transcriptHoldsFinishedAnswer holds: an assistant row after the question that the server finished, with an id above lastKnownMessageId.
That mark is the highest message id the surface held when it sent, taken before a re-run trims the old answer away and never lower than the mark of the failed turn that "Try again" sends again, so a read that still shows the old answer never passes for the new one.
Any other read is dropped, so the error and "Try again" stay.
"Try again" on the chat route sends rerun: {message_id, reason: "retry"}, and the server refuses a retry whose question already has a finished answer.
It deletes nothing and sends one rerun_refused frame and no done, so the turn settles as completed and onDone reads the answer the transcript holds.
An edit or a regenerate whose request never got a frame back is sent again as it was, because a retry would be refused over the old answer and lose the edit.
For one release the server also accepts the older skip_user_message with message_id, as an edit, so a tab loaded before rerun existed keeps working through a deploy; a request that sends both shapes is refused with 422.
The widget registers the same two handlers and reads its stored transcript through them, and the store's retirement of the oldest finished records is what bounds everything else.
A saved question with no answer row after it carries "This question was not answered." on every surface, derived on read by unansweredQuestionIds, because the row itself cannot say why nothing followed it.
Where a turn stands is one tag on its state, StreamPhase in liveTurns.ts.
A record can never carry idle, the phase that means no turn has run, because TurnState narrows a record's state to TurnPhase.
A surface whose conversation has no record reads that resting state instead of nothing at all, so it reads timeline, sources and the rest without a null check on every line.
A transcript that will not load keeps its conversation and says so.
useTranscriptLoadFailure (lib/api/useTranscriptLoadFailure.ts) turns a query's error, its last success and what it is doing now into "loading is fine" or "it failed, and here is the way to try again", and ConversationLoadFailedNotice draws that above the scroller on the chat page and the guest page; the widget draws the same sentence under its header.
A 404 is not a failure there - the conversation is gone, and the route leaves rather than offering a retry - and the rows already on screen stay put while a retry is out.
Where a transcript opens and where it stays is useTranscriptScroll (lib/streaming/useTranscriptScroll.ts), shared by the chat page, the guest page and the widget.
A conversation opens at its latest message, and a reader who scrolls up is left there: the hook follows the bottom of a growing answer only while the reader is at it.
A question asked in a conversation that is already on screen is anchored to the top of the view with room reserved under it, so the answer is written where the reader is already looking; a question that starts a conversation follows the bottom instead, since there is nothing above it to anchor against.
Each conversation's place is remembered for the tab, so coming back to a chat returns to where it was left rather than to the top or to a bottom that has since moved.
One timeline, live and saved¶
A turn's display state is one TurnTimelineState in lib/streaming/turnItems.ts: an ordered list of items (notes, rounds of tool calls and approvals), the text block still open, and the live reasoning title.
applyTurnEvent is the reducer, and the live store calls it for every frame while savedTurn folds a saved row's trace through the same function, so a reloaded turn draws what it drew while it streamed.
answerText reads the answer out of that state, and every surface asks it rather than reading the open block itself, because a block is only the answer once no note has claimed it.
TurnTimeline draws the state on the chat page, the guest page and the widget alike; the server writes every sentence in it, as agent-retrieval.md describes.
The live switch in liveTurns.ts is exhaustive at compile time and tolerant at run time: a frame type this bundle does not know is skipped, so a server that adds one cannot end a turn in an older tab.
Every stream URL carries ?stream_version=2, which StreamQuery in campus_core/api/streaming.py parses on the server, and the round and reasoning frames go only to a client that states version 2 or higher.
A server that predates the parameter ignores it, because a POST endpoint does not read its query string, and a request body field or a new header would not be safe in that direction.
The disclaimer under a conversation's chat box¶
Once a conversation is on screen, the chat page (ChatRoute) and the guest page (GuestApp) show "AI responses may have mistakes." in small grey text directly under the chat box.
ComposerWithDisclaimer (src/components/chat/ComposerWithDisclaimer.tsx) is its one home.
It is the bottom area of the conversation layout on both pages: the composer, the line under it, and the padding down to the bottom of the window.
The welcome screens place the composer on their own, without ComposerWithDisclaimer, so they show no disclaimer while there is no answer yet.
The website widget renders the composer in its own footer, under which sits only its "Powered by CampusCore" caption.
The line's left inset, pl-4.25, is the composer's 1px border plus the px-4 on Composer's text area, so the line starts where typing does.
A change to that padding in Composer is a change to ComposerWithDisclaimer too.
Testing the SPA¶
The SPA has two Vitest projects, both defined in web/vitest.config.ts.
unit runs every *.test.ts and *.test.tsx file in jsdom, which is fast and does no layout; pnpm test runs only this project.
browser runs every *.browser.test.tsx file in real browsers, through Vitest's browser mode and Playwright; pnpm test:browser runs it.
A test belongs in the browser project when its claim is about geometry or real input: a size, a position, what a point on the screen hits, a real tap, or how far a container scrolls. Everything else stays in jsdom, where a class name or a rendered string is the evidence.
The browser project runs each file in three instances:
chromium-touchandwebkit-touchemulate a phone with Playwright'shasTouchandisMobile, so the page reports(pointer: coarse)and(hover: none), in a 390 by 844 frame;chromium-mousehas a mouse, so the page reports(pointer: fine)and(hover: hover), in a 1440 by 900 frame.
Each instance provides its pointer to the tests, which read it through src/test/browser/pointer.ts.
Every browser file starts with the test expectInstancePointer, which fails when the browser reports a different pointer than its instance was set up for.
Without it, a touch instance that lost its touch emulation would check every touch assertion against a mouse, and could pass.
A test that needs a wide layout, such as the settings rail or a tablet, calls page.viewport(1024, 768) from vitest/browser before it renders.
The helpers live in src/test/browser/:
render.tsxmounts a component withflushSync, because the browser project is not anactenvironment: a real tap updates state outsideact, and the flag would turn every tap into a warning;geometry.tsreports what a point hits and the 44px box a control should own, andexpectNoScrollGrowthfails when tap areas make a container scroll further;tap.tsmakes a real tap through thetapcommand incommands.tsand reports where the touch landed;controls.tscollects the controls a person aims at in a subtree (controlsIn), andexpectAreasApartfails on touch when one of them misses a point of its own 44px box or shares any of it with another, and with a mouse when two of them have overlapping drawn boxes.
Chromium and WebKit both move the click that follows a tap onto a control a few pixels away, and Chromium moves the touch event's target too.
A click or a focus alone therefore does not prove a tap hit what it aimed at.
Assert on the element under the touch's own coordinates, which tapAt returns.
The test setup has four files.
src/test/setup/shared.ts loads in both projects: it unmounts every React root a test leaves mounted, and resets the chat's stores.
src/test/setup/jsdom.ts loads in unit only: the act flag, the jsdom shims such as matchMedia and <dialog>, and the API contract check described under "One door to the network", which reads openapi.json from disk and so cannot run in a browser.
src/test/browser/setup.ts loads in browser only: it imports the app's own stylesheet, and wraps the native showModal and close to record each modal as the jsdom shim does.
src/test/setup/console.ts loads first in both projects, and fails any test that writes to console.error or console.warn.
The failure lists each call, so a React warning such as a missed act or a nested button cannot pile up unseen behind a green run.
A write made at import time, in a beforeAll hook or by an earlier test's timer fails the next test.
A write after a file's last test fails the file when it lands before the file's afterAll hooks run.
A test that expects output silences it for itself with vi.spyOn(console, "error").mockImplementation(() => {}), and restores the spy in a finally or an afterEach, so a failing assertion cannot leave the rest of the file unguarded.
Vitest's agent reporter, which it picks under Claude Code, hides a passing test's stderr, so pass --reporter=default to see what a test writes.
Browser tests render components from the data they are given and send no requests; a component that reads on mount, such as a saved action card or the website widget, gets a stubbed fetch.
A test that measures a line of text waits for document.fonts.ready first, because the first test in a file can render before the app's font has loaded.
A figure that differs between engines is keyed by server.browser from vitest/browser.
Locally and in the gate, the browser project runs in the browser_tests compose service, on the official Playwright image: docker compose --profile tools run --rm -T browser_tests.
The image carries the browsers of one Playwright release, so its tag must equal the pinned playwright package version, which scripts/check_playwright_pin.sh checks.
In CI, the Frontend job installs Chromium and WebKit with playwright install --with-deps and runs pnpm test:browser after the unit tests.
A test finds a control by the role and name a screen reader announces, so a control that loses its name fails the test that uses it.
The helpers live in src/test/:
controlNamed(root, role, name)incontrolNamed.tsreturns the one button, link, menu item, option, tab, switch, checkbox, radio, text box, search box or number field in reach with that name, and throws when there is none or more than one.controlsNamedfrom the same file returns every match in reach, for a test that counts them.noneNamedfrom the same file reports that no such control is on the page, leaving out one inside a closed dialog, for a test that proves one is absent.dialogNamed(root, name)indialogNamed.tsreturns the dialog whosearia-labelledbyheading reads that name.fieldLabelled(root, text)infieldLabelled.tsreturns the text field its<label>points at.
A test that proves a field's accessible name uses controlNamed, and fieldLabelled stays for pinning that a visible label is paired with its field.
accessibleDescription(element) in accessibleName.ts reads the text of the elements its aria-describedby names, for a test that proves a hint reaches its field as a description.
A name is an exact string, or a regular expression anchored on the stable part when the name carries changing text, such as a count or a date.
A pattern built from runtime text goes through escapeRegExp in escapeRegExp.ts.
A name runs inline text together and separates a block or a flex or grid item with a space, as Chrome does, so Sa<b>ve</b> reads "Save".
A control is out of reach inside a closed dialog, behind the top modal, or under a hidden, aria-hidden or inert element, and in a real browser when CSS hides it, just as it is for a person.
The ESLint rule no-restricted-syntax refuses a querySelector or querySelectorAll call that selects button, a, textarea, an input or a control role in any test file, through the CONTROL_LOOKUP list in web/eslint.config.js.
It lets an input through when the selector pins its type to checkbox or radio, because a selector that reads every box is common, but a test that means one box finds it with controlNamed.
It also lets an input through when the selector pins its type to one controlNamed has no role for, such as file, date, color or hidden.
In a template literal, a token after an interpolation inside quotes is past what the rule sees.
The helpers under src/test/ are exempt, because they are what the rule sends people to.
The geometry tests in GEOMETRY_TESTS are exempt, because they measure every control whatever its name.
Any other lookup the rule refuses carries // eslint-disable-next-line no-restricted-syntax -- <reason>, and it may stay for one of three reasons:
- It reads a whole set, such as every tab to pin their order, or it measures geometry.
- It proves a control is absent in a form
noneNamedcannot see: any control of a kind whatever its name, a button whose text holds a word, or an anchor with nohref. A test that proves no field of any kind is on the page is the first form. - It reaches a pointer-only target that is
aria-hiddenon purpose, such as a drawer's scrim.
src/test/controlLookupLint.test.ts lints sample lookups under every test block, so dropping the rule from one block fails it.
Backend API framework (campus_core/api/)¶
An endpoint is an ordinary function wrapped by one decorator:
@api_endpoint(methods=["POST"], input=CreateFolderInput, output=FolderSummary, auth="workspace_admin")
def create_folder(request, data: CreateFolderInput) -> FolderSummary:
...
The decorator (decorators.py) does five things:
- Applies the auth guard named by
auth=before the body is parsed. Levels aresession(default),workspace_admin,superuser,public_access, andnone(explicit, greppable opt-out). The guard runs throughsync_to_asyncon async views, so an async endpoint enforces the same level as a sync one. - Parses the request into the declared Pydantic
inputmodel (JSON body for POST/PUT/PATCH, query params for GET), raising a 422 with field-level, PII-free detail on failure. - Serializes the return value from the declared Pydantic
outputmodel. A view may return a rawHttpResponseBase(file download, redirect) as an escape hatch; a download declaresbinary_response=Trueand setsContent-Disposition: attachment. The two today are the audit CSV and the skill export. The audit CSV is a plain link with adownloadattribute, because the server has nothing to refuse; the skill export is fetched instead, so its refusal (nothing published, a blocker in the live text) arrives as the JSON envelope the page can show rather than rendering as the page. - Maps every error to one envelope -
{"error": {"code", "message", "details?}}- and never lets an exception escape as an HTML 500.ApiErrorsubclasses carry their status (ApiNotFoundError404,ApiConflictError409,ApiRateLimitError429,ApiServiceUnavailableError503, ...). A 503 is for a valid request whose dependency is down and expected back: the two upload confirms and the chat attachment confirm raise one when the queue could not take the file's processing message. A fault in our own code or configuration is not that - it is a bug, and a bug is a 500. A 409 carries acode:confirmation_required(the roles API, retry withconfirmed),draft_conflict(the skill draft moved, reload),live_version_changed(the skill's live version moved, retry withacknowledge_live_change), andfolder_name_taken(a folder in that scope already carries the name, choose another). The first three are codes a client switches on, because each names a different thing for the client to do next.folder_name_takenis not: the two folder forms show the server's sentence, which already says which folder holds the name and what to do about it, so neither form reads the code. A builtinPermissionErrorwith a user-facing message maps to 403; one with an OSerrnois treated as a bug and returns a generic 500. - Self-registers in
registry.pysoschema.pycan emit OpenAPI without a second declaration.
Extras:
- Rate limiting is declarative:
rate_limit=RateLimit(...)on the decorator, enforced after auth, backed by the shared Redis cache, keyed by the authenticated user (never a spoofable header), and fail-open on a cache outage. - Streaming uses
@api_streaming_endpoint: the view is anasync defyieldingSseEvents. A failure before the first frame becomes a JSON error; a pre-frameApiErrorraised inside the generator surfaces its real message in the terminal SSEerrorframe. Seestreaming.pyfor the disconnect/heartbeat/single-task-pump behavior. The request context the correlation middleware binds is readable inside the generator, so an audit row or a log line written there carries the request id and the IP. - Input models forbid unknown fields (
ApiInputsetsextra="forbid"), closing mass assignment on every mutation. - Object-level authorization lives in the service layer, not the view: owner-scoped lookups return 404 for another user's id rather than confirming it exists.
The contract pipeline¶
Pydantic input/output models -> manage.py export_openapi -> openapi.json
openapi.json -> openapi-typescript -> web/src/types/generated/api.ts
- Regenerate after changing any
input=/output=model (or a contract model they reference) withscripts/sync_openapi_types.sh. - Both
openapi.jsonandapi.tsare committed generated artifacts. .github/workflows/pr-validation.ymlruns an unconditional drift gate: it re-exports the schema and regenerates the types, and fails the PR if either differs from what was committed. A local pre-commit hook does the same, but CI is the enforcement that cannot be skipped.- The generator runs with
--default-non-nullable false. A field with a server-side default is optional on the wire, and the exporter always writes a request body as a$refintocomponents.schemas, where the generator's default would render every such field required. Without the flag a typed body could not leave out a field the server is happy to default. Responses are unaffected: the exporter marks every response property required before the document is written.
Every request body names its input type¶
A request body is not unknown on the way out.
DeclaredBody<T> in web/src/lib/api/schema.ts accepts only a type that is one of the generated input types, matched both ways, so api.post<ConnectionTestResult, ConnectionIdInput>(path, {...}) compiles and each of these does not: a body sent under no type at all, a misspelled field, a superset of the input type, {}, object, Record<string, unknown>, and a type no endpoint declares.
RequestBody is the union of every JSON body in paths, and NoInfer stops the compiler reading the type off the literal, which would make every body its own type and check nothing.
A helper that forwards bodies takes <TBody extends RequestBody> and passes DeclaredBody<NoInfer<TBody>> through, so the caller's named type is what is checked - useConnectorMutation in lib/api/workspaceConnectors.ts is the pattern.
The rule is pinned by web/src/lib/api/requestBody.typetest.ts: a plain line for each accepted form and a @ts-expect-error line for each refused one.
Nothing in that file runs; loosening the guard turns its unused directives into typecheck failures, which is the only way a rule made of types can be tested.
Renaming a field on a Python ApiInput and regenerating now fails pnpm typecheck at every call site that still sends the old name.
One door to the network, and what guards it¶
web/src/lib/api/http.ts is the only module that calls fetch.
Everything else - the typed client, the two chat streams, the widget frame, the guest handshake, a skill export download - goes through its send, and ESLint's no-restricted-globals and no-restricted-properties refuse fetch, window.fetch, globalThis.fetch and self.fetch everywhere else, with http.ts and the test files exempted.
That single door is what lets the web tests check every outgoing request against openapi.json.
src/test/setup/jsdom.ts replaces the http module for every importer with vi.mock, so a request is checked even from a test that stubbed fetch itself, and web/src/test/contract.ts validates each one with ajv against the committed document: the path has to match a declared template, the method has to be declared on it, path and query parameters have to match their schemas, and a body has to match the operation's request schema exactly - a missing body where one is declared and a body sent where none is declared are both violations.
Anything outside /api/ is left alone.
Violations are recorded and thrown from an afterEach, so the test that sent the request is the test that fails.
There is no allowlist: a red test is either an SPA bug, fixed in the SPA, or a test sending something the server would refuse, fixed in the test.
src/test/contract.test.ts checks the check itself, including a child-process run of contract.fixture.test.ts that proves a recorded violation actually fails a test rather than only being recorded.
.github/workflows/pr-validation.yml gives campuscore_app/openapi.json its own paths filter, because the web tests read it: a change to that document alone has to run them.
Enumerated fields¶
A status, or any other closed set of values, on a wire model is typed with the model's TextChoices class (status: SourceFileStatus), or with a module-level Literal alias when there is no model behind it (ErrorKind, ViewerKind, SslMode).
Never str: a bare string reaches the SPA as string, and the SPA then keeps its own copy of the allowed values, which drifts.
The generator emits a named enum component for a TextChoices field and an inline enum for a Literal, web/src/lib/api/schema.ts re-exports each as a named union, and every SPA branch on the value is an exhaustive switch.
A client-owned union keeps assertNever in its default; a value the server sent uses the non-throwing form, const unhandled: never = status; void unhandled; return <fallback>;, so a newer server never turns into a render crash in a browser holding the previous bundle.
apps/main_app/tests/test_api_status_types.py sweeps the exported document and fails on any bare-string property named status, kind, protocol, scope, match_mode, source, actor_type, category, outcome, action or features, in a component or in a query parameter.
The sweep looks inside an array's items too, so a list of an enumerated type counts: AuthenticatedBootstrap.features is a list[UserFeatureSlug], and the SPA's own union is AuthenticatedBootstrap["features"][number].
Add a name to that list when a new field joins the family.
A site whose value set is genuinely open is named in that file's OPEN_SET_SITES with its reason, and a second test fails when an exemption stops naming a real site, so an exemption cannot outlive the thing it excused.
Two sites are listed today.
ActionConfirmationEvent.action is one, because connector plugins declare their own @action methods, so the chat stream's confirmation event carries a method name no enum can list.
ToolResultEvent.outcome is the other, because it is the count a trace row shows, such as "3 documents".
When every reference to a TextChoices field moves inside a union's discriminators, the named enum component disappears from the document, because nothing refers to it any more.
schema.ts then aliases the status from the union instead of from the component - CustomDomainStatus = DomainRow["status"] - which is the same set of literals, and the union's own coverage test takes over from the component's.
The same typing on an input= model is the whole validation: the framework answers an unknown value with a 422 naming the field, before any handler code runs, so no service re-checks the value and no handler holds a fallback for one that never arrives.
A column behind such a field carries a models.CheckConstraint on its values, which is what makes the API's parse of a stored value a proof rather than a coercion.
In the SPA a value set may appear in exactly one shape the compiler checks: a Record<Union, ...> keyed by the generated union, or the exhaustive switch above.
A hand-written array of the members, or a literal compared against a value typed string, is the drift this rule exists to prevent; comparing a literal against the generated union (connector.kind === "oauth2") is fine, because tsc rejects a member the server no longer sends.
When the UI needs to render the members, the server sends the list of choices with their labels.
A status with companion fields whose row carries check constraints is a per-status union rather than a flat model: one variant per status, status: Literal[<Enum>.<MEMBER>], joined with Field(discriminator="status"), and wrapped in a RootModel when it is a top-level output=.
PendingActionResult, ConnectionRow, BatchProgress, FileItem, FileStatus and DomainRow are the pattern: only a failed action carries an error_message, only an expired connection says why, only a failed batch has a failure_reason, only a failed file has an error, only a failed domain says why provisioning gave up, and the serializer raises on a row that contradicts its own status because Pydantic would otherwise drop the field silently.
The same split applies to any discriminator, not only status.
FeatureItem is joined on scope: UserScopedFeature carries the roles a feature is scoped to and DeploymentScopedFeature carries none, so the SPA cannot read a role scope off a feature that has none.
MessageOut and GuestMessage are joined on role: a UserMessage carries is_edited and its attachments, an AssistantMessage carries was_interrupted, its rating and its trace, and the row's own check constraints are what say those combinations are the only ones.
AuditEventPage is joined on kind: an AuditEventFirstPage carries the view's total and the filter vocabularies, and an AuditEventContinuationPage carries none of them, because counting them costs a table scan the client only pays once.
An output model can also be a projection of a stored one: TranscriptTrace (services/chat/turn_trace.py) is the transcript's view of PersistedTurnTrace, the same fields over the wire event classes, so the stored data_preview never reaches the schema while the stored form keeps it.
Datetime fields¶
A datetime on a wire model is typed WireDatetime or WireDatetime | None, never str.
WireDatetime and the WIRE_DATETIME adapter over it live in campus_core/api/base.py and are exported from campus_core.api, so the form a datetime takes on the wire has one home: every stored datetime is UTC under USE_TZ, so the framework serializes every one of them as 2026-01-01T12:00:00.123456Z, and the keyset cursors write and read their timestamp half through the same adapter, so a row's time and the cursor that pages past it can never drift.
The field lands in openapi.json as format: date-time, which is what tells a generated client the string is a datetime.
apps/main_app/tests/test_api_datetime_types.py sweeps the exported document and fails on any bare-string property named timestamp, created, modified, last_login, or ending in _at, in a component or in a query parameter.
Add a name to that list when a new datetime field takes a name outside it.
Serving the SPA¶
The bundle is a static asset, not a second service (campus_core/spa.py):
- Vite builds into
static/spa/;collectstaticbakes it intoSTATIC_ROOTat image build time; WhiteNoise serves it. index.htmlis a Django template so it can set the CSRF cookie the SPA reads for itsX-CSRFTokenheader. CSRF is not exempted; API requests flow throughDynamicCsrfViewMiddlewarelike any other.spa_shellis a catch-all mounted last that returns the shell for any SPA route but 404s the/static/,/media/,/api/, and/_allauth/prefixes, so a missing chunk during a rolling deploy fails cleanly instead of returning HTML where JavaScript was expected.- Locally,
scripts/build_spa_local.shmirrors the Dockerfile's node stage to refresh the Django-served bundle; the Vite dev server on:5173serves source live and shares the localhost session cookie.
Where to look¶
- Framework:
campus_core/api/{decorators,auth,responses,exceptions,pagination,registry,schema,streaming,rate_limit}.py. A list endpoint pages with the keyset helper inpagination.py, which mints the cursor and parses both of its halves, so a malformed one is a 422 before any query runs. - Endpoints:
apps/main_app/apis/*_api.py(singular_api.py= typed SPA endpoints;*_apis.py= the remaining/cc_admin/HTMX admin tools). - Frontend entry:
web/src/main.tsx->router.tsx->RequireAuth->AppShell. - Contract:
apps/main_app/management/commands/export_openapi.py,scripts/sync_openapi_types.sh,openapi.json,web/src/lib/api/schema.ts.