Gone: /auth/register, /auth/signup-policy, POST /admin/users, the
RegisterPage, the register half of the landing modal, the Register
button, the "Sign up" link, the "Create an account" hero button, and the
UserCreate schema. /register redirects to /login for anybody holding a
bookmark. A first admin on a fresh install still comes from
DEFAULT_ADMIN_EMAIL at startup, so nothing is locked out.
And no flash of the old way in. Both sign-in surfaces defaulted to "no
provider" and drew the email form while /auth/sso/config was in flight,
then swapped it — so a reload showed a form that does not exist, briefly,
every time. They render nothing until the answer arrives. The landing
modal is now one button, "Sign in with PedsHub SSO", with no sentence
under it: the button already says where you are going.
Also, the section strip takes the width it has. It sat inside the 1200px
measure that keeps an article readable, so on a wide desktop the last
entries fell off the end and a scroll arrow appeared beside acres of
empty space. Verified at 1280, 1600 and 1920: ten links, no arrows.
And "Make a deck" comes out of the strip and the phone menu — that was
an over-reach on my part. The landing CTA keeps it, pointing at
app.pedshub.com/#resources, which is what was actually asked for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Opening any shared deck answered 500 — ResponseValidationError, "Input
should be a valid integer" for user_id. The ownership migration made
those columns nullable and the response models still declared
`user_id: int`, so the first read of a deck after it was a crash rather
than a page. A learner hit it on Cards.
FlashcardDeckResponse, DocumentResponse and QuizResponse now allow None,
with a test that walks the three and fails if any of them promises an
owner again. The grant-input schemas were left alone on purpose: their
user_id names the person a grant is for, and a grant with nobody in it
is not a thing.
Also gone: send_login_code_email, forty-eight lines of email template
for a feature that no longer exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
They were one page, and it served neither. Somebody wanting to know what
"Studying for" does had to scroll past what the tutor is prompted with
and what re-embedding breaks.
/help — every signed-in account. Signing in through PedsHub SSO, what
"Studying for" scopes, Qbank against Sessions against Collections, study
and exam mode, where performance comes from, Reading and Cards and study
plans, "Make a deck", and how to report a bad question. An educator also
gets a section of their own: what a moderator has, what a grant gives
and what it does not, that nothing in the bank belongs to anybody, how a
draft becomes an article, how a plan is built, and what a moderator
cannot do. A learner never sees that half.
/handbook — administrators only now, with the same FAQ plus the rest.
The Settings card that pointed at it is admin-only to match, and
RequireAuth learned an admin door, which it did not have.
Help is in the account menu rather than the footer: a question you have
while working is answered from where you are.
And the repo docs describe the site that exists. CLAUDE.md lost the LMS
section — courses, modules, lessons, enrolments, all removed months ago
and still documented — and gained the permission model and the sign-in
flow. ADMIN.md's role table said moderators create courses; it now says
what the three roles actually reach, where roles come from, and that the
bank has no owners.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Two endpoints still set a role locally — PUT /access/{id}/role and PUT
/admin/users/{id}/role. With OIDC_ROLE_CLAIM configured, my sync applies
the person's groups on every sign-in, so a role set in the app lasts
until they next sign in and is then overwritten. That is the worst kind
of setting: it appears to work, holds for an afternoon, and quietly
undoes itself.
Both now answer 409 naming where the switch actually is. With no mapping
configured they behave exactly as before, which is what a site with no
provider needs.
The two existing tests now patch is_configured explicitly rather than
inheriting whatever the container's .env happens to say — a test that
reads differently on two machines is not a test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Both were this app doing an identity provider's work. Sign-in and
sign-up happen at sso.pedshub.com now: it takes the address, sends the
code, checks it, and knows about second factors — none of which belongs
here, and two of which were never done here at all.
Gone: services/invites.py, services/login_codes.py,
routers/login_code.py, the two models, the three admin invite routes,
the invite_only flag and its switch, the invite field on both sign-up
forms, and the code half of the sign-in page — which was the primary way
in and is now a button that says "Sign in with PedsHub SSO". The
password form stays for a site with no provider configured.
Migration r7b8c9d0e1f2 drops invite_codes (three spent rows) and
login_codes (empty). The dump beside it has both.
585 tests, and the contract snapshot is 320 routes — five fewer, all
five named in the diff so the removal is reviewable rather than
discovered later by a client.
Also: "Make a deck" in the signed-in menu and on the landing page, going
to the scribe's My Resources at app.pedshub.com/#resources. Same
sign-in on both sides; the arrow says it leaves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
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
/register drew the whole form under SSO-only — name, email, password
twice, invite code, Sign Up — and only the POST refused it. That is
asking somebody for four fields and a password they will never use
before telling them the door does not exist.
/auth/signup-policy is the question the form already asks, so it answers
it: sso_only, the provider's name, and registration_open false whenever
single sign-on is the only way in. The page says one line instead. The
Register tab on the landing modal and the Register button in the header
go with it — and the modal falls back to Sign in if it was opened
straight into Register.
The sign-in page needed nothing: it already hid its "Sign up" link on
registration_open false, which is now also true under SSO.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Two findings from a security pass on the SSO path, both real.
sso_only gated login and the login codes and nothing else. Register,
forgot-password, reset-password, resend-verification and setting a
password through PUT /auth/me all went through — so a site running
single sign-on could still mint a password account nobody vetted, and if
the flag were ever turned off, there it would be. One helper, five doors,
403 with a reason at each.
And the access token travelled in a query string. The SSO redirect is a
page load, so the browser asked nginx for /sso-callback?token=<a live
bearer token, good for a day> and nginx logs the request line — every
sign-in wrote one into the frontend container's access log, the
browser's history, and the Referer of whatever loaded next. It carries a
one-time code now: a random 32 bytes parked in Redis for sixty seconds,
traded at POST /auth/sso/exchange for the token, and deleted as it is
read, so a code replayed from any of those places buys nothing.
Also the OIDC state cookie, which is what stops an authorization
response being replayed at you: same_site lax (strict drops it on the
provider's top-level GET and fails every sign-in) and secure whenever
APP_URL is https.
And one cross, not two. The header's menu button is already a cross
while a drawer is open, so the drawer's own close button was a second
control an inch below it for the same job — gone from the article, the
player and the review.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Authlib's Starlette client is the async one, so `authorize_redirect`
hands back a coroutine. `sso_login` was a sync `def` that returned it,
and FastAPI tried to serialise a coroutine as a response body:
ValueError: 'coroutine' object is not iterable. Every click of the SSO
button was a 500.
It has presumably always been broken. Nothing caught it because nothing
had a provider configured to click the button with — /auth/sso/config
answered sso_enabled:false, so the button was never drawn. It surfaced
within a minute of an Authentik being pointed at the app.
Verified against the live provider: /api/auth/sso/login now answers 302
to sso.pedshub.com/application/o/authorize/ with response_type, scope,
state and nonce.
A shape test guards it — the endpoint is a coroutine function and the
redirect is awaited — because the alternative is standing up an OIDC
provider inside a unit test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Everybody arriving through SSO landed as a learner and was promoted by
hand. That is fine for three people and wrong for three hundred: the
list of who may edit the bank then lives in two places and only one of
them is maintained. OIDC_ROLE_CLAIM names the claim that carries somebody's
groups ("groups" for most providers, "roles" for Entra app roles), and
OIDC_ADMIN_GROUPS / OIDC_MODERATOR_GROUPS say which values mean what.
All three shapes a provider might send are read — a list, a
space-separated string, a comma-separated one — and matched case-blind.
Applied on every sign-in, not only at creation, because a group somebody
can be added to and never removed from is not a list anybody can rely
on. The one demotion it refuses is the last administrator: a mistyped
group name should not lock everybody out of the settings page,
including the person who could fix the group name. It is logged when it
happens.
Blank OIDC_ROLE_CLAIM is the old behaviour exactly: role "user", set
once, never touched.
And an address the provider will not vouch for is not an identity. This
matches on email, so an explicit email_verified:false would hand an
existing account to whoever typed its address at a careless provider.
Refused now — only on an explicit false, since a provider that omits the
claim is not asserting anything either way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Found by walking the permission model as a real learner account rather
than reading the guards. The bank listing has always nulled the answer
side for whoever does not write the question; this route, added for the
full-page editor, returned the row whole behind nothing but
get_current_user. Any signed-in account could ask for
/questions/detail/3869 and be handed the correct option, the
explanation, the per-option reasoning and the key points for a question
it had never sat — 2,924 questions, one URL, no attempt required.
Same rule as the listing now, via the same may_edit_question: the stem
still reads, the answer side comes back null, and explanation figures —
answer-side by definition — are filtered out with it. Nulled rather than
refused, because the stem is legitimately readable.
Three tests: a reader gets the stem and none of the answer, whoever
writes it gets all of it, and an author counts as writing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Two voices, and they belong in different places. The thumbnail in an
explanation carries what the *question* says about the figure — words an
educator chose for this question. Opening it now adds the catalogue
entry underneath: what the image was filed as, the description written
when it was filed, where it came from, and any marks an educator drew on
it, behind a switch.
It rides on explanation rows only. Those are already withheld by the
server until answers are revealed, so the record reaches somebody who
has finished the question and gets nowhere near somebody who has not —
which is the whole reason the caption came off the figure in the first
place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
An Orpheus id (groq-orpheus-english) did not start with "local-", so
generate_tts_audio sent it down the OpenAI path and every call failed. It
also could be added with no voice, and the "local-%" filters in /tts/voices
and the default lookup hid any non-local voice from learners even when it
was added and marked default.
services/tts_voices.py is the one table of which voices belong to which
model (Kokoro, Orpheus English/Arabic, Fish), the same table the scribe app
keeps. Anything the table knows, or anything local-*, goes through the
LiteLLM gateway with the options its family needs (Orpheus: wav). Adding a
bare model id creates one row per voice with friendly names, so an
administrator adds "groq-orpheus-english" and six voices appear to test one
by one; a voice from another family is refused, naming the ones that work.
Learners are offered every active voice, each saying which model serves it,
and /tts/speak answers with the media type the model actually returned.
Kitten and Supertonic tables go — those models left the gateway.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The caption under a figure in a session was the image library's own
description, and a library description is written to catalogue an image:
"an X-ray of a child's pelvis and hips, showing abnormalities in the
right hip joint" is a fine catalogue entry and a complete giveaway under
a stem about a limping five-year-old. One line in figure_json fell back
to it, and every one of the 343 figures in the bank was inheriting one —
338 of them on stems, read before the question was answered. Not one
figure had a caption of its own.
So a figure carries what the question says about it and nothing else.
The caption and the label are set on the question, when the image is
attached; most question figures want neither, and now show neither. The
catalogue title went with it — nothing rendered it, and it gives the
same thing away over the wire.
The second route in was the viewer: an image written into a stem as
markdown opens a panel that fetches what the library knows and prints
it. Inside an attempt it no longer asks. In the library, in an article,
in review of the image itself, the description is still the
description.
Also: no PMID links. A reference list says what was read; it is not a
set of doors out of the article. The number is there to look up, as
text.
And a drawer's own button is a cross. It kept the ☰ that opened it while
covering the screen, which reads as a second menu rather than the way
out of the one in front of you.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Reading shows nobody drafts, not even an admin. An educator's unfinished
work sat among the published shelf with a tag on it, which made Reading
and Editorial two views of one list and left you unsure which you were
looking at. The list is published-only for everybody now, and the tag
and its style are gone with it — the badge stays on an article's own
page, where a draft can still be opened.
And Editorial has its own URL. /editorial/articles/:id renders the same
page, but the crumb reads "Editorial" and goes back to the queue.
Opening an article from the queue used to land on Reading's address, so
the only way out was the top of the published library — you lost your
place in the queue to look at one draft. Drafting from the reading page
lands there too, because a new draft is editorial work from the moment
it exists.
References from PubMed are fields, not a sentence. Every other
reference on an article is {title, author, pages} and the reader reads
those keys, so the flat line the PubMed path wrote drew as six blank
rows under a References heading: the DKA draft cited six real papers
and appeared to cite none. A paper now fills journal, year and PMID
instead of pages, and the PMID is a link to the record. Rows written
before this pull themselves apart on the way out rather than being
rewritten in the database, so the drafts that already exist heal
themselves.
Repeat session has never worked. The dialog asked the bank for mode
"study" — the name of the route it lands on — and the bank has "timed"
and "learning", so every repeat came back 422 and the dialog reported
its own house message, "Could not build that session", because the
detail was a list rather than a string. Both fixed: the right mode, and
a server that says something is quoted rather than swallowed.
And the objective named in "your performance analysis for Pediatrics
Boards" opens the objective picker. It was a link to the account page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Four things a deck got wrong.
The category. Every deck written from an article is filed where the
article is filed — and the list drew all of them "Uncategorized",
because the router defines its own FlashcardDeckResponse that shadows
the one in schemas/, and that one has no category_id. So the field was
set on the row, returned by nothing, and an educator refiled by hand
what the system had already filed correctly. The shared schema was
imported by no module at all, so it is gone rather than left as a
second definition to read past next time.
The size. Fifteen cards is the per-chunk default, and an article is one
chunk however long it is — a ten-section piece and a two-paragraph stub
both asked for fifteen. Now roughly a card per 150 words, floored at 12
so a short article still makes a deck and capped at 30 so one call stays
inside the model's output.
The card. Set at list-item size inside a frame that fills the window, so
a two-line question sat in the middle of an acre of white. The face
scales with the window and stops at a comfortable measure; the back is
set smaller than the front, as prose rather than a headline.
And the contract snapshot, which still owed the jobs endpoint from the
last commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
**The badge is off the navbar.** It sat in the header of every page, for
everybody, polling every thirty seconds — for a number that means something to
the handful of people who start an extraction and nothing at all to a learner
sitting a session. Extraction is workbench business and it lives there now:
Settings → Tools → Jobs, and a link from the workbench itself, which is where
one is started.
To answer the question it raised: a job ages off the list after a day, and the
steps behind it after an hour. Which is to say it disappears when Redis forgets
it, on its own, with nothing to tell you it had.
**So there is now a way to clear one.** "Forget" takes a job off your list, with
a confirm beside it. It stops nothing that is running — the button is not
offered for a running job — and deletes nothing the job produced; it clears a
line somebody has read and dealt with so the ones they have not are not buried
under it. Only from your own list: the id alone is not authority over anybody
else's, and the keys behind it are shared.
**And a way to read one.** The details panel is called "Log" now, because that
is what it is — every step the job took, in order, and the reason it stopped if
it stopped. That reason has been recorded all along and shown nowhere.
Also, on the deck: the way out is a back link above the title like every other
page rather than a small grey button in the bar of card controls, and the four
buttons that are the whole interaction are full size.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
**Card generation was failing every time.** The model configured for the
flashcard task — and for extraction and keyword — was `ds-deepseek-v4-flash`,
which the AI proxy no longer serves. All three now point at the v4.1 model that
does exist, and the dead rows are gone.
The reason was reachable only by reading the job record: the panel said "Card
generation failed." and nothing else, while the proxy's actual answer sat in a
field nothing displayed. A failed job now says which model is missing and where
to change it.
**The reader had no styles of its own.** Its appearance lived in
ArticlesPage.css, imported by the reading page — so an article rendered
correctly there and as unstyled boxes anywhere else it was used. The split pane
inside AI Mode is where that finally showed: section headings drew as bare
bordered rectangles. The reader imports what it needs now.
**Settings → Tools listed the question workbench and the taxonomy**, which are
question work and belong beside the questions. Both are on the question
manager's own bar now, the taxonomy's way back leads to Questions rather than
to a settings page nobody was on, and the workbench's does too.
Also: air under AI Mode's composer, which sat flush against the footer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
**Two sources an AI draft can draw on**, both off until an administrator turns
them on, both appended to the prompt as extra material rather than woven into
it — so a draft with nothing to draw on is byte-for-byte the draft that has
been working well.
- *The clinical library.* The indexed shelf the clinical assistant already
searches, over MCP on the internal network. Ported from ped-ai: sessions are
reused, a dead one is reopened once, and a library that cannot be reached
never fails the article — it just means the educator is writing without it,
and the progress line says so.
- *PubMed.* NCBI's E-utilities, no key required. Ported whole, including the
two lessons that cost somebody an afternoon over there: PubMed ANDs every
term, so "bronchiolitis management in infants" can find nothing where
"bronchiolitis management" finds six — hence the query ladder — and three
esearch calls in a row will trip the rate limit, hence the spacing. The
reference list is written from the records rather than by the model, so every
line is a paper that exists with a PMID somebody can look up.
Measured on the live stack: 24 excerpts, 6 papers, 6 references, 6 in-text
citations, in one draft.
**The card system, which turned out to be half-built:**
- There was no way to make a deck by hand, and no way to edit a card at all —
you could browse, view and delete. Both are there now, the editor taking
front, back and a picture.
- Filing, writing, sharing and deleting are all educator work now, behind one
named gate rather than four scattered checks. A learner studies.
- A deck generated from an article inherits that article's category instead of
landing in Uncategorized for somebody to file by hand.
- A link inside a card previewed instead of going. A card is a box a few lines
tall, often inside a flipping panel, and a hover card anchored in one is
clipped by it — so the link read as broken because clicking it did nothing.
Where there is no room to preview, the honest behaviour is to take you there.
**An AI draft belonged to no editorial queue.** Nothing set `generated_by`, so
a drafted article was neither "generated, unread" nor anything else: the tile
counted it and there was nowhere to click. Drafts are stamped with the model
that wrote them, and there is now a plain Drafts queue that cannot be fallen
through.
**The sign-in code email** is laid out rather than written: the code is the
biggest thing on the screen, then which account it signs into, then a way back
to the page, then permission to ignore the whole thing.
Also: a back link out of a deck, in the same words as the rest of the app.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
**Cross-references can name a section.** `[[264#workup|the workup]]` opens the
reader at that heading, which is what a sentence about one part of a long
article actually means. Whole-article `[[264|label]]` is unchanged, and a
section renamed since is not a broken link — it lands at the top of the right
article, which is a mild disappointment rather than a dead end.
**A picker that writes the marker for you.** 🔗 Link an article, in the editor:
type a few words, click the article — or one of its sections — and the marker
is on the clipboard with the right title as its label. Getting an id used to
mean opening the library in another tab, finding the article and reading the
number out of the address bar, which is four steps and a chance to mistype,
every time. Its own small endpoint, because the listing deliberately does not
carry sections and this needs nothing else.
**Three things about cards that were built but never drawn:**
- A card can carry an image. The column is there, the API returns it, the
editor accepts one — and no view in the app rendered it, so every picture
anybody attached to a card was stored and never seen. Both card views show it
now, small until clicked like every other figure.
- The deck browser printed `[[331|Epiglottitis]]` as brackets and a number. The
study view has rendered them as links for a while; now both do.
- There was no way to make a deck by hand. Every deck came out of a model —
generated from a document section or an article — so an educator who wanted
to write six cards had nowhere to put them, and the add-a-card route could
only add to a deck that did not exist yet. `+ New deck` on the cards page.
**Generate cards ran in silence.** It starts a real job, and the only place its
progress was drawn was inside the refine panel — which lives in the editor and
is shut. Pressing it on the reading page did nothing visible for ninety
seconds. It now says what it is doing where it was pressed.
**Overlays were invisible to learners.** A stored width is a fraction of the
image, and the stroke is drawn with `non-scaling-stroke`, which makes
`stroke-width` a count of screen pixels — so 0.006 meant six thousandths of a
pixel. The editor has always multiplied by its rendered width; the viewer now
does the same sum. Every region an educator has ever marked was invisible to
everyone who was not editing it.
Also: the figure viewer no longer scrolls, at any width, and the page behind it
is pinned properly (`overflow: hidden` on the body does nothing on iOS, so a
figure opened half-way down an article drifted while it was read). Options are
full width on a phone. The question toolbar's seven glyphs are four, with the
rest folded into the ⋯ that was already there, spelled out in words. The jobs
popover closes on a click anywhere outside it. And the editor has a way back to
Editorial — "back to the article", from an article you opened to edit, is a
loop.
The contract snapshot caught both new routes on the way through, which is what
it is for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
**The API.** Every route now lives under `/api/v1`, with `/api/...` rewritten
onto it — one route, two spellings, so they cannot drift and the OpenAPI
document describes each endpoint once. Errors carry an `error` object with a
stable code, one human sentence and, for a validation failure, the fields that
were wrong; `detail` is untouched so nothing that reads it breaks. The whole
surface — 320 routes, their parameters and their status codes — is checked in
as `backend/tests/api-contract.json`, and a test fails on any difference,
naming the routes that moved. `docs/api.md` is the contract in prose.
**Refresh tokens**, so an app can stay signed in without keeping a password.
Rows rather than signatures: listable, withdrawable, stored as hashes, rotated
on every use. A spent token coming back ends the whole session, because a theft
and a replay look identical from the server and the safe reading is the unsafe
one. A browser is not given one — it has nowhere to put it and a person to ask.
**An end-to-end stack**: `docker-compose.test.yml` with its own Postgres and
Redis, `e2e/seed.py` for the smallest world the tests name, and Playwright with
five projects — desktop, iPhone, Pixel, iPad and a browserless API project.
Devices because every bug reported this week was a phone bug found by a person
looking at a screenshot; a desktop-only suite would have passed through all of
them. Forty tests, five clean runs.
It found four things in its first hour:
- **A fresh deploy could not start.** `create_all()` ran before
`CREATE EXTENSION vector`, so any database that had never had pgvector
installed died on the first table with a vector column. Invisible here
because this one has had the extension for a year.
- **A figure in a published article was a 404 for everyone but an admin.**
Media in the library is nobody's to read by default, and nothing made an
exception for a drawing an article actually shows — so every illustration
added this week was an empty box for every real user.
- **Every rate limit was one bucket for the whole site.** The backend saw
nginx's address for every request, so ten bad passwords from anybody locked
out everybody, and no log line could say who. nginx now takes the real
address from the proxy and overwrites the header on the way in; uvicorn runs
with --proxy-headers.
- **The reading page's breakpoints disagreed** — 1150px in the component,
820px in the stylesheet. Between them the menu button claimed the contents
drawer and then toggled a class on a rail that was still in the layout: the
contents did not open and the site menu did not either. The button was dead
on every tablet.
And two smaller ones: the login limiter counted successful sign-ins, so eleven
people behind one hospital NAT locked each other out — it is cleared by a
correct password now; and `/uploads/{path}` served GET and HEAD from one route
with one operation id, which makes every OpenAPI client generator refuse the
document.
The first admin's password is generated and printed once at first start when
`DEFAULT_ADMIN_PASSWORD` is blank, rather than the account not existing:
`docker compose logs backend | grep -A3 "FIRST ADMIN"`.
CI (`.forgejo/workflows/tests.yml`) runs the backend suite, the contract, the
frontend suite and the build on every push to dev, main or master, and the
end-to-end stack on those branches and on pull requests into them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
1,881 files: a Capacitor shell for Android and iOS, a Forgejo workflow that
signed and uploaded an APK, and `/api/mobile` — the API that shell talked to.
Nothing in the web app called it, and the PWA (manifest, service worker, icon)
is untouched: installing PedsHub on a phone works exactly as before.
`/api/mobile` was worth removing on its own account. `GET /api/mobile/sync`
returned, for every quiz a learner could see, every question in it *with its
correct answer and explanation* — "bulk sync with answer keys for offline
study". It is the same hole that was just closed on the question bank, still
open through a second door, and `GET /api/mobile/quizzes/{id}` was a third. It
also carried a second password-login endpoint with its own rate limiter, which
is twice the sign-in surface for an app that no longer exists.
The tests that reached those endpoints were asserting quiz visibility and
grading, not the transport. They assert the same things through /quizzes and
/attempts now, so nothing is less covered than it was.
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
Reported from a phone, all of it:
- The menu button opened a drawer and then did nothing. Pressing the same
button is how a thumb closes a drawer; the only way out was the strip of page
beside it. It toggles now — chats, contents, a session's questions and a
finished attempt's rail, all four.
- AI Mode's chat list started at the top of the window, so its first row sat
behind the header: unreadable, untappable, and covering the button that would
have closed it. It starts below the header now, the way an article's contents
already did, and the measurement they share is one hook rather than two.
- The star that saves an article hung its panel from its right edge. That star
is the first thing in the reading bar, so on a phone two hundred pixels of the
panel were off the left of the screen, over the title. It measures and picks
a side.
- Cited questions were listed under "Sources". A question is not something you
read, it is something you sit — so it stays out of the list and out of the
count, and still counts towards the session the button builds.
- The session offer counted its questions out loud, which invites haggling over
a number the learner does not set. "Practise this", then "Your session is
ready". Twenty is the cap, as it was.
- Asked for five questions, the model explained itself: how many it had looked
at, what it could go and fetch. It is now told to ignore the number, not to
apologise for it, not to offer to find more, and to say the same thing again
if asked again.
Also: AI refine is off the reading page. Drafting is drafting — it belongs in
the editor, next to Save, not on the page a learner is reading.
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
The moderator-only UI was hidden from moderators. `/auth/me` never returned
`is_moderator`, and the whole frontend decides what to show by reading it —
which is why "Draft with AI" appeared to do nothing: the panel was never
rendered for the person who owns the button.
AI Mode, from the reports alongside it:
- An empty answer was stored and drawn as a blank card. Both the empty reply
and the reply that is empty once an invented citation is deleted are now
refused before anything is written, so the question comes back in the box
with a reason. Old blank cards render an "Ask again" instead of nothing.
- The model narrated its own retrieval — how many sources it had seen, what it
could go and fetch — when asked for more questions than it could cite. It is
now told to name what there is in one sentence and stop.
- A thread took its name from the learner's raw typing, so the rail read "hi,
how do i treat a febrile seizure?". Openers are trimmed, "i" is a capital,
and the cut falls on a word.
- A cited article opened somewhere else. It now gets the same hover card as a
cross-reference in prose — excerpt, new tab, and the pane beside the answer.
- A built session said "questions ready" with no number: the endpoint returns
`count`, not `questions_count`.
And a race the last of those exposed: the guard that stops the transcript
being fetched over an answer that has just arrived was cleared as soon as the
request finished, so whether the exchange survived depended on when React ran
the effect. It now tracks which thread is on screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
**"Draft with AI does nothing" was almost true.** The job worked — about forty
seconds, then a draft saved — but the panel closed onto a library that looked
unchanged, and the only progress it ever showed was the same "Drafting…" line.
The job now reports the article it made and the page goes straight into it, and
the wait is counted in seconds so it is visibly a wait rather than a hang.
**Editorial rows open the editor.** It is the queue of things to *do* to an
article; rows that opened the reader made an editor press Edit on every one.
**No Contents button on a phone.** The header's menu opens the contents, and a
second door in the prose is the same mistake the player had. An open drawer now
has a backdrop that closes it, and choosing a section closes it too — it sits
over the prose it just scrolled to.
**References wrap.** A source title is often a filename with underscores and no
spaces to break at, so the list ran off the side of the screen.
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
Three things the card system did not have.
**Spaced repetition.** There was none. "Known" and "to review" were React state
that vanished on reload, so a deck of two hundred was two hundred cards every
time and the only spacing was whichever cards a learner remembered to skip.
Verdicts are now kept, and the deck comes back in the order the learner's own
history calls for: due first, most decayed first, then never seen, then the
rest — because somebody who has met the whole deck recently should still get a
deck rather than a screen saying come back on Thursday.
It borrows the question player's arithmetic rather than choosing its own.
`recall_probability`, `DUE_RECALL`, the thirty-day half-life: two schedulers
with two ideas of "due", in one product that shows a learner one readiness
number, is how the number stops meaning anything. Two outcomes and no
four-point scale — a scale asks a learner to rate their own recall in units
they have never calibrated, and the extra resolution is noise.
**Cards are prose.** Both faces go through the same renderer as everything
else, so a card can carry `[[264|respiratory failure]]`, a `==key point==`, a
teaching tip or a figure. That is most of what "link cards to things" turns out
to mean.
**A deck is reachable from the question.** Beside the topic-reading chip under
the correct answer, one chip per linked deck. Read from the question's end
only, deliberately: a card that listed the questions it belongs to would hand a
learner revising the deck the shape of the exam, and the answer with it.
Migration m3d4e5f6a7b8.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The half of "adaptive" that was never built. Difficulty was a filter a learner
could set and nothing the session did on its own, so somebody at 30% on a topic
and somebody at 90% were asked the same questions in the same order.
A question's value is now scaled by how far its level is from the one the
learner's readiness on *that topic* calls for: under 0.45 wants easy, up to
0.78 medium, above that hard. Per topic because strong on growth and weak on
arrhythmias is the normal case and one number across the two describes nobody.
A multiplier and not a filter, for the same reason the reranker is a
permutation: thinning the bank to one level makes it three times smaller, and
on a narrow topic that is the same eight questions every time. One step away
keeps 0.72 of its value, two steps 0.5, and an unlabelled question 0.92 —
unknown is not wrong.
The first band's edge is 0.45 rather than 0.55 so that a topic with no evidence
— which sits at exactly NEUTRAL_RECALL — gets medium questions. Handing
somebody the easiest questions in a subject nobody has measured them on is a
poor way to find out what they know.
Written up in docs/adaptive-sessions.md, which now says five rules.
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
The rule was in my head and in a commit message; it needed to be in the task.
The first run detached sixteen good figures — a tick on a leaf against a July
fever, fungal hyphae against a scaly rash, a recessed chin in a two-week-old's
notes, an ECG on a tachypnoeic neonate — because a judgement about relevance was
allowed to act on a photograph, and the connection between a clinical figure and
its question is often indirect.
Two changes. The prompt now separates the two kinds of figure and says how each
is judged: a clinical one — photograph, radiograph, ultrasound, ECG, fundoscopy,
otoscopy, microscopy — is refused only when it is anatomically impossible, and a
table, citation, chart or nomogram is refused whenever it is about another
subject. And the model must now say which kind it is looking at, so the code can
enforce it: "no" on anything clinical becomes "unsure", which flags it for a
person instead of detaching it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
An HPV vaccination question was carrying a table of craniofacial reconstruction
by age. Extraction takes figures off the page a question was printed on, which
is usually right and occasionally catastrophically wrong — and the mistake is
invisible to a text review, because nothing about the stem or the filename says
the picture is about something else. My earlier pass read stems and answers and
could not have caught it.
So this looks. Every figure goes to a vision model with its question and is
asked whether it belongs: strict about "no", generous about "unsure", because
detaching a figure a question needs is worse than leaving a stray one attached
for somebody to notice. A clear mismatch is detached and the image stays in the
bank with a note saying what happened, so nothing is lost and the decision can
be checked.
Ran over all 341: **30 detached, 2 unsure, 309 kept.** Among the thirty — the
rule of nines on a hepatitis B newborn, a molluscum table on a collapsed
athlete, a textbook citation page on a drowning, and the craniofacial table
that started this.
It also describes each figure while it is looking at it, and keeps the
description. 435 assets said "Figure from question #1206", which is a filename
with extra steps and is why the image bank could not be searched; 335 now say
what they show, and none of the placeholders remain.
The model is named in the task rather than read from the admin's `tool`
setting: this is a one-off audit, and it should not quietly become the thing
that decides what the tool model is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
A 2,000px radiograph written into an article rendered at whatever width it
happened to be — a wall of greyscale in the middle of a sentence, four
megabytes to draw it, and no way to look at it properly. Every image in prose
is now a 256px thumbnail with the author's label under it, and a click gives it
the screen.
The viewer puts what is known about the figure beside it: its title, what it
shows, and where it came from. `media_assets` gains `source` and `source_url`
for that — a citation belongs to the file, because the same figure used in
three articles is cited the same way in all three, and a licence that turns out
to be wrong is one row to fix rather than three paragraphs to find. Asked for
when the figure is opened, not when the page is drawn.
And `overlay`: the regions an educator has marked, as vector shapes in
normalised coordinates on the unit square, so one drawing is correct in a
thumbnail, in the viewer and on a projector. Off until the learner turns it on
— marks shown before they have looked answer the question for them. Vectors
rather than a second burnt-in picture, for four reasons written down in
docs/image-overlays.md. The tool that draws them is next; this is the storage,
the contract and the reader's half.
On a narrow screen the description stacks above the image rather than beside
it, where it can be read before scrolling to the picture.
Also here: `classify_question_difficulty`, which labelled all 2,924 questions
in batches of twenty-five against a written rubric — 622 easy, 1,634 medium,
668 hard, no failed batches. The column had been NULL on every row since it
existed. Migration l2c3d4e5f6a7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Two halves of one TODO, settled with a measurement rather than a guess.
*Shrunk readiness* was already done — `CandidateRanking.accuracy()` pulls a
topic towards NEUTRAL_RECALL by PRIOR_ANSWERS, so one miss does not read as 0%
— and there is now a test pinning it, because the note claiming otherwise
outlived the fix by weeks.
*Difficulty as a dimension the session moves along* cannot be built, and the
reason is a number: all 2,924 questions have a NULL `difficulty`, and the
empirical route is no better at 788 answers over 706 questions from 5 learners,
about one answer each. A ladder scored against that would be scoring noise
while looking as though it worked.
What is built instead is honesty in the control that already exists: the
Difficulty facet counts each level under the other filters and disables one
that would empty the bank, so nobody picks Hard and watches the count fall to
zero with no explanation. Reopen the ordering when something writes that
column.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
"Questions filed there later are not added" was the honest description of what
the previous commit built, and it was the wrong thing to build. "The Cardiology
article covers the Cardiology questions" is a standing statement about the
material, not a snapshot of who happened to be filed where on the afternoon
somebody pressed a button — and a copy stops being true the first time a
question is added, silently, with nothing on any screen to say so.
So the claim is now stored, and it is what writes the links:
* `question_article_links` is still the **only** table anything reads. No count,
no QBank button, no mirror panel on a question, no AI Mode boost learns a
second question to ask.
* `article_topic_claims` records *why* some of those rows exist, and is the one
place that makes them — when the claim is staked, when a question is filed
into the category (single, bulk, or on create), and on a half-hourly sweep
that catches whatever bypassed both.
A link made this way is an ordinary row and can still be deleted by hand; a
sweep puts it back, which is the honest consequence of a standing claim.
Dropping the claim is how you stop it, and the panel now lists what an article
follows with two ways out — stop following and keep the links, or stop and
remove them.
Migration k1b2c3d4e5f6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Two things, both about the AI draft nobody could tell was working.
The prompt never mentioned that a section belongs to one of three readings, so
every generated article was one long view and the other two tabs stayed empty
— on 300-odd articles. It now describes what each view is for, asks for the
high-yield one as tight lists with `==key points==` marked, says to omit the
clinical view for a topic with no bedside, and says explicitly not to
categorise or link the article, because those are an educator's judgements.
And an unknown or missing variant is read as "long", which is what every
section written before this already is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
There was no way to delete an article from anywhere in the interface. The API
had one; the only route to it was curl.
Now there is a control at the foot of the editor, and it does one of two things
depending on the article's history — and says which before it is pressed:
* A draft that was **never published** is deleted outright. There is nothing to
restore, and a trash full of abandoned stubs is a second list to maintain.
* Anything that has been published, even once, is **marked** and appears in the
trash on Editorial, restorable exactly as it was. Somewhere there is a
learner's note against one of its sections, a question linked to it, and a
link somebody sent a colleague; a DELETE typed in the afternoon should not
settle any of that.
`first_published_at` is what decides, stamped on the first publish and never
cleared — unpublishing does not make an article unseen, so it does not make
deleting it safe either. Backfilled from `reviewed_at` for everything currently
published, because an article with a null stamp reads to the rule as a
never-published draft.
A binned article is out of the listing, the editorial queue, every slug and id
lookup, and — immediately — the search index, so it cannot still answer a
learner's question from the trash.
Also on Editorial, because a hundred rows is a queue you work through and not a
page you scroll past on the way to the next queue: each bucket keeps its own
box, its own scrollbar and its own filter.
And the editor finally has a way out that is not Save: Back and Discard, with
an inline confirmation when there are unsaved changes. The way out was the
browser's back button, which throws the sitting away without saying so.
Migration j0a1b2c3d4e5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
One question at a time is right for a cross-reference and wrong for "every
Cardiology question belongs to the Cardiology article", which is most of what
an educator is doing in that panel.
Choose a category and every question filed under it — primary category or
additional, and its subtopics unless you say otherwise — is linked in one go.
The count is fetched first so the button carries the number: "Link 43
questions" is a decision, "Link this topic" is a guess, and the difference
matters when the topic turns out to be the whole of Cardiology. Capped at 300,
with a refusal that names the number and suggests a subtopic.
Ordinary link rows, not a rule. The article does not "claim" the category; it
gains the questions in it now. A rule would be a second answer to "which
questions belong to this article" — one the counts, the QBank button, the
mirror panel on each question and the AI Mode boost would each have to learn to
ask — and it would silently attach tomorrow's questions to an article nobody
has looked at since.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The first message named the conversation, so a rail of them read "hi", "hello",
"hi". The name now waits for the first turn that is actually a question —
usually the very next one — and the thread stays "New chat" until then.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The document matcher decided by extension and fell through to "decode whatever
this is as UTF-8" for everything that was not a .pdf. A shell script, an HTML
page, a CSV or a JPEG all became a search query, and a file called report.pdf
holding something else was read as a PDF.
Three questions now, in order, before anything else touches the file: is it
under 2 MB (read one byte past the cap, so a huge file is never held in memory
to be measured); what is it, sniffed from the leading bytes; and what text is
in it. PDF, DOCX and images, and nothing else — 415 with a sentence naming what
to upload instead. DOCX is parsed from the zip with no new dependency and is
checked against the one attack that shape allows, a member that unpacks to far
more than the file's size suggests. An image is read by the tool model, and
where no tool model is configured it says so rather than 500ing.
On injection, since that is the question people mean: there is no path from an
uploaded file to code that runs. The extracted text is a search query — bound
parameter to Postgres, never concatenated into SQL — and the content of a
message to a model. It is never rendered as HTML, never written to disk, never
passed to a shell. Control characters are stripped because they make queries
that match nothing, not because anything would interpret them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
"hello" came back with four confident citations and a list of topics the
learner might like, drawn from questions about caustic ingestion. Retrieval had
done nothing wrong: reciprocal-rank fusion always returns an order, and the
similarity gate that exists to catch this is a threshold — "hi" embeds at 0.46
against a corpus of clinical prose and is caught, "ok" at 0.51 and "good
morning" at 0.50 are not.
So the decision is made before any measuring: a message made entirely of
pleasantries, or asking what the assistant is, gets mode "chat" — no retrieval
at all, no shortlist to cite from, and a prompt that says what it can do
without claiming to know what is in the library, because it has not looked.
The vocabulary is closed rather than a length rule, so "croup dose?" is still a
query. A missed greeting costs a slightly odd reply; a swallowed question costs
an answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Four things that share a spine, so they arrive together.
**Folders.** A hand-picked set of questions, and the fourth thing a grant can
name beside exam, discipline and category. Deliberately not `user_collections`
with a sharing flag: a library is a consequence of access — you save what you
can already see — while a folder is a source of it, and one table holding
thousands of private lists beside a handful that confer permission is one
mistake away from a leak. Built from the question manager, granted on /access.
Membership stays with the owner and moderators so a grantee cannot widen their
own reach, and deleting a folder takes its grants with it.
Two live constraints had to be rewritten to accept it: `ck_grant_has_a_dimension`
and `uq_grant_dimensions` both predate `folder_id`, so a folder-only grant
failed the check and two folder grants collided on the unique index.
**Per-question feedback.** The learner's half already existed. What was wrong
was who could read it: any grant at all let an educator list and delete reports
about the whole bank. Reports are now scoped by `question_scope_predicate`, the
same predicate that decides which questions that educator can see, and a reply
thread makes the report a conversation the learner can follow rather than a
form that swallows what they said.
**Per-section notes and article feedback.** Two tables on purpose:
`article_section_notes` is private to whoever wrote it, `article_feedback` goes
to whoever maintains the article. Both point at the section id inside
`articles.sections` rather than at `article_section_index`, whose rows are
dropped on unpublish — a cascade from there would delete a learner's writing
because an educator took an article down for an afternoon. A rename keeps a
note attached; a deleted section leaves it marked orphaned under the heading it
was written on, for its writer alone to remove.
The header's feedback badge covers both, because questions and reading are the
same job to whoever is doing it.
Migration i9f0a1b2c3d4. 556 backend and 572 frontend tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Retrieval fused a bi-encoder and BM25 by reciprocal rank. A bi-encoder embeds a
document long before the question exists, so the two never meet: it is good at
"same topic" and mediocre at "answers this". A cross-encoder reads the pair.
The proxy already serves three — `cohere-rerank-v4.0-pro` is the default and
measurably better than the fast variant. Query text goes exactly where the
embeddings already go, and nothing new was signed up for.
It found a defect nobody was looking for. In AI Mode each finder scored
`1/(1+rank)` *within its own corpus*, so the best article, section, question and
card all scored 1.0 and the shortlist was a meaningless round-robin. A
cross-encoder is the first thing in this system that can compare a question
with a section. Candidates per kind widened so it can select rather than merely
reorder.
Measured against labels neither ranker produced. Questions, 60 disease tags:
precision@3 0.394 → 0.483. Sections, 60 article titles: 0.772 → 0.833.
"Management of bronchiolitis" led with influenza transmission and a pregnancy
question; "when do you image a first febrile seizure" returned the definition
rather than the sentence saying imaging is unnecessary.
And the honest negative, in docs/reranking.md: board vignettes are written
*not* to name their diagnosis, so on "what causes croup" it prefers a question
that says the word in passing over the barking-cough vignette that never says
it. Some of the bi-encoder's strength is traded away.
Not on the typeahead. A page of results is a choice being made and worth a
third of a second; a typeahead is a word being finished, runs on every
keystroke, and has nothing to judge yet.
The three-state thresholds stay on cosine, argued at the constant: a reranker
only ever sees a shortlist and structurally cannot answer the corpus-wide
question those numbers ask, and whether an answer claims to come from the
library is a promise that must not depend on a network hop.
Every failure returns None and leaves the order alone — unconfigured, no proxy,
connect error, bare 502, timeout, non-JSON, a duplicate or out-of-range index,
a non-numeric score, a list the wrong length. Verified against the running site
with a bogus model name: same results, fused order, no error to the reader.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
**Missing views.** The note said 10 articles without a clinical view and 13
without a short one; the real numbers were 8 and 11, and the eight originals
turned out to carry no `variant` key at all, so everything in them was being
read as the long view. 33 sections written across 11 articles, every sentence
traceable to that article's own body, and the legacy sections stamped with the
variant they were always being treated as. No doses appear anywhere, because
not one of those eight states one. Every article now has a high-yield view and
`mdm_pass check` reads 330 of 331 conforming with no placeholder sections.
**Pediatric Dentistry** was neither an educator's job nor a delete. Its body is
real — scope, disparities, records and consent, public health, biomaterials —
and only its clinical view was the disclaimer. The three placeholder sections
are gone and the article stays: the reader hides a view an article does not
have, and a discipline overview about consent and public health has no bedside
path to write. Inventing one was the thing not to do.
**The 15 tied conditions were stale**, ticked with the evidence rather than
worked: the taxonomy rebuild gave every one of them a single home, and the
duplicate-name query now returns nothing.
**The 31 tied questions were 3**, and reading all three showed none was a real
tie. The best of them: a question about acute flaccid paralysis after minor
trauma was filed between "Nursemaid's Elbow" and "Radial Head Subluxation" —
two names for one condition, neither anything to do with the question. It is
under Spinal Cord Injury now, cross-filed to Acute Flaccid Paralysis, and the
two elbow links are removed rather than cross-filed: a wrong link surfaces a
question under a topic it does not belong to, which is the opposite of what
cross-filing is for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Single sign-on wrote a random string nobody would ever know. That reads as
"has a password" to everything that asks — so Settings demanded a current
password before it would let those accounts set their first, and the only way
through was to click "forgot password" for a password they never had. The same
trap was waiting for anybody who only ever signs in with a code.
Null says the true thing. Signing in refuses an account with no password the
way it refuses a wrong one, because which accounts have one is not a question
that endpoint answers. Setting a first password asks for no current one;
changing an existing password still does. `/auth/me` reports whether there is
one at all and nothing about it, because Settings has to choose between "Set a
password" and "Change password" and cannot tell from the outside.
The random strings already written are left alone. They are unguessable, so
nothing can sign in with them, and clearing them would mean deciding from
outside which accounts were meant to have one.
Identity is the email address throughout, so the three ways in are three ways
into the same account: single sign-on, a code, or a password — and a person may
acquire or drop the third at any point without losing the other two.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
A password is a thing to remember and a thing to lose. Somebody who can read
their own mail can now sign in without one: ask, receive six characters, type
them into the page that is already open.
A code rather than a link, and the difference is not cosmetic. The token in a
link was 256 bits, unguessable however long it lived, so its length, its expiry
and its rate limit were three independent decisions. Six characters is 2^30,
and the three stop being independent — so they are argued together:
* six characters of the invite alphabet, imported rather than copied, because
there should be one answer to which characters a person may be asked to
retype and that one already drops O/0 and I/1;
* a code answers five guesses and is then retired, not slowed — whoever is
typing has lost the mail or does not own it, and both are one click from a
new one;
* one code live per person, since several would mean one guess tested against
all of them;
* ten verify attempts per address per fifteen minutes, so nobody buys five
fresh guesses at a time by asking again.
Tens of guesses an hour against a billion, and the victim gets a mail for every
code burned. Eight characters would buy a thousandfold against an attack the
guess budget has already ended, and cost every person two more characters.
The attempt count lives in the row, not the cache. The Redis limiter fails open
when Redis is down, which is right for what it usually guards and wrong for the
only thing standing between a patient stranger and six characters.
Verifying is scoped to the address. A short code looked up on its own would be
tried against every code live on the site at once — the short code's one real
weakness, closed by knowing whose code it should be before comparing.
Fifteen minutes, because a first mail between strangers is routinely greylisted
five to ten and a code that expires before it arrives is not a sign-in method.
Shortening it buys nothing: one code is live and it answers five guesses
however long it sits there.
Nothing distinguishes an address with an account from one without — same
message, same status, same duration, and both rate limits counted before the
account is looked up, so a 429 cannot become the tell. Redis keys are
fingerprints, and the table holds a fingerprint rather than the code.
SSO stays first where it is configured, and a password is still one click away
for anybody who has one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
`/auth/forgot-password` and `/auth/resend-verification` both take care to say
"if that email exists" and both then answered the question anyway.
The reset limiter returned early for an unknown address, so it counted nothing
for one and counted for the other: ask four times and a registered address
gets 429 while an unknown one gets 200 for ever. It counts either way now — in
Redis for an address with no rows to count, keyed by a fingerprint, because a
list of addresses somebody tried is itself worth not keeping.
Resend answered "Email already verified." for a known verified address and "if
that email exists" for everything else, which is not a hint but an answer. One
sentence for every outcome now.
And Editorial has a way to write something. Drafting was only reachable from
the library — a page about reading, behind a button an educator arriving to
work has no reason to look for — so the two panels now open from a link.
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
The voice picker was a dropdown in the quiz player, beside the question — the
one control on that screen with nothing to do with answering it, and one a
learner sets once and never touches. It is a setting now, on the user rather
than in a Redis blob, with a play button beside each voice because a voice is
worth hearing before it is chosen. Choosing nothing stays a real choice: it
means whatever an administrator marked default, so a site that changes its
default reaches everybody without a row being edited.
The tutor reads figures from `question_media` rather than the two legacy path
columns. Those agree exactly today, so nothing was being lost — the first
question given a second figure in the editor would have been the one that
broke it, silently and only for the tutor. The legacy columns remain as a
fallback for anything not projected into that table yet.
And the retrieval thresholds are written down in docs/retrieval-thresholds.md:
the three answers, the sixteen queries they were measured against, why they are
deliberately not the retrieval floor, and how to re-measure when the corpus
grows. Worth keeping the headline in mind — "discuss love" scores 0.491,
alongside "tell me a joke". A number in the 0.4s is noise, not a weak signal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN