571 categories, 21 uploaded documents, 14 articles, 8 card decks, 30
shared tests and 2 questions carried somebody's name — mostly
daniel@danvics.com, which is not even the working administrator any
more. So "who may edit this" partly depended on who happened to create
it, and handing the site to somebody else would have meant rewriting
every one of those rows.
Migration q6a7b8c9d0e1 empties those owner columns and makes them
nullable, because ownerless is now a legitimate state and a NOT NULL
owner is exactly what forced a name onto every row. Nothing is deleted
and nothing moves. What keeps its owner, deliberately: attempts, notes,
favourites, collections, folders, study-plan progress, and the quizzes
that are somebody's own sittings rather than shared bank tests.
study_plans needed nothing — it never had an owner column.
Then the code, so it cannot grow back. Authorship is no longer a way in
anywhere: may_edit_question and can_edit_article ask the role and the
grants and nothing else; the article draft, status and delete paths lost
their "or you wrote it" arm; decks are the bank's, so an educator
reaches any of them and a learner reaches the shared ones; documents are
the corpus, so they are editors-only rather than "mine"; and every
creation path writes user_id NULL. The bank listing's "mine" facet went
with it — it counted nothing and could only ever count nothing.
Verified against production as a real learner account: every bank write
403s, admin settings 403, documents empty. As an admin, everything
opens.
Also: a category grant no longer offers Editorial in the menu. It offers
Questions and Images, which is what a grant covers; Editorial is the
whole library's review queue and its route is moderator-only, so the
entry was a door that answered "Not yours to open".
Six tests changed rather than deleted — they asserted the old model, and
each now asserts the new one: writing an article does not make it yours,
writing a question does not make it yours, an answer image is not opened
by authorship, the tutor is not opened by authorship.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Asked for: no tutor on a question outside a session. The tutor is handed the
correct answer and told it may explain it, so it is answer-side content — and
once that rule is written down, the same rule catches two bigger holes:
- `GET /questions/bank` returned `correct_answer`, `explanation`,
`option_explanations`, `key_points` and `attending_tip` for every question in
the bank, to any signed-in learner. It is the question manager's listing, but
nothing stopped anyone calling it: the whole answer key, one request away
from the questions it answers. Stems are still listed to everyone; the answer
side now goes only to whoever writes that question.
- The explanation image behind a question was readable by the same rule, with
no attempt behind it.
"Whoever writes it" is one function now — `may_edit_question` — and it means
moderation, authorship, or an editorial grant that reaches where the question
is filed. Everyone else earns the answer by sitting the question, which is what
an attempt is. The bank browse, the search, the session and the review are all
unchanged; the frontend already sends `attempt_id` everywhere it shows an
answer.
The question manager was reachable by a learner with no grant, and would now
load as a bank of stems with every answer field blanked — a broken page rather
than a door that is not theirs. It says so instead.
Also, while looking at where cards surface: the answer review showed neither
the topic reading nor the cards written against a question, though the player
has shown both under the answer for a while — and the review is the one place
a learner goes through everything they got wrong. The list form of that
component fetched its cards and then dropped them on the floor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
There will be no courses. What was there: one draft called "jk" with two empty
lessons, and 4,000 lines of code around it — courses, modules, lessons,
enrolments, per-lesson progress, SCORM, BigBlueButton, completion certificates,
three React pages, a router, two models.
Its real cost was everywhere else. Every query that measured practice had to
remember `Quiz.course_id.is_(None)`, and forgetting it in one place would have
silently mixed course attempts into a learner's analytics; the bank predicate
carried a subquery to exclude a course's own questions from every search,
recommendation and share; quiz access had a second, parallel rule about
enrolment. All of that is gone, so the remaining rules say what they mean.
`quizzes.allow_review` goes with it. It was only ever enforced for a course
quiz, so it had become a promise nothing keeps — the public session page was
still offering "no answer review" about sessions that review fine.
The fixtures' question 5 lived in a course quiz and stood for "a question that
exists but is not in your bank". There is no such thing now — a question is in
the bank unless it is deleted — so the counts it kept out of the numbers are
back in, and the tests that turned on it now turn on deletion or on the
attempt that actually holds a question.
Files the LMS uploaded stay on disk and stay protected: LEGACY_LMS_PREFIXES in
app/utils/upload_access.py is what keeps them unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
`GET /api/contact/submissions` had no authentication. `require_admin` was
imported inside the function body and never used as a dependency, so the import
read as protection and was none: anyone who guessed the path could read every
sender's name, email address and message. `PUT .../read` was open the same way.
Both now depend on `require_admin`, with a test that a learner gets 403 and an
administrator gets the list. A row with a null timestamp no longer takes the
whole listing down with it — which is the only reason the hole showed up as a
500 rather than as data.
Also: the tutor's site switch lives in Redis, which the tests share with the
running site, so turning the tutor off in the interface turned a test red. The
test now sets the flag it depends on and puts it back.
And the tutor button is hidden until the server says it is allowed, rather than
shown and then withdrawn — on a site with it switched off that flicker reads as
a bug rather than a policy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
**Caching.** `/uploads` answered `private, no-store` for everything, so a page
of forty thumbnails re-fetched forty images every time it was drawn. A
derivative may now be kept for a week by the browser that asked for it —
`private`, never a shared cache, because a shared cache in front of
access-controlled images is how one learner is served another's figure. It is
safe to keep because it cannot change: `thumbs/256/<key>` is made once from an
immutable original. Originals still say no-store.
**The embedding model is env-only.** Every vector in the database came from it,
and vectors from different models are not comparable — change it and search
returns noise until 3,000 questions, 334 articles and every card have been
re-embedded. The settings page now shows it as text with Test and Regenerate
beside it, and the API refuses a change rather than ignoring one, naming
`LITELLM_EMBEDDING_MODEL` in the refusal.
**The figure audit retries and gives up.** Its second run met a proxy outage and
reported all 327 figures unreadable, having changed nothing but spent the time.
Three tries each with backoff now, and it aborts after twelve consecutive
failures: a run that says "everything is unreadable" has told you nothing.
**`.env.example` is complete.** It listed 23 of the 53 settings; it now lists
all of them, grouped, each with the default it falls back to and — where it
matters — what happens if it is wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Three things landed together; the message names all of them, because a commit
that mentions one is a commit nobody finds the other two in.
**Figures.** Thirty-four JPEG 2000 files — 21 on questions, the rest unattached
in the media library — are WebP now, with `questions.image_path`,
`questions.explanation_image_path` and `media_assets.path` repointed together.
Serving already converted them on the way out, so nothing was broken; this
removes the step and makes what is stored the same thing that is served. The
originals stay: they are the only copy of what came out of the PDF, they cost a
few megabytes between them, and a conversion nobody can undo is not one to run
against a live bank. Paths are found by what the columns say rather than by
listing a bucket, because three tables record them and updating two would be
worse than none.
**The openai SDK is gone.** Ten call sites — one more than the map said, the
Celery article drafter — every one of them a POST with a JSON body, and not one
reading usage, cost, tool calls or logprobs. Every other call to the same proxy
was already plain httpx: embeddings, the ChromaDB embedding function, speech
both ways, model discovery, the vision probe. So this deletes an abstraction
rather than swapping one for another, and leaves one HTTP client instead of
two. `chat()` and `achat()` return the message content; a `ProxyError` carries
the status and the first 500 characters of the body, which is where the proxy
explains itself.
Behaviour is preserved deliberately, including a 600-second fallback timeout
for the four call sites that were running on the SDK's ten-minute default.
Lowering that is a real change and belongs in its own commit.
Proved against the live proxy on both services rather than only against mocks:
a completion, an async completion, a real 400 the vision probe still classifies
as a refusal, 407 models read from the catalogue, and a word read off an image.
**Voice.** A chosen voice is honoured whatever serves it. The prefix check only
accepted a locally served one, so a site adding a hosted voice would offer it
in Settings, save the learner's choice, and then quietly read every question in
the default voice. The list has always come from the database — adding a voice
is a row in Settings → AI models, never a code change.
And the sign-in page stops offering a locked door: `signup-policy` reports
whether registration is open at all, and the Sign up link goes when it is not.
The switch existed and the only way to discover it was to fill the form in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
**Prepared sessions.** Most of this existed: unanswered first, weakest topic
next, wrong-before-right after that, all scaled by what share of the real paper
each topic carries. What it could not do was change with time, say anything
about itself, or be reached without filling in a form.
Evidence now decays on a thirty-day half-life. Exponential rather than a fixed
window because memory has a slope, not a cliff — under a window, 29 days counts
fully and 31 counts for nothing — and because it is memoryless, so an answer's
weight does not shift when unrelated questions are answered, which is what lets
the preview stay a valid forecast. Spring is worth an eighth of last week. Two
things decay: a question's recall probability, drifting towards even rather
than past it, so an old right answer becomes eligible rather than wrong; and a
topic's accuracy, against a prior of two "no idea" answers, which fixes "right
once, known forever".
Strict unanswered-first meant that on a bank of 2,900 nothing was ever
recycled — spaced repetition existed and was unreachable. Review now takes up
to two fifths of a session. And the damping that spread the picks across topics
was applied only to seen material, so a learner with no history was handed the
heaviest domain entire instead of a spread; that was live.
The plan is the product. It is computed, shown, and then the session is built
from that plan's own ids and the plan returned with it, so the two cannot
differ; every figure in it is a tally over the chosen questions rather than a
forecast. No model touches the ranking — a learner asking "why these twenty"
has to get the same answer twice.
**Vision.** The proxy's own `/model/info` says which models can see, so nothing
is hard-coded: 77 report yes, 11 no, and 328 say nothing at all, which means
absent rather than incapable — so those are asked once with an 8px PNG and the
refusal cached. The deployment's main model turns out not to see, and questions
carry figures the learner is looking at, so the tutor was answering about an
image it had never been shown. It routes to a configured tool model now, folds
the description back in as text saying plainly where it came from, and caches
on the bytes because the same figure is re-sent every turn.
Also fixed on the way: `article` was missing from the admin's task list, so
article drafting always ran on the fallback model whatever an administrator
chose; and `.jpx` stem images were sent as JPEG because `mimetypes` guesses
that from the name, so the provider rejected them two hops later.
An administrator must pick a tool model in Settings → AI models. Until then the
tutor says a figure exists that nothing could read, rather than describing one
it cannot see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
**Linking.** A question could be tied to an article only from the article, by
typing the question's number into a box — so opening a question you had just
linked showed no sign of the link, and there was no control to add one. Both
ends now search: find the article by title from the question, find the question
by stem from the article, pick which section of the article the link lands on,
and see what is already linked. One shared finder, so the two ends of one
relationship cannot describe it differently. `GET /questions/{id}/articles`
mirrors the endpoint that already existed the other way, and `GET
/articles/linked` is retired — it answered this question by shipping the whole
prose of every linked article to the quiz player for a list of titles.
"Practise this topic" is a reader's control and no longer appears on an editing
screen.
**The player.** The rail was a bordered card floating in the page with a
scrollbar of its own, so a session had two scrollbars side by side and a
collapse handle tucked inside the card's padding. It is a column now: flush,
full height, its own background rather than its own border, the handle on the
boundary it moves, and a progress bar under the count. The bar at the foot is
the bottom edge of the window — three flush segments, no gaps, no pills —
because Exit as a small grey pill beside a large blue Next made leaving look
like the accident.
Study mode no longer asks whether you are sure. Leaving suspends: every answer
is saved, nothing is graded, and it is waiting where you left it — so the
dialog asked permission for something reversible, under a name for something
that does not happen. An exam still asks once, because a block has a clock, and
it now says what it is: "Leave this block?", not "End Session".
Options are lettered. The explanations already are — a stem extracted from a
board PDF says "Preferred Response: E" — so numbering them 1 to 5 left the
reader translating between two labellings of the same five lines. The tutor is
told the same letters, and the answer key is marked against its own option and
declared authoritative, so a model that would have answered differently cannot
tell a student the marked answer is wrong.
"Preferred response" and "Source page 518" are gone: the first labelled a block
that is obviously the answer, the second named a page of a book the learner
does not have. The clocks moved out of a grey strip across the explanation,
where they read as part of the answer, to the foot of the rail with everything
else about the session.
**AI Mode.** Sources are headed and counted at the end, where evidence belongs,
with the practise button after them rather than above. That button appears only
when there is something to build from and says what it will build — it used to
sit under "how can I help you today?" offering to make a session out of
nothing. A cited question opens in place: `/questions/:id` is the editor, so
following one dropped a learner into a form for changing the question they had
just been told about. And a session built from a chat is named like every other
session, rather than after the chat — asking "hi" produced "hi — practice".
Also: two test questions with raw `<p> </p>` in their stems were live in
the bank; retired. And 36 article summaries were written as a table of contents
with the colons filed off — "Peanut allergy prevention and management: LEAP
guidelines by risk tier, risk stratification, and anaphylaxis treatment" — every
noun phrase sounding informative and none of them saying anything. Rewritten as
claims, with the rule added to the prompt that produced them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
`Question.is_shared` defaulted to 1 and was only ever set by a route nothing
called, so in practice it divided the bank into "everything" and "everything,
plus your own private ones" — a distinction that cost every recommendation
denominator a join and never changed an answer. Who may reach the bank is the
site's own access rules; who may manage a question is the category grant tree.
So the two predicates the whole bank was built on are now the same thing, and
say what they actually mean: a question is out of reach if it has been deleted
or belongs to a course. Nothing else. The column is dropped, the route that set
it is gone, the bulk "share" action with it, and the Private tile and pill go
from the question manager.
The tests that turned on it have been rewritten rather than deleted, because
the rule they were really about survives: revoking a question still revokes
every session carrying it — by deleting it, which is the only revocation left.
Several others named a category holding exactly two reachable questions and
then answered two particular ids; that category holds four now, so they name
the pair instead. A session's own sharing flag is untouched — that is a
different thing, and it is still how a session is handed to somebody.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Key points on questions link into article sections (AMBOSS-style) with samples; difficulty tagging with builder/bank filters; adaptive session algorithm prefers unanswered questions then recycles older incorrect ones, weakest categories first with damping; question create/edit is now admin/educator only; expired exams no longer auto-submit on resume; exam suspend messaging updated. Migrations k4f5a6b7c819, l5a6b7c8d920, m6a7b8c9d031. 63 backend and 97 frontend tests pass.
Tutor questions require owned selected attempts; similarity context filters eligibility before ranking. Uploads move to a permission-aware boundary with reference ACLs, canonical legacy aliases, pre-mutation attachment checks and card-aware moderator rules. Nginx stops caching media and supplies native byte ranges. Verified 37 deployed-image backend tests, 69 frontend tests/build, real pgvector/Nginx/browser checks, and two independent reviews.