State endpoints
State endpoints are the only ones that mutate persistent data. They use
optimistic concurrency, return Cache-Control: no-store, and never
leak the bearer token.
Create
POST /api/v1/stateMints a new anonymous plan. Request body is empty or an initial state document. Response body contains:
{ "data": { "state_token": "<base64url ≥256 bits>", "state_version": 0, "catalog_version_id": "math-2026-spring" }, "meta": { ... }, "warnings": [], "unknowns": [], "source_references": []}The state_token is returned exactly once. Subsequent endpoints
require it as Authorization: Bearer <token>. See Authentication.
Read
GET /api/v1/state/currentAuthorization: Bearer <token>Returns the current state document and version. Cache-Control: no-store.
Patch
PATCH /api/v1/state/currentAuthorization: Bearer <token>Content-Type: application/jsonApplies a structured set of changes. Body:
{ "expected_state_version": 7, "operations": [ { "op": "add_course", "term_id": "fall-2026", "course_code": "MATH 247", "status": "planned" }, { "op": "remove_course", "term_id": "winter-2026", "course_code": "STAT 230" } ]}Returns the new state and version. 409 Conflict if
expected_state_version doesn’t match — re-read and retry.
Replace
PUT /api/v1/state/currentAuthorization: Bearer <token>Content-Type: application/jsonReplaces the full state document (typically used to re-import an
export). Body is a complete state document plus
expected_state_version. Same 409 Conflict semantics as PATCH.
Export
GET /api/v1/state/current/exportAuthorization: Bearer <token>Returns a portable JSON copy of the state with Cache-Control: no-store.
Does not write a state-event row.
You can re-import an export later via PUT /api/v1/state/current.
Migration preview
POST /api/v1/state/current/migration-previewAuthorization: Bearer <token>Content-Type: application/jsonBody:
{ "target_catalog_version_id": "math-2026-fall"}Returns a diff: which courses were renamed, which requirements changed,
which entries become invalid. Does not apply the migration —
preview only. To apply, follow with PUT /api/v1/state/current with the
migrated state.
See ADR 0004.
Delete
DELETE /api/v1/state/currentAuthorization: Bearer <token>Content-Type: application/jsonBody:
{ "confirm": true }The explicit confirmation is required — DELETE without confirm: true
returns 400 bad_request. On success:
- The state row is removed.
- The HMAC verifier is removed.
- A minimal tombstone is recorded (no academic content).
- Subsequent requests with the same token return
401 unauthorized, indistinguishable from any other unknown token.
There is no soft delete and no recovery path. (ADR 0019)
Errors
| Status | Code | Cause |
|---|---|---|
400 | missing_confirm | DELETE without { "confirm": true }. |
401 | unauthorized | Token did not match a stored verifier. |
401 | missing_token | Authorization header absent on a state endpoint. |
403 | token_in_query | Token supplied via query parameter (always rejected). |
409 | state_version_conflict | expected_state_version mismatch. |
422 | catalog_version_mismatch | Body’s catalog_version_id doesn’t match the persisted state’s. |
Concurrency
State mutations serialize through a WriteCoordinator queue
(ADR 0027). Under load:
- Reads run lock-free on SQLite WAL.
- Writes drain through a single in-process goroutine with a bounded queue (queue depth 64).
- Per-IP rate limits cap abuse; configurable via
UWSCRAPE_RATE_LIMIT_STATE_PER_IP_PER_MINUTEandUWSCRAPE_RATE_LIMIT_STATE_PER_IP_BURST.
Want more?
- Student state schema spec — full JSON shape.
- Backend state storage spec — schema, lifecycle, snapshots.
- Your plan — user-facing view.
- State and privacy — full posture.