fix(api): resolve the instrument subject filter without a relation join - #1423
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes a MongoDB/Prisma scaling failure in the API’s instrument info query (GET /v1/instruments/info?subjectId=) by avoiding a high-cardinality relation filter that Prisma compiles into a $lookup capable of exceeding MongoDB’s 100 MiB per-document limit. Instead of joining from Instrument to InstrumentRecord, it first queries the child collection to find relevant instrument IDs for the subject, then filters instruments by those IDs.
Changes:
- Replace the
records: { some: { subjectId } }Prisma relation filter with a two-step query:InstrumentRecorddistinct instrument IDs →Instrumentfiltered byid in [...]. - Add a private helper (
findInstrumentIdsBySubject) to encapsulate the distinct ID lookup. - Add unit tests guarding against reintroducing the relation filter and verifying the new query behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| apps/api/src/instruments/instruments.service.ts | Reworks subject filtering to query InstrumentRecord for distinct instrument IDs, avoiding a MongoDB $lookup blowup. |
| apps/api/src/instruments/tests/instruments.service.spec.ts | Adds regression tests ensuring the subject filter is resolved via InstrumentRecord and that no record query runs when subjectId is absent. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
CI is red on this branch, so I have not reviewed it yet. Failing check: That test is not in your diff and no longer exists on Also: GitHub reports this branch as Please rebase or merge |
|
Suggested model: Opus 5 Sizing the remaining work on this PR for whoever picks it up, human or agent. Beyond the stale CI, the conflicts land in a service |
joshunrau
left a comment
There was a problem hiding this comment.
Requesting changes so this tracks as needing author action.
Blocking: conflicts with main, plus stale CI. Details in my earlier comment — the failing test isn't in your diff and no longer exists on main. Rebase fix/instruments-subject-filter-lookup, resolve the conflicts, and push; that clears both.
No content review has been done on this branch yet — it's gated on CI being green.
6e167b1 to
3c3e25f
Compare
joshunrau
left a comment
There was a problem hiding this comment.
Thanks for this — the diagnosis is correct and the $lookup ceiling is a real outage, so the
direction is right and the measurements are appreciated. Three things before it can land.
-
The scoped record lookup introduces a new 500 on the endpoint you are fixing.
accessibleQuery(ability, 'read', 'InstrumentRecord')in
apps/api/src/instruments/instruments.service.ts:537does not return an empty filter when the
caller holds noread InstrumentRecordrule — it throws
ForbiddenError: It's not allowed to run "read" on "InstrumentRecord". Only anundefinedability
returns{}.basePermissionLevel: 'STANDARD'grantscreatebut notreadonInstrumentRecord
(apps/api/src/auth/ability.factory.ts:47-52), andGET /v1/instruments/infois gated on
read Instrument, which STANDARD does hold. libnest'sGlobalExceptionFilterturns a
non-HttpExceptioninto a 500, so a STANDARD user calling that endpoint with asubjectIdnow gets
an Internal Server Error where they previously got 200. Every other
accessibleQuery(..., 'InstrumentRecord')call site in the repo sits behind a matching
@RouteAccess({ action: 'read', subject: 'InstrumentRecord' }); this one does not. Please return an
empty id list for a caller who cannot read records rather than letting the error escape. -
Add a test that would have caught it, in
apps/api/src/instruments/__tests__/instruments.service.spec.tsalongside the scoping test at line
681. Build the ability withAbilityFactory.createForPayload({ basePermissionLevel: 'STANDARD', ... })
and assertfind({ subjectId })resolves. The current scoping test hand-builds an ability that
happens to include aread InstrumentRecordrule, so it passes either way. -
An end-to-end test in
testing/is required for every change here. A spec loading
/datahub/$subjectId/tableas a GROUP_MANAGER and asserting the instrument list renders, plus a
STANDARD case insrc/specs/authorization.spec.tsasserting the endpoint does not return a 500,
would cover both the fix and point 1. You are right that the at-scale reproduction cannot be
expressed there — note that in the spec so the gap is deliberate rather than missing.
One more thing, for a separate PR rather than this one: InstrumentRecord has no @@index at all, so
the new lookup is a collection scan filtered by subjectId. It is still far better than the $lookup
it replaces and it has no ceiling, but @@index([subjectId]) is the natural companion to this change.
On the visibility question: adding the record-level scoping means that for a subject enrolled in more
than one group, instruments administered in another group disappear from a group manager's datahub
list. Joshua has confirmed he wants that — it is a disclosure fix and it matches
/v1/instrument-records — so keep the scoping, and fix point 1 by returning an empty id list rather
than by removing it.
If you hand this to Claude Code, Fable 5 is the right size — the fix turns on CASL ability
semantics, route-guard alignment and group-scoping intent at once, on the auth and data-integrity path.
Reviewed at commit 3c3e25f.
3c3e25f to
40e51f5
Compare
joshunrau
left a comment
There was a problem hiding this comment.
Thanks — this is in good shape. The empty-id-list guard, the AbilityFactory-built unit test and the
STANDARD end-to-end case all landed as asked, and you are right that instrument-completion.spec.ts
already covers the populated group-manager path: it picks the instrument out of the datahub combobox,
which is exactly this endpoint. Lint is clean and the api suite is green here. Two things.
testing/src/specs/authorization.spec.ts:68-80— the group-manager case asserts an empty list for
subjectId=any-subject, a subject that does not exist, so it passes whether the scoping works or
is broken for every caller. The title says it "should serve the subject-filtered instrument list",
which it cannot demonstrate. Either seed a record through theapifixture and assert that
instrument comes back, or rename it to what it does assert — that the endpoint answers rather than
erroring.- The
accessibleQuerybehaviour your guard depends on is documented nowhere.
.agents/docs/architecture/auth-and-permissions.md:66-71andapps/api/AGENTS.md:81-84describe
only theundefinedability →{}case. Please add the other one to those paragraphs: a defined
ability holding no rule for the subject throwsForbiddenError, which is not anHttpException
and so renders as a 500. That is the whole reasonfindInstrumentIdsBySubjectneeds its
short-circuit, and it should not have to be rediscovered.
On the broader question your guard raises — whether accessibleQuery should return a deny-all filter
rather than throwing — the decision is to leave it as it is. Every current call site was checked and
none is reachable by a caller holding a defined ability with no rule for the subject it queries, so
the per-call-site guard stays the pattern. Nothing to do for this PR beyond item 2, which is what
keeps that from being rediscovered the hard way.
If you hand this to Claude Code, Fable 5 is the right size — the work sits on the auth and
group-scoping path, and the documentation item means writing the normative description of the repo's
most safety-critical helper.
Reviewed at commit 40e51f5.
`GET /v1/instruments/info?subjectId=` returns HTTP 500, and the datahub
subject view stops rendering entirely, once a single instrument
accumulates roughly 182,000 records.
`InstrumentsService.find` expressed "instruments this subject has records
for" as a prisma relation filter:
records: { some: { subjectId } }
On mongodb prisma compiles that into a $lookup which materialises *every*
record belonging to an instrument into one array before applying the
predicate, and mongodb caps a single document's $lookup output at 100 MiB
(error 4568). The limit cannot be raised and allowDiskUse does not apply
to it. Because the threshold is per-instrument, the busiest instrument
hits it first -- a core intake instrument administered at every visit
reaches 182,000 records at 5,000 subjects x 36 visits.
`useInstrumentVisualization` calls this endpoint on mount and it backs
both /datahub/$subjectId/table and /datahub/$subjectId/graph, so there is
no partial degradation and no workaround available to the user.
Query the child collection instead, which is bounded by the subject's own
records rather than the instrument's, and has no size ceiling.
The record lookup carries the caller's ability, so the filter is resolved
only from records they may read. Without it the subject filter would be
answered from every group's records -- disclosing which instruments a
subject has been administered outside the caller's groups -- which the
relation filter it replaces did not do.
Verified against a live instance with one instrument at 105.7 MiB:
main HTTP 500 (three times), "exceeds 104857600 bytes"
branch HTTP 200 in 77ms, 811 bytes
And below the ceiling, where main still answers, on the same 55.3 MiB
instrument:
main 1.66-1.83s
branch 0.04-0.10s
Fixes #1416
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZEJLwa7jwD8sKzcE4PfSc
Scoping the record lookup to the caller introduced a new failure on the endpoint this branch set out to fix. `accessibleQuery` does not return an empty filter when the ability holds no rule for the subject at all -- only an `undefined` ability does that -- it throws a CASL ForbiddenError, which is not an HttpException, so libnest's filter renders it a 500. A STANDARD user holds `create` but not `read` on InstrumentRecord, and `GET /v1/instruments/info` is gated on `read Instrument`, which they do hold. So a standard user passing `subjectId` got an Internal Server Error where main returns 200. Return an empty id list for such a caller instead: no readable records means no instruments qualify. The scoping itself stays, since resolving the filter from records the caller cannot read is the disclosure this branch is closing. Pinned at both tiers, each verified by mutation: - a unit test building the ability through `AbilityFactory` with a STANDARD payload, rather than by hand -- a hand-built ability tends to carry a `read InstrumentRecord` rule, and it is the absence of one that breaks - an e2e case in `authorization.spec.ts` asserting the endpoint answers 200, which returns 500 without the guard The at-scale reproduction cannot be expressed at the e2e tier; that gap is noted in the spec rather than left silent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZEJLwa7jwD8sKzcE4PfSc
…bility rule accessibleQuery's other failure mode was documented nowhere: a defined ability with no rule for the queried subject throws CASL's ForbiddenError, which is not an HttpException and so renders as a 500. The guard in findInstrumentIdsBySubject exists solely because of it, and the decision to keep the throw (per-call-site guards, no deny-all filter) is now recorded. The group-manager e2e case is renamed to the contract it actually asserts — an empty list for a subject with no visible records, rather than an error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
40e51f5 to
061e7a3
Compare
Fixes #1416.
GET /v1/instruments/info?subjectId=returns HTTP 500 — and the datahub subject view stops rendering entirely — once a single instrument accumulates roughly 182,000 records.Cause
InstrumentsService.findexpressed "instruments this subject has records for" as a prisma relation filter:On MongoDB, prisma compiles that into a
$lookupwhich materialises every record belonging to an instrument into a single array before applying the predicate. MongoDB caps one document's$lookupoutput at 100 MiB and aborts the whole aggregation past it (error 4568):The limit cannot be raised, and
allowDiskUsedoes not apply to it.Two things make this worse than the raw number suggests:
useInstrumentVisualizationcalls this endpoint on mount, and it backs both/datahub/$subjectId/tableand/datahub/$subjectId/graph, so both pages fail and the user has no workaround.The fix
Query the child collection instead of joining from the parent. The work is then bounded by the subject's records (tens) rather than the instrument's (hundreds of thousands), and there is no size ceiling:
Verified against a live instance
Real API, real MongoDB replica set, database provisioned through the app's own
POST /v1/setup, then one instrument pushed past the ceiling.Above the ceiling — busiest instrument at 105.7 MiB, identical database for both:
mainexceeds 104857600 bytesBelow the ceiling — same 55.3 MiB instrument, where
mainstill answers:mainSo this is not only an outage fix: even well under the limit the endpoint was taking ~1.7 seconds to return 811 bytes, and it now takes ~40 ms.
Tests
Three added to
instruments.service.spec.ts:InstrumentRecordand the instrument query contains norecordsrelation filter (this is the regression guard — it asserts the absence of the construct that fails);{ id: { in: [] } }rather than an unfiltered query.The existing
findInfotests are unchanged and still pass.Checks
tsc --noEmitonapps/api— cleaneslint src— cleanprettier --check— cleanvitest run(whole workspace) — 255 passed, 1 skipped, 1 failedThe one failure is
instrument-records.service.spec.ts > upload > should create records with pending set. This branch is cut frommain, so it still carries that pre-existing failure — #1422 fixes it, and the suite is fully green there.Notes
@@index([instrumentId])onInstrumentRecord(proposed in Performance: no indexes are ever created on any MongoDB collection #1406) is a useful mitigation for the old query but not a fix — it does not remove the 100 MiB ceiling. This change removes the ceiling from the picture entirely; the index still helps the new query.FilesService.findusesfiles: { every: ... }, which is bounded by files-per-record and is safe today; it is the only other instance.Rebased onto
main, review items addressedRebased onto
26ec89168. The stale CI is gone and the branch is mergeable;lint-and-testis green.Conflict resolution.
maingained aseriesGroupIdclause in the samewherethis PR rewrites.Both are kept — the
records: { some: ... }relation filter is the only thing removed, replaced bythe resolved id list. The spec conflicts were pure additions from
main(series fixtures), kept as-is.Copilot: the record lookup was unscoped. Fixed.
findInstrumentIdsBySubjectnow takes thecaller's ability and applies
accessibleQuery(ability, 'read', 'InstrumentRecord'). This mattered:the ids feed straight into the instrument query, so an unscoped lookup would disclose which
instruments a subject had been administered outside the caller's groups — something the relation
filter it replaces did not do. Pinned by a test asserting the exact
accessibleQueryclause, using aconditioned ability so it cannot pass vacuously.
Copilot: misleading test name. Renamed to say what it actually asserts — that no record query
runs when no subject is given.
Round 2: rebased onto
5bfe4b2dc, review items addressed1. The new 500 — confirmed and fixed. You were right, and I reproduced it exactly: with an
ability holding no
read InstrumentRecordrule,accessibleQuerythrowsForbiddenError: It's not allowed to run "read" on "InstrumentRecord"rather than returning{}.Only an
undefinedability returns{}.findInstrumentIdsBySubjectnow returns an empty id listfor such a caller, before
accessibleQueryis reached. The scoping itself stays, as you asked.2. A test that would have caught it. Added, built through
AbilityFactory.createForPayload({ basePermissionLevel: 'STANDARD', ... })rather than by hand —your point about the existing test is exactly right, a hand-built ability tends to carry the very
rule whose absence breaks this. Verified by mutation: removing the guard fails it with that
ForbiddenError.3. End-to-end. Added to
authorization.spec.ts: a STANDARD case asserting the endpoint answers200 rather than 500, and a group-manager case alongside it. The STANDARD one returns 500 without the
guard — verified. The at-scale reproduction is noted in the spec as a deliberate gap rather than left
silent, as you suggested; the populated UI path is already covered by
instrument-completion.spec.ts,so I pointed at that rather than duplicating it.
Noted on
@@index([subjectId])— agreed it is the natural companion, and I have left it out of thisPR as you asked. The group-visibility consequence stays as decided.
🤖 Generated with Claude Code
https://claude.ai/code/session_014ZEJLwa7jwD8sKzcE4PfSc