Skip to content

feat(api,gateway): add all-or-nothing bulk remote assignments - #1521

Merged
joshunrau merged 3 commits into
mainfrom
feat/bulk-remote-assignments
Sep 3, 2026
Merged

feat(api,gateway): add all-or-nothing bulk remote assignments#1521
joshunrau merged 3 commits into
mainfrom
feat/bulk-remote-assignments

Conversation

@thomasbeaudry

Copy link
Copy Markdown
Collaborator

Backend for group-scoped bulk remote assignments (#1500), following the review on that issue.

A front-end PR follows once this is approved. It builds directly on these contracts, so it is kept separate to let the backend be reviewed and tested on its own.

What it does

POST /v1/assignments/bulk/preflight and POST /v1/assignments/bulk, both group-scoped.

A batch is N subjects × M timepoints: each (instrument, expiry) pair applies to every selected subject, so 50 subjects and 3 instruments create 150 assignments in one operation.

Following the review comments

  • All-or-nothing. No per-row results. Preflight throws with every issue attached; create re-runs the same checks so a conflict appearing between review and submit cannot slip through, and any failure deletes every row the call staged.
  • Non-2xx leaves everything as it was. The gateway writes its batch in a transaction; the API rolls back its own staged rows if that call fails.
  • Preflight explains the problem. 422 with a typed body: CONFLICT, INSTRUMENT_UNAVAILABLE, SUBJECT_UNAVAILABLE.
  • A conflict is an existing OUTSTANDING, unexpired assignment for that subject and instrument in that group. It blocks by default and is waived only by an explicit allowDuplicates.
  • Each bundle is fetched and sent once per batch, not once per assignment.

SUBJECT_UNAVAILABLE deliberately does not distinguish a subject that does not exist from one outside the group, so the endpoint cannot be used to probe for subjects elsewhere.

Also in here

  • A dynamic future-date schema. z.coerce.date().min(new Date()) froze its bound at module load, so a long-running API kept accepting expiries that had since passed. Now used by the single-assignment contract too.
  • The gateway had no vitest project. Added, and the file is confirmed collected.
  • The staged assignment no longer carries encryptionKeyPair. It was being serialized into the response and posted to the gateway; Assignment does not declare the field, but structural typing does not remove it at runtime.

Verification

446 unit tests plus 4 API-level Playwright tests covering real Mongo scoping, the real gateway round trip, and the all-or-nothing guarantee.

The e2e tests were checked against a mutant rather than trusted for being green: breaking the conflict query's status filter turns the repeat preflight from 422 into 200 and fails the suite.

Known gaps, deliberately left for discussion

  • timepoints has no upper bound. subjectIds is capped at 500, but N×M means a group with many instruments can still make one request very large, and every assignment needs a keypair.
  • The audit write sits outside the rollback. If it fails after a successful batch, the caller sees a 5xx while the assignments exist. Neither fix is obviously right: rolling back real assignments over a logging failure is bad, and so is an unaudited creation.
  • Preflight skips the conflict query when a subject or instrument is already unavailable, so those are reported on a second round trip rather than the first.

🤖 Generated with Claude Code

thomasbeaudry and others added 3 commits September 2, 2026 13:42
Backend for group-scoped bulk remote assignments (#1500), following the
review on that issue:

- N subjects x M timepoints: every (instrument, expiry) pair applies to
  every selected subject.
- All-or-nothing. Preflight throws with every issue attached rather than
  reporting per-row outcomes, and create re-runs the same checks so a
  conflict appearing between review and submit cannot slip through. Any
  failure deletes every row staged by the call.
- One gateway request per batch, carrying each instrument bundle once
  rather than once per assignment.
- Conflicts block by default and are waived only by an explicit
  allowDuplicates.

Extracts a dynamic future-date schema: z.coerce.date().min(new Date())
froze the bound at module load, so a long-running process kept accepting
expiries that had since passed.

Adds the gateway vitest project, which did not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A staged assignment was returned straight from the model, so the
private key that decrypts it was serialized into the HTTP response and
posted to the gateway. Assignment does not declare the field, but
structural typing does not remove it at runtime, and the gateway's schema
only discards it after it has crossed the wire.

Also answers preflight with 200 rather than the 201 a POST defaults to,
since it creates nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drives the API rather than the UI, so it needs no page object and no
regenerated route tree. Covers what the unit tier structurally cannot:
real Mongo scoping, the real gateway round trip, and the all-or-nothing
guarantee across both.

Verified to fail without the behaviour it asserts: breaking the conflict
query's status filter turns the repeat preflight from 422 into 200 and
fails the suite.

Also drops a dead userId binding found while reviewing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@joshunrau
joshunrau merged commit 2d9cb51 into main Sep 3, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants