Repository navigation
Setup Operations
Owner: Thuong. These procedures accompany issues #78–#82. Repository tooling is available; production restore history, a completed Neon drill and delivered alerts are not yet verified. Never substitute a local test for that evidence.
Run node scripts/verify-deployment.mjs --headers after deploying both halves.
It checks /healthz (including the database), the SPA login document, and browser
security headers. The same script without --headers is the availability probe.
The deploy workflow checks that the built _headers matches the source file.
The CSP allows the actual PKCE flow and private R2 media, including blob previews;
it does not load Google's GIS SDK. Test login, image display, audio playback and
upload previews on the deployed origin before closing #78. The headers
themselves were observed on 2026-10-04; see the last section.
Fly's own check calls /livez, which never touches the database, so Neon's
compute can suspend after five idle minutes. /healthz is the database-aware
probe: every call is a query that wakes the compute and keeps it billed for at
least five more minutes. Point only deliberate probes at it. At three probes an
hour, the monitor below would keep the compute running for about a quarter of
otherwise idle hours; choose its cadence with that cost in mind. Both routes
are rate-limited per client address (/livez 30/min, /healthz 10/min).
The proposed initial monitor is .github/workflows/production-monitor.yml:
three checks per hour, with a three-minute job limit. To activate it:
- Merge the workflow onto the repository's default branch.
- The owner enables GitHub Actions failure notifications for their account.
- Run
workflow_dispatchwithtest_alert=true. This fails only the workflow; it never stops the application. Record the recipient and receipt timestamp. - Set repository variable
PRODUCTION_MONITOR_ENABLED=trueand verify a scheduled success. Record its run URL and the alert test's run URL here.
This is a best-effort starting point: GitHub schedules can be delayed, and notification routing depends on the workflow actor/account settings. Upgrade to an external uptime service if prompt detection is required. No service account or billable subscription was created. Unexpected errors remain in structured Fly logs with request IDs; an external error collector is deferred until a service and data handling policy are chosen. Never send answers, tokens or integrity payloads to an error collector by default.
Fly health checks remove unhealthy Machines from routing; they do not themselves
restart a failing Machine. During an incident, inspect fly checks list and
request-ID logs, then explicitly choose rollback or restart.
References: GitHub scheduled-workflow limitations, notification recipients, Fly health checks.
Every request logs one http line at INFO: method, path, status,
duration_ms and request_id. A failure a handler returns also logs one
handler line at ERROR with the same request_id and the error, and answers
500 INTERNAL; the permission gate's and the API-reference gate's 500 log only
the http line. Count 5xx from status.
A streamed upload (media, a replacement, an import source, a profile photo) is given
a 110-second read deadline in place of the server's 30 seconds. If the API cannot set
it, the request logs a streaming_read_deadline line at WARN with the error and
continues under the 30 seconds: a writer wrapped around the server's lost
Unwrap. Any such line is a fault to fix.
A browser that aborts a fetch (TanStack Query cancelling on navigation, a closed
tab, a dropped connection) is not a fault. A request that fails because its own
client went away answers and logs status:499, nginx's name for it, with no body
and no ERROR line; a client_cancelled line at DEBUG, off in production, names
the error. A 499 is in no 5xx count. The same holds for the permission gate, the
API-reference gate and /healthz.
The request's own context must be cancelled, and the error either context canceled or a network timeout. The second is what pgx reports when the cancel
reaches it while it writes to Postgres: it expires the connection's deadline to
interrupt the write and returns write failed: write tcp ...: i/o timeout,
which does not name the cancel. The pool sets no write deadline of its own, so on
a request the client did not cancel the same line is a real fault.
The other case is a client that resets or closes the connection while the
response is being written (write: broken pipe, write: connection reset by peer, use of closed network connection). The write meets the lost peer before
the server has seen the close, so the context may still be live and the error
names no cancel. It counts when it is the error of the write to the response
itself, as the access log middleware recorded it: the same text from the database
connection is a fault. It answers as a cancel does, status:499 at INFO and the
client_cancelled line at DEBUG, though the connection may already carry the
first bytes of a 200; the access line reads 499, and the server writes no second
status to a socket that is gone.
What stays a server error: a context deadline exceeded (the pool's two-second
ping, an upload's two-minute budget), a context the server cancelled while the
client was still connected, an i/o timeout on a request nobody cancelled, a
write of the response that timed out, and any other failure, even on a request
the client also cancelled. The maintenance
gate and the rate limiters have no such path: the first reads its window under a
context the client cannot cancel, and the others decide from memory, a body they
cannot read only costing them its key.
The proposed restore-history target is seven days, subject to the owner's Neon plan and cost approval. The actual production window is unknown. Record the project, parent branch, PostgreSQL version and configured window from the Neon console before accepting #79. PITR history is separate from student-record retention. Neon can create an isolated child branch using past data only within its available history; do not restore over the production branch for a drill. See Neon branch creation and restoration.
- Choose a known checkpoint in the available history. Record the UTC timestamp, migration version, expected assignment/attempt IDs and expected scores using a restricted incident record, not a public issue.
- Create a separate recovery branch at that timestamp. Keep its credentials away from the production deployment and disable application writes/jobs.
- Set
RESTORE_DATABASE_URLto that branch's direct connection URL (PG18). Runnode scripts/verify-recovery.mjs > recovery.json. The script uses a read-only repeatable-read transaction and never prints credentials or names. - If an evidence file was captured at the same immutable checkpoint, run
node scripts/verify-recovery.mjs checkpoint.jsonto compare it. Digests cover the listed critical columns; they are a consistency signal, not a complete cryptographic backup verification. Independently inspect the expected records, score values, migration compatibility and one resumed/read-only student paper through an isolated API instance. - Check every object in the evidence's media manifest against private storage: object exists, downloaded bytes match the recorded SHA-256, and an unsigned request does not expose it. Database recovery cannot restore missing R2 objects. Keep a separate private object backup and rehearse restoring it.
- Record start/end time, branch ID, source timestamp, recovery evidence, application check, media check and operator. Remove the temporary branch after review so it does not accrue storage charges.
For a local logical-backup rehearsal, stop application writes, capture evidence,
use PG18 pg_dump --format=custom, restore into a new empty database owned by
quizzivy_migrate, and compare the evidence. Preserve roles/ACLs; never use
--clean against the source. This proves the local procedure, not Neon PITR.
A database-level privilege is restored only by pg_restore --create, which
this procedure does not use: after restoring into a new database, run
REVOKE TEMPORARY ON DATABASE <database> FROM PUBLIC; as its owner, or the
restored database holds goose's record of migration 00079 without its effect
(#194).
Local rehearsal on 2026-09-22: PG18 custom-format dump restored into a new
empty database, verifier matched the checkpoint before and after migration
00029 down/up, and the temporary database was removed. The non-application
zz_review scratch schema was excluded. See the
PR #98 verification for scope and counts.
This does not change the pending production evidence below.
Approved on 2026-09-22: integrity events are retained for thirteen calendar
months after assignment closure and at least thirteen months from server received_at; the audit log is retained. Disabled accounts
are never automatically erased. Retention uses UTC calendar arithmetic, deletes
only when both dates are strictly before the cutoff, and appends an audit entry for each nonempty batch.
Migration 00029_index_integrity_retention.sql adds the supporting index without
blocking normal writes. Application-role UPDATE/DELETE privileges on either log
remain forbidden.
Build server/cmd/maintenance and provide MAINTENANCE_DATABASE_URL only to that
manual process using an owner/maintenance credential. Never set it on the API.
The shape is maintenance <command> [flags], each command with its own flags:
maintenance retain-integrity -batch 1000
maintenance retain-integrity -batch 1000 -apply
maintenance anonymize-student -student STUDENT_UUID
maintenance anonymize-student -student STUDENT_UUID -applyThe older order, flags before the command (maintenance -apply -batch 1000 retain-integrity), is still accepted for these two commands. Every command takes
-timeout <duration> (default 1m); an unknown command or flag prints the usage
line and changes nothing.
Omitting -apply is a dry-run. Retention processes only one bounded batch per
invocation; repeat after review until it reports zero rows. Set a monthly
operator reminder after credentials and production authorization are arranged.
A student must have no active attempt before anonymization. The transaction
replaces email/name, removes password and Google identities, revokes refresh
credentials and disables the account. IDs, attempts and memberships survive;
existing access JWTs can remain valid until their configured ACCESS_TOKEN_TTL expires. Disable the account and allow that interval before a sensitive erasure operation; the operation is idempotent and records no original identity in its new audit
entry. It rejects admin accounts.
This is structured-identity anonymization, not a guarantee that all personal information disappears: answers, teacher notes, existing audit diffs, IPs and backups may retain identifying material. Review free text separately under an explicit request. The approved policy retains audit entries unchanged. Restrict access to backups and record how a restore will reapply later anonymizations before allowing the restored system to serve users.
A media file leaves a teacher's library when it is replaced or deleted, but its object stays
in storage until seven days after nothing names it: no question, group, material or published
test version, of any owner. Then the API removes the object for good and marks the row
(media_assets.purged_at). Nothing restores it, and the teacher is told only by the answer
to the replace or the delete and by the figures on the media page (F-68, the owner's decision
of 2026-10-12; docs/plan/20-data-model.md §45).
The API runs the sweep (jobs.SweepRetiredMedia) on every machine, only where media storage is
configured, at start-up and then every hour. A row is taken under a lock the other machines
skip, so a second machine removes nothing twice. It logs one line a run, with counts and no
title or filename:
| Log line | Level | Meaning |
|---|---|---|
media retention swept |
INFO |
unreferenced files dated, cancelled waits cleared, removed objects deleted |
media retention left files in place (MEDIA_RETENTION_INCOMPLETE) |
WARN |
failed files stayed for the next run, ten minutes later; err is the first cause, an object key at most |
media retention sweep failed (MEDIA_RETENTION_FAILED) |
WARN | the run could not read or list the files; it retries in ten minutes |
media object removed but its row was not marked (MEDIA_RETENTION_UNMARKED) |
ERROR | the object of asset_id is gone and the database refused the mark, for the ten seconds the finish is given after the delete. The next run marks the row, because deleting a missing object succeeds. If the line repeats for one id, writes to app.media_assets are failing |
A run that keeps failing means storage is unreachable or refuses the delete: nothing is lost,
the objects stay and the rows are retried. Each removal writes an audit row media.purged, with
no actor. After the first start of the release that added the sweep, a file that left a library
earlier is dated by that run and its object goes seven days later; the first removals are
therefore seven days after the deploy. A file stays while a question, a group or a version names it,
and naming it again before its day clears the wait.
The switch. MEDIA_RETENTION_SWEEP is on (the default) or off, nothing else, and is read
when the API starts. Off means that machine never runs the sweep and logs media retention sweep is off at WARN once; quota counting and the dating that replace and delete do go on, so files pile up
as pending and the usage figures still say so. Use it in an incident, to stop removals while
something is investigated, and turn it back on afterwards:
fly secrets set MEDIA_RETENTION_SWEEP=off -a quizzivy-api # restarts the machines
fly secrets unset MEDIA_RETENTION_SWEEP -a quizzivy-api # back to onKeep it out of fly.toml, so that a secret is the only place it is set. A value other than on
or off stops the API at start-up, naming the setting.
The sweep removes objects for good, and the migrations 00111 to 00113 stay on a database whose API goes back. If the API must go back to v0.10.0, the release before F-68, after the first removal:
- v0.10.0 does not know
purged_at. It still treats a purged file as a replaced or deleted one, so it signs a URL to an object that is gone wherever it signs one for that row, and a bind of that file (a stale media picker, an import review that names a replaced file) passes its checks and reaches the database, where the trigger of 00113 refuses it. v0.10.0 does not map that refusal, so the teacher gets a 500. - Nothing is removed further, because v0.10.0 has no sweep; and nothing restores what was removed.
- Before rolling back, turn the switch off and count the damage:
SELECT count(*) FROM app.media_assets WHERE purged_at IS NOT NULL. A teacher whose import review names a purged file re-attaches it from the library; a file a version holds is never purged. - Run no Down migration: v0.10.0 starts on this schema, and Down would drop the trigger that keeps a writer from naming a removed file.
Roll forward when you can.
Never delete an object by hand to free space: a file the library still lists, or that a version
holds, would answer 404 to the students who sit that test. The usage on the media page
(heldBytes, pendingBytes) says what is counted and what waits.
A maintenance window is a period during which the API answers every request to a
path one of its routes serves, under any method, with 503 MAINTENANCE, except
GET/HEAD /livez, /healthz and /public/status, so a migration or a provider
change can run without users working against it. A path that no route serves gets
its 404 without the gate asking about a window, so a scanner probing for /.env
never makes the API read the database.
Plan one for anything that could fail half-way under live traffic. A window lasts at
most 12 hours, starts no earlier than a minute ago, and never overlaps another.
maintenance window-schedule -start 2026-10-01T15:00:00Z -end 2026-10-01T16:00:00Z
maintenance window-schedule -start 2026-10-01T15:00:00Z -end 2026-10-01T16:00:00Z -apply
maintenance window-list
maintenance window-cancel -window WINDOW_UUID -apply
maintenance window-end -window WINDOW_UUID -applyScheduling is a dry run until -apply. The dry run reports how many attempts,
assignments and student overrides would move (attempts, assignments and
overrides in its JSON); read it before applying.
What moves when a window is scheduled, in the same transaction, audited as
System (no actor, reason: "maintenance", the window id and the database role):
- every attempt still in progress whose deadline is after the window's start
gets the window's length added to its deadline (
attempt.extended); - every open assignment that would close inside the window closes that much
later (
assignment.extended); - every per-student override of a published assignment whose
closes_atfalls inside the window closes that much later (assignment.override_extended, one audit row per override, naming the student). An assignment closed early is included, because the override is what keeps its student in.
An override with no closes_at (a longer time limit or more attempts only) has
no close to move, and the overrides of an assignment that was never published
are left alone.
window-schedule, window-cancel and window-end take the advisory lock
(73819, 40) exclusively. A student's start, a teacher's extension, update or
reopening of an assignment and the two override writes take it shared, so none
of them runs between a window's scheduling and the extensions it makes
(docs/plan/20-data-model.md §28).
The override extension updates app.assignment_student_overrides.closes_at, so
the database role behind MAINTENANCE_DATABASE_URL needs SELECT on the table
and UPDATE (closes_at) on that column. R5's grant_maintenance_role migration
grants both to quizzivy_maintenance; until it lands, run cmd/maintenance with
the owner or maintenance credential this document already asks for. A role
without the grant fails the apply with a permission error, and the transaction
writes nothing.
window-cancel cancels a window that has not ended; window-end ends an active
window now. Both keep the extensions already granted: a deadline or an override
close that moved does not move back. window-list prints the upcoming and
active windows.
A window takes effect within 30 seconds of being written, which is how long the API may serve its cached answer to "is there a window?". Schedule the start at least a minute ahead of the work. The import worker is not gated: it keeps processing the imports it already holds (O-R1.1). If a window is needed for work that must not race imports, stop the worker's process group for its length.
JOIN_CODE_KEY seals every join code issued from v0.8.0 on and keys the hash
the API finds it by (D5). It is standard base64 of exactly 32 random bytes.
The API refuses to start without it, and never logs it.
-
Generate it with
openssl rand -base64 32. -
Store it as a Fly secret (
fly secrets set JOIN_CODE_KEY=...), with an offline copy in the owner's password manager. The offline copy is the only way back from a lost Fly secret. - Never paste the key into a log, a ticket, a chat or a workflow. No GitHub workflow environment holds it.
The rekey runs from the operator's machine, never from a scheduled workflow.
The operator supplies JOIN_CODE_KEY and JOIN_CODE_KEY_PREVIOUS to
cmd/maintenance from the offline copies, with MAINTENANCE_DATABASE_URL.
- Stage both keys in one command, so they reach the machines in the
same release:
Never set them one at a time. Without
fly secrets set --stage JOIN_CODE_KEY_PREVIOUS=<old key> JOIN_CODE_KEY=<new key>
--stageeveryfly secrets setrestarts the machines, and a half-changed pair either stops the API from starting (the two keys equal) or makes every code under the old key unredeemable until the second command lands. - Deploy. The API now issues codes under the new key and still finds and
reads codes under the old one. Its startup log line
join code keysnamescurrent_key_idandprevious_key_id. - Run the dry run and read its report:
maintenance rekey-join-codes
-
currentKeyIdandpreviousKeyIdmust equal the ids in the API's log line. If they do not, the keys on the operator's machine are not the pair the API runs with: stop. -
unknownmust be 0: it counts active codes sealed under a key that is neither of the two.-applyrefuses while it is not. -
pendingis the number of codes that will move. -
unopenedlists the codes under the old key whose ciphertext does not open, by row and class id, never by code. -
legacycounts every code issued before v0.8.0, revoked or expired ones included. They hold no ciphertext and cannot be re-keyed. From v0.10.0 the API replaces the live ones at start-up (see "Legacy join codes" below), so what remains here is revoked or expired, and has nothing to re-key.
-
- Apply it. It walks the codes in batches of 500 (
-batch), one transaction each, and writes onlycode_hash,code_ciphertextandkey_id. Joins to the codes in a batch wait until that batch commits, so run it at a quiet time:A code that does not open is listed undermaintenance rekey-join-codes -apply
unopenedand left where it is; the others move. For each listed code withrevoked: false, rotate that class's code (from the class page, or byPOST /teacher/classes/{id}/join-code): once the old key is unset, it can neither be read nor redeemed. A revoked one needs nothing. - Run the dry run again and verify:
pendingis 0,unknownis 0, and everyunopenedcode showsrevoked: true. - Stage the removal of the old key:
fly secrets unset --stage JOIN_CODE_KEY_PREVIOUS - Deploy.
The report is JSON, like every other maintenance command's.
Every code sealed under it becomes unreadable and unredeemable: its class's
code reads back as code: null, legacy: false, and joining with it answers
JOIN_CODE_INVALID. Generate a new key, deploy it, rotate every class's code,
and tell the teachers to share the new codes.
A code issued before v0.8.0 is held only as its SHA-256 (lookup scheme 1): it
redeems, but cannot be read back. jobs.RotateLegacyJoinCodes replaces each live
one, once, when the API starts. No operator command is involved; Serve starts
the job beside the prunes, and the worker process does not run it.
For every class whose active code is scheme 1, not revoked and not yet expired,
one transaction revokes that code and issues a sealed one (scheme 2, under the
current JOIN_CODE_KEY). The new code keeps the old one's expiry, use cap (a
stored NULL stays NULL), use count and creator, and leaves the class's
self-join switch as it was. A class whose legacy code had reached its cap
therefore gets a code that is used up: its teacher issues a new one to admit
more students (F-67). The old code then answers as any replaced code does:
"revoked" where self-join is open, and as an unknown code where self-join is
closed or the class is archived. Archived classes are rotated too. Each rotation
is audited as class.join_code_rotated with no actor (the System), the diff
{reason: "legacy_rotation", previousHint, hint}, never a code.
-
Concurrency. Each class is its own transaction and takes
pg_advisory_xact_lock(73819, 41), then the class row (FOR NO KEY UPDATE), then re-reads the class's active code. Two machines starting together therefore rotate a class once, and a second run finds nothing to do. A class whose transaction lost a deadlock or serialization conflict is tried once more after the other classes (docs/plan/20-data-model.md§28 and §33). - Budget. Two minutes for the whole run. It never runs on a timer. A class it did not reach, or that failed, waits for the next start-up: restart the app machine or deploy again.
-
Teachers. Each affected teacher gets one in-app notification,
join_codes.rotated, with how many of their classes were rotated and the first five names; a later run adds its count to the same unread row. A failed notification undoes nothing, and a process that stops between a teacher's last rotation and their notification leaves them untold (the next run finds nothing to rotate), so the release note tells the teachers as well. -
Logs (
slogJSON; none carries a code or a hint, only class and user ids):legacy_join_codes_found {count}before the first class when there is one;legacy_join_codes_rotated {found, rotated, failed, teachers, notify_failed}after the last;legacy_join_codes_problem {err}at Warn for each class left unrotated and each teacher left untold;legacy_join_codes_failed {err}when the listing itself failed. A run that finds nothing logs nothing.
Release check: run this with any credential that can read app.class_join_codes,
before and after the deploy. The count before is the number of classes the run will
rotate (record it for the release note); the count after must be 0, or the classes
that legacy_join_codes_rotated reports as failed are waiting for the next
start-up:
SELECT count(*) FROM app.class_join_codes
WHERE revoked_at IS NULL AND lookup_scheme = 1 AND expires_at > now();The lookup branch that still finds a scheme-1 row (so a revoked or expired legacy code answers as revoked or expired, not as unknown) stays until R5 removes it.
The contract's email format used to accept "qc tp"@example.com: the request
passed, the generated type stored the unquoted qc tp@example.com, and the same
type could not print that value back, so the write committed and answered 500
and every list holding the row answered 500 afterwards. The email format now
accepts an address only if the bare address it yields parses to itself
(validation.ParseEmail), so the row can no longer be created. Nothing in the
release rewrites a stored value.
Release check: run this read-only query with any credential that can read
app.users, before and after the deploy. It lists accounts whose local part (the
text before the last @) holds an ASCII control character, a space, any of
"(),:;<>@[\], DEL, or that begins or ends with a dot or holds two in a row.
Nothing non-ASCII is refused by Go's address parser, so it has no Unicode clause.
It finds every row the old API could have written. It reads only the local part,
so a row inserted by hand with a defect in its domain, or with an empty local part,
is not listed; the API cannot create one. stored_email_test.go runs this text
against mail.ParseAddress:
SELECT id, email
FROM app.users
WHERE regexp_replace(email, '@[^@]*$', '') ~ '[\x01-\x20"(),:;<>@\[\\\]\x7f]|^\.|\.$|\.\.'
ORDER BY created_at, id;No rows is the expected answer. If it returns rows, nothing in the release repairs them; the repair is per row and the owner's.
-
A student. An Admin, or the teacher who owns the student, corrects the address
with
PATCH /teacher/students/{id}. A teacher who only shares the student gets403 STUDENT_SHARED. -
A staff account (a teacher or an Admin).
PATCH /teacher/students/{id}answers 404 for it, and the API has no operation that disables an account, so the repair is one statement run by the owner with a credential that may writeapp.users:UPDATE app.users SET email = '<corrected address>' WHERE id = '<id from the query>';Write the id and the change in the release record.
Until a row is repaired, these keep answering 500 INTERNAL: GET /teacher/students
for a page holding the row (also with a q that matches it) and GET /teacher/students/{id}, POST /teacher/students/reset-passwords, GET /teacher/classes/{id}/members for a class holding the student, GET /teacher/attempts/{id} for that student's attempt, and, for the account's own
session, GET /auth/me and PATCH /auth/me. The account cannot sign in again: the
quoted form is refused, and the stored form fails the sign-in request's own check
with 400. A session it already holds keeps refreshing.
The AI assistant (D19, docs/plan/74d-d4-deck-update.md section G) calls
Anthropic's Messages API through platform/anthropic. It is off unless
ANTHROPIC_API_KEY is set: with the key unset nothing else below is read, the
assistant's adapter is nil, its operations answer 501 and the web draws no AI
entry point. The API never logs the key, and no error, log line or panic the
client produces carries it.
With the key set, both tiers and all six prices are required, and the API refuses to start, naming the variable, when one is missing or malformed:
| Variable | Holds |
|---|---|
ANTHROPIC_API_KEY |
the provider's API key |
AI_TIER_FAST_MODEL, AI_TIER_CAPABLE_MODEL
|
the provider's model identifier for each tier; the capable tier must accept images and PDF pages |
AI_TIER_FAST_PRICE_INPUT, AI_TIER_FAST_PRICE_OUTPUT, AI_TIER_FAST_PRICE_CACHE_READ
|
the fast tier's list price in US dollars per million tokens, such as 0.10
|
AI_TIER_CAPABLE_PRICE_INPUT, AI_TIER_CAPABLE_PRICE_OUTPUT, AI_TIER_CAPABLE_PRICE_CACHE_READ
|
the capable tier's |
A price is a plain decimal with at most six decimals between 0.001 and 1000. A price per token or per thousand tokens would make a credit (0.001 US dollars) undercount the pool, so it is refused. No model identifier is written in the repository: take both from the provider's current model list.
-
Set them as Fly secrets, all nine in one command so they reach the
machines in the same release, and then deploy:
The startup log line
fly secrets set --stage ANTHROPIC_API_KEY=<key> \ AI_TIER_FAST_MODEL=<model> AI_TIER_CAPABLE_MODEL=<model> \ AI_TIER_FAST_PRICE_INPUT=<usd> AI_TIER_FAST_PRICE_OUTPUT=<usd> AI_TIER_FAST_PRICE_CACHE_READ=<usd> \ AI_TIER_CAPABLE_PRICE_INPUT=<usd> AI_TIER_CAPABLE_PRICE_OUTPUT=<usd> AI_TIER_CAPABLE_PRICE_CACHE_READ=<usd>
ai assistant enablednames both models;ai assistant disabledmeans no key was read. -
Rotate the key by creating the new one in the provider console, running
fly secrets set ANTHROPIC_API_KEY=<new key>(which restarts the machines on it) and revoking the old one once the log line is back. A call in flight during the restart can fail once. -
The import worker reads the same nine variables when it starts: Fly secrets
reach every process group, so nothing more is set. It meters the "Read with AI"
of scanned PDFs (
docs/setup/word-import-worker.md) through the same credits. With the key unset or misconfigured the worker refuses a scan asPDF_NO_TEXTor refuses to start, as the API does. - Change a model or a price by setting its secret and deploying. Prices and models are read once, at start-up; a call is metered at the prices the process started with, so re-read them whenever the provider's list price changes.
-
Never paste the key into a log, a ticket, a chat or a workflow. No
GitHub workflow environment holds it, and it never appears in a test
fixture: a task that calls the provider for real does so on the builder's
machine, with the key in
.env. -
Before the key is set in production, the provider account's data
retention setting is the owner's to confirm
(
docs/plan/70-redesign-overview.md§10).
The client sends a 429 or a 5xx again up to three times with a doubling
back-off that honours Retry-After, never sends a 4xx again, and gives a call
60 seconds (120 with a PDF). A 401, 402 or 403 from the provider is the key or
the account, not a reason to retry: read the provider console.
The AI assistant is metered in credits (D21): one credit is $0.001 of the provider's list price,
so the $200 pool is 200,000 credits a month. Each teacher has an allowance under it (20,000 by
default), a call reserves its ceiling before it reaches the provider and settles what the provider
metered after, and the database refuses a reservation that would overspend either the pool or the
allowance (docs/plan/20-data-model.md §46). A month is the calendar month in APP_TIMEZONE
(Asia/Ho_Chi_Minh when unset), and the pool, the allowances and the used credits start over on
the 1st. Credits an Admin adds do not carry over; a teacher's custom allowance and pause do.
Until the Admin's AI credits page exists (R5, T-R5.35) the console is cmd/maintenance, the
operator's tool, run with MAINTENANCE_DATABASE_URL as the other commands are. Every write names
who is running it and is a dry run until -apply; a dry run runs the same code as the Admin
operation inside a transaction it rolls back, so it reports exactly what would happen, writes
nothing and tells nobody. The report is JSON.
maintenance ai-credits status
maintenance ai-credits set-allowance -user USER_UUID -credits 50000 -operator thuong
maintenance ai-credits set-allowance -user USER_UUID -default -operator thuong -apply
maintenance ai-credits pause -user USER_UUID -operator thuong -apply
maintenance ai-credits resume -user USER_UUID -operator thuong -apply
maintenance ai-credits add-credits -credits 50000 -operator thuong -apply
maintenance ai-credits approve -request REQUEST_UUID -operator thuong -apply
maintenance ai-credits decline -request REQUEST_UUID -operator thuong -apply
maintenance ai-credits set-rules -default-allowance 50000 -warn-percent 90 -on-limit pause -operator thuong
maintenance ai-reservations-sweep-
statusreads the current month: the pool (plan, added, used, held, left), the days to go and what the month is on course to use (onPace, credits used divided by the days elapsed times the days in the month) with whether thatwithinPool, the teachers with their allowance, use and standing (on_track,near_limitfrom the warning share,at_limit,paused), the requests waiting, and the rules. It lists the Admin and every user who holdsai.use, and anyone who has used AI this month. -
set-allowancetakes-credits N(1,000 to 10,000,000), the teacher's own monthly figure, which a change of the default leaves alone, or-default. Credits an approved request added this month stay on top of either. It takes effect at once and the use so far still counts, so a figure below what the teacher has used is refused.pauseandresumestop and restore a teacher; the buttons stay visible and say why. -
add-creditsis the Admin's approval to spend beyond the plan: the pool is never overspent without it. The audit row names who approved. The product bills nobody; the extra is paid for outside Quizzivy (DG-196). -
approveraises the teacher's allowance for the request's month by the amount anddeclinechanges nothing but the status; both tell the teacher. An earlier month's request can only be declined. -
set-rulestakes any of-default-allowance(rewrites this month's allowances that are not custom, never below what a teacher has used),-warn-percent(50 to 99) and-on-limit(pause, orrequestto let a teacher ask for more). -
ai-reservations-sweepreturns the credits of calls that never finished, which a process that died mid-call leaves held. The API does the same hourly when the provider is configured (ReleaseStaleAiReservations), so the command is for reading the count first or releasing at once. A reservation older than ten minutes is stale; a call lasts at most two minutes.
Every write is one audit_log row (ai.allowance.updated, ai.credits.added,
ai.rules.updated, ai.credit_request.approved, ai.credit_request.declined) with no actor,
reason: "maintenance" and the operator in details. The ledger (app.ai_usage_ledger) has a row
for every call the provider metered, with its token counts and the provider's request id; to
reconcile a month with the provider's invoice, sum its credits and compare the tokens. Neither the
ledger nor audit_log can be rewritten by the application role.
The credits tables exist whether or not ANTHROPIC_API_KEY is set, so allowances can be arranged
before the key is. Until R5's grant_maintenance_role (T-R5.17a) runs, use the owner or
maintenance credential; that migration must grant the role what these commands write
(docs/plan/20-data-model.md §46, "Privileges").
None of these lines carries the key, a prompt or a student's words; each names the feature and,
where the provider answered, its request_id, which is what the provider's console searches by.
| Line | Level | Means |
|---|---|---|
ai assistant enabled / ai assistant disabled (no ANTHROPIC_API_KEY)
|
INFO | Start-up. The first names both models. |
anthropic_retry |
WARN | The provider answered 429 or 5xx, or a stream ended overloaded; the client waits and tries again, three times at most. |
ai call failed |
WARN | After the retries, or on a refusal or an answer that is not one complete JSON object. The teacher saw 503 AI_UNAVAILABLE; what the provider billed was settled and the rest released. |
ai answer unusable |
WARN | The provider answered in the schema, but the operation's checks left nothing to return. error says why, with counts and never the answer's text. The teacher saw the same 503, and the call was billed. |
ai call rejected |
ERROR | The provider refused the request itself, which is the key, the account or a model name. Read the provider console. The teacher saw the same 503. |
ai call failed unexpectedly |
ERROR | An error that is none of the above. |
ai clean-up |
INFO | One bank clean-up: how many calls it made and how many gave a usable answer, whether the 45-second budget ran out, and every request_id of the pass. The ledger gets one row for the pass and keeps only the ids that fit 200 characters, so this line is where all of them are. |
ai clean-up: an answer had nothing usable |
WARN | One call of a clean-up answered something no decoder could use. It was billed and settled; the other calls' findings were returned with truncated set. |
ai clean-up: the smallest call can cost more than the reservation |
ERROR | At the configured prices not even one call of the clean-up fits its 200-credit ceiling, so none was made and nothing was reserved: the fast tier's prices are wrong. The teacher saw 503 AI_UNAVAILABLE. |
ai reservation not ended |
ERROR |
Finish could not settle or release a call. The sweep returns the credits once the reservation is ten minutes old. |
ai call cost more than its ceiling |
WARN | The provider metered more than the feature reserves. The teacher was charged the ceiling and the ledger keeps the real token counts. |
Live smoke against the provider. Before a release or a hotfix that touches the AI, run one real call of each kind of feature with a key that is not production's:
QUIZZIVY_AI_TEST_KEY=… go test -tags aismoke ./internal/core/adapters/tests/ -run TestLive -v
It sends Generate, an explanation, wrong options, a score and a tagging (fast tier) with the models
in QUIZZIVY_AI_TEST_CAPABLE_MODEL and QUIZZIVY_AI_TEST_FAST_MODEL (default claude-sonnet-5-5
and claude-haiku-5-5), and fails unless every answer passes its decoder and Generate keeps all
five questions. It costs a few cents. Without the key it fails rather than skips, and CI never
builds the aismoke tag.
| released stale ai reservations / ai reservation sweep failed | INFO / WARN | The hourly sweep and its failure, which the next hour retries. |
| import read with ai / ai import settled | INFO | The worker's record of a scan it read: pages, pages it could not read, credits held and charged. |
A deploy while a bank clean-up runs (the server waits 15 seconds to shut down, and a pass can take
45) cuts the request before Finish, so the 200 credits it reserved stay held, with no line to say so.
The sweep returns them once the reservation is ten minutes old, at start-up and hourly, so for at most
an hour and ten minutes; nothing is charged, since nothing was settled.
The live smoke needs the owner's key and is not in CI. On the builder's machine, with the nine
variables in .env (.env.example lists them) and the API running against a database that holds
the seed: generate three questions from a test's passage, check the seeded test, suggest a score
for a seeded short answer that waits for a mark, run "Clean up" on a bank of a few hundred questions (the ledger gets one row for it, and its request_id lists the calls), and, for T-D4.33, create an import with "Read with
AI" on from a two-page scan of the corpus and let the worker read it. Then read the ledger back:
maintenance ai-credits status prints the pool's used credits and the teacher's use, and the rows
are select feature, tier, credits, request_id from app.ai_usage_ledger order by created_at desc
run as the owner. The pool's used credits must have moved by the sum of the rows. Paste the
credits and the ledger rows into the pull request, never the key and never a response, which may
hold a student's words.
- Neon project/branch and observed history window: pending access.
- PITR drill, application checks and private media restoration: pending access.
- Monitor recipient and delivered test notification: pending owner setup.
PRODUCTION_MONITOR_ENABLEDis not set (checked 2026-10-04), so the monitor has never run on its schedule (#81). - Response headers: observed on 2026-10-04 with
curl -sI.https://app.quizzivy.com/sends HSTS, the CSP,nosniff,Referrer-PolicyandPermissions-Policyexactly asweb/public/_headersstates them.https://api.quizzivy.com/livezsends HSTS,nosniff,Referrer-Policy: no-referrerandContent-Security-Policy: frame-ancestors 'none'; base-uri 'none'; object-src 'none'. - Sign-in, image display, audio playback and upload preview under that CSP: pending the owner's check on the deployed site (#78).
-
/boot.jsis served withcache-control: max-age=14400(observed 2026-09-27 and 2026-10-04) although_headersasks forno-cache: the zone's Browser Cache TTL overrides it.https://quizzivy-web.pages.dev/boot.jssendsno-cache(2026-10-04), so the header is rewritten in the zone, not by Pages. A changedboot.jscan reach a browser up to four hours late. Owner: Cloudflare dashboard, Caching, Configuration, Browser Cache TTL, "Respect Existing Headers"; thencurl -sI https://app.quizzivy.com/boot.jsmust showno-cache(#191). -
TEMPORARYon the production database (#194): to be checked after the release that carries the migration revoking it fromPUBLIC.SELECT has_database_privilege('quizzivy_app', current_database(), 'TEMPORARY');must answer false. If it answers true, the migration role does not own the database there, and its owner runsREVOKE TEMPORARY ON DATABASE <database> FROM PUBLIC;once; the query must then answer false. Pending. - Maintenance dry-run review and first authorized production batch: pending.
Generated from docs/setup/operations.md. Edit that file in the repository; changes made on this page are overwritten.
Generated from the repository's documentation on every push to develop.
Start here
Product
Implementation plan
- 00 Architecture overview
- 10 Phase 0: Scaffold and contract
- 11 Phase 1: Auth, join, shells
- 12 Phase 2: Admin authoring
- 13 Phase 3: Assign and take
- 14 Phase 4: Grade and results
- 15 Phase 5: Hardening
- 16 Phase 4.5: The student at a desk
- 17 Word import: milestone plan
- 18 Word import: teacher workflow
- 19 Versioned content contract
- 20 Data model
- 30 Risk register
- 40 Open items
- 50 Non-functional requirements
- 60 Backend architecture
- 91 September 2026 issue remediation
- 92 Student layouts and interaction rules
Redesign (Phase R)
- 70 Redesign overview
- 71 R1: Foundations (v0.7.0)
- 72 R2: Access (v0.8.0)
- 73 R3: Student console (v0.9.0)
- 74 R4: Teacher workspace (v0.10.0)
- 74d D4: Deck update (v0.11.0)
- 74e E1: Moving a question across a group
- 75 R5: Admin console and email (v0.12.0)
- 76 R6: Question types and scoring (v0.13.0)
- 77 R7: Collaboration (v0.14.0)
- 78 R8: Schedule (v0.15.0)
- 79 R9: Insights (v0.16.0)
- 80 R10: Learn (v0.17.0)
- 81 R11: Landing, leads, v1.0 (v1.0.0)
Setup and operations
- CI
- Deploying
- DNS for quizzivy.com
- Google OAuth client
- Production operations
- Cloudflare R2 bucket
- Word import worker
- R4 rehearsal on a Neon branch
Design
Team
- Agent team
- Verification environment
- Team ledger
- R4 readiness
- R4 verification strategy
- T-R4.51 browser verification
Code READMEs