Skip to content

Register specimens and containers through one module (slice 1: bulk combined import) - #90

Draft
m-murphy wants to merge 27 commits into
chore/agent-guardrailsfrom
refactor/specimen-registration
Draft

m-murphy wants to merge 27 commits into
chore/agent-guardrailsfrom
refactor/specimen-registration

Conversation

@m-murphy

@m-murphy m-murphy commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Summary

Stacked on #89. First slice of ADR 0010: Specimen registration goes behind one module, and validation becomes the write itself, rolled back.

 POST /imports/bulk-combined/validate
-  validateBulkCombinedPayload          # its own checks
-    prepareCombinedSubjectContainerBatch
-    lookupWriteInputCollectionId       # barcode miss → null, no occupancy check
-    collectContainerPlacementErrors    # rejects 2 papers per Sheet
+  withRolledBackTransaction
+    registerSubjects                   # same code as the import

 POST /imports/bulk-combined
-  prepareSubjectWithSpecimens
-  revalidatePreparedSubjectInTx
-  createContainerForSpecimen
-    lookupGridCollectionId             # barcode miss → falls back to name
+  withWriteTransaction
+    registerSubjects                   # find or create Subjects
+      registerSpecimens                # plan all → refuse with every error, or write
lib/specimen-registration/
├── index.ts        # registerSpecimens, checkSpecimenRegistration
├── plan.ts         # Specimen reuse, units, positions, barcodes, sublabels; reads only
├── collections.ts  # every identifier given must name the same Collection
└── write.ts        # inserts; a constraint hit becomes a 409

Rules now enforced in one place:

  • Specimen reuse: a Specimen is reused by (Source, type, date).
  • Collection identity: a row's name, barcode and id must agree on one Collection. A Collection is created only when nothing matches and a location is given.
  • Paper on Sheets: several papers can share a Sheet, but a sublabel can't repeat on one Sheet.
  • Grid position clashes: checked per resolved Collection, so naming one plate by name and by barcode is caught.

createContainerForSpecimen now wraps registerSpecimens, so derivations, control batches, POST /specimens and add-container already write with these rules. Their separate validators are replaced in slices 2–4.

Evidence

All three mismatches were reproduced against the old code before this change.

  • Before: existing plate P1 (barcode P1-BC, tube at A01) and a row { name: 'P1', barcode: 'UNKNOWN', position: 'A01' } → validate { valid: true }, import fails "Position A01 is already used". At A02 the import put the tube in P1.
    After: both report Micronix plate 'P1' has barcode 'P1-BC', not 'UNKNOWN'.
  • Before: two subjects with papers on Sheet-1 in one box → validate rejects "Sheet name 'Sheet-1' in box is used more than once", import writes both papers on one sheet.
    After: both accept; a repeated sublabel on that sheet is rejected by both.
  • New fixture test, 8 payloads: validate(payload).valid === importSucceeds(payload), and each matches its expected validity.
  • 18 module tests. bun run ci:verify passes locally (API: 1069 pass, 0 fail).

Review follow-ups

  • Validate matches import in per_subject mode too: validate takes the payload's atomicMode and runs each subject in its own savepoint inside the rolled-back transaction, so a subject that depends on a collection a failed subject would have created is flagged by both. The parity fixtures cover both modes.
  • 409s: a constraint hit during any container write (add-container, POST /specimens, /specimens/bulk, derivations, control batches) returns 409 with every error, not 400 or 500.
  • /specimens/bulk validates and registers through Specimen registration, so its validate and import agree.
  • Transactions: each request gets its own transaction on the shared connection; withRolledBackTransaction and the lock are documented in the db README.
  • Collection identity: a location that contradicts the collection's own is rejected, and the message names the identifier that found a plate with another name.
  • ADRs: ADR 0010 says it amends ADR 0006 and states that collection identifiers must now agree. Its per_subject wording matches the code.
  • Cleanup: container types map to collections and tables in one place; typed claim keys and Specimen identity replace strings and -1 sentinels; errors are worded for any caller; the paper barcode check is left to the contract schema; new test factories for allowed container types and units.
  • Evidence: bun run ci:verify passes: API 1144, web 948, contract 86.

Merge Danger

Door: two-way

No schema or wire-shape change. The validate and import response bodies keep their shapes. A full_file import failure now also carries details.errors with every problem.

Blast Radius: every container write

Every path that creates a Container goes through the new module. Inputs that used to pass now fail:

  • Contradictory identifiers: a collection name and barcode that point at different plates, or a barcode that misses while the name matches.
  • Repeated sublabel: the same sublabel twice on one Sheet.
  • Ambiguous new plate: one new plate given two different locations in one request.
  • Stale identifiers: before, an id won and a name or barcode beside it was ignored. A form that sends an id with a stale name or barcode is now refused with 400.

Messages for some failures changed wording. Each failure now returns every error in one response.

🤖 Generated with Claude Code

m-murphy and others added 27 commits October 6, 2026 14:16
Bulk combined import checked each row in four places (its validator, a
prepare step, an in-transaction revalidate, and the container writer),
and the copies disagreed with the write:

- A row naming an existing plate by name with an unknown collection
  barcode passed validation without an occupancy check, then failed on
  import, or landed in a plate whose barcode the row contradicted.
- Two papers on one Sheet in different subjects failed validation but
  imported.
- One plate named by name in one row and by barcode in another escaped
  the in-file position check.

lib/specimen-registration owns Specimen registration (ADR 0010):
registerSpecimens plans every entry, refuses with every error, or
writes; checkSpecimenRegistration is the same write, rolled back. It
finds or reuses the Specimen by (Source, type, date), resolves each
Collection so every identifier given must name the same one, checks
Grid positions, barcodes, and sublabels against the database and the
rest of the request by resolved Collection, and applies unit defaults.

Bulk combined import now finds or creates Subjects and hands the rest
to the module; its validate route is the full_file import rolled back.
createContainerForSpecimen wraps registerSpecimens, so every container
write follows the same rules while the other paths' validators wait
for their own slices.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
withRolledBackTransaction left BEGIN open across awaits on the one
bun:sqlite connection. A request that wrote while a validate was open
got "within a transaction" from its own BEGIN, ran inside the validate's
transaction, and lost its writes when the validate rolled back, after it
had already reported success. Two overlapping withWriteTransaction calls
shared one transaction the same way.

The open transaction now lives in an AsyncLocalStorage, and a lock per
connection serializes transactions. A call from inside the same async
context runs in a savepoint; a call from any other context waits for the
lock and opens its own transaction. Nesting no longer depends on the
SQLite error text.

A nested withWriteTransaction that throws now rolls back its own writes,
so a per_subject import run inside an open transaction drops the failed
subject's Subject row too. ADR 0010 says how per_subject gets that.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The db README covered only withWriteTransaction. It now lists both
helpers, how the per-connection lock and AsyncLocalStorage decide
between waiting, a savepoint, and a new BEGIN, and the writes the lock
does not cover: plain writes and Drizzle's sync transactions still land
in whatever transaction is open, and are lost if it rolls back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A full_file import whose write hit a constraint threw ConflictError with
the first message only, while its 400 carried details.errors. ConflictError
now takes details and the route handler returns them, so the 409 lists
every error too, and /subjects/with-specimens does the same.

failInsertsWithUniqueViolation lets a test reach that path: a trigger
makes the insert fail as if a concurrent request took the row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
createContainerForSpecimen turned registration's 409 into
{ success: false }, so its callers reported a constraint hit as their
own failure: add-container and POST /specimens replied 400, and
derivations, control batches and /specimens/bulk threw a plain Error
(500). It now throws ConflictError with the errors, and POST /specimens
lets it through instead of wrapping it in a ValidationError.

Each route has a test that makes the insert fail as a concurrent write
would and expects 409.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… asked for it.

writeRegistration reported a constraint hit while inserting a planned
Collection against entry 0, because its current-row marker only moved
once specimens were written. Each planned Collection now records the
first container that needs it (directly, or as its Sheet's box or bag),
and a 409 on that insert names that entry and container.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r name.

A row whose collection id found a plate named differently from the
row's name was told the plate "with this barcode" had that name. The
message now names the identifier that found it: the id, or the barcode.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s own.

A new plate given two locations failed only within one registerSpecimens
call. Control batches and /specimens/bulk register one container per
call, so the second call found the plate the first had created and
ignored its location. The planner now checks a given location against
the stored Collection as well as a planned one, for plates, boxes, and
bags, and ADR 0010 states the rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… registration.

/specimens/bulk validated with registration-prepare, which resolved
collections through container-write-placement: a barcode that missed
fell back to the name, and two papers on one Sheet were rejected. Its
write already went through registerSpecimens, so validate passed rows
the import then refused, and refused rows the import accepted.

The route now resolves each row's Source and hands the rows to
registerSpecimens in one call; validate is checkSpecimenRegistration on
the same entries. That leaves registration's collections.ts as the only
placement resolver, so container-write-placement, registration-prepare,
and container-placement-validation are deleted. ADR 0006 points at the
new resolver and ADR 0010 moves /specimens/bulk and
/subjects/with-specimens into slice 1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
checkSpecimenRegistration returned the Collections a registration would
create, which no caller read. It now returns the errors alone.
checkSpecimenRegistration and the controlBatchId source stay: the
/specimens/bulk validate route calls the first and passes control rows
through the second.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lass, and lookups.

- BulkCombinedContainerInput was RegistrationContainer under a second
  name; bulk combined import uses RegistrationContainer.
- BulkCombinedRejected copied SpecimenRegistrationError. Both now extend
  RefusedWithErrors, which carries the status and the error list keyed
  the way each caller keys its input.
- CollectionPlanner looked a plate up by id, barcode, and name with
  three copies of one query, and a box or bag with two. storedGrid and
  storedParent each do it once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… in one place.

The step from a container type to its plate or box type, or to its
table, was written out five times across plan.ts, collections.ts, and
write.ts. tables.ts now holds those maps, and the planner and writer
look types up there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…message matching.

- Request claims were template strings built at each call site. A Claim
  union (barcode, position, sublabel) now has one key builder.
- entrySpecimen marked an entry with errors as -1; it is now undefined,
  and findSpecimen takes the Source union instead of falling back to
  controlBatchId -1.
- Bulk combined import reworded a missing position by matching
  "Position (well) is required" in the message. validateContainerWriteFields
  now returns the field each error belongs to, and its position message
  ("Position is required for micronix tubes (e.g. A01).") serves every
  caller, so the rewording is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
only(rows, what) returned the row an INSERT … RETURNING gave back, or
threw. The name now says that.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…port say "your file".

Registration errors named CSV columns (plate_name, box_name, sheet_name)
and said a value repeated "in your file", though add-container,
POST /specimens, /subjects/with-specimens, and the specimen entry form
send JSON. The module now says "in this request" and describes missing
names without column names. RegistrationOptions.inputName lets a caller
name its input; the bulk combined import, which takes a CSV, passes
"your file".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t the difference.

The parity fixtures ran only full_file imports. Each fixture now also
runs per_subject and checks the weaker property that mode allows:
validate passes exactly when every subject imports, and every subject
validate flags is one the import rejects. One more test pins the case
where they differ: a subject that relies on a plate an earlier, failing
subject would create is rejected by the import but not flagged by
validate. validateBulkCombinedPayload and ADR 0010 describe this.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tion

# Conflicts:
#	packages/api/src/db/README.md
#	packages/api/src/lib/bulk-combined-import.ts
#	packages/api/src/lib/container-creation.ts
#	packages/api/src/lib/container-write-placement.ts
#	packages/api/src/lib/registration-orchestrator.ts
Validate always checked the file as one full_file import, so in
per_subject mode it could pass a subject the import rejects: one that
relies on a plate an earlier, failing subject would have created.
Validate now takes the payload's atomicMode and, for per_subject, runs
each subject in its own savepoint inside the rolled-back transaction,
as the import does, so it flags exactly the subjects the import
rejects. The parity fixtures check that for per_subject, with the
failed-creator case and the two-locations case. ADR 0010 says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ADR 0006 still named createContainerForSpecimen after
resolveContainerPlacement as the single write executor. It now carries
an "Amended by ADR 0010" note there and lists ADR 0010 in its related
ADRs, and ADR 0010 gains a Related ADRs section saying what it changes
in 0006.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Container writes used to let a collection id win over a name or barcode
beside it, and fell back from an unknown barcode to the name. Since
registration they must all name the same Collection. ADR 0010's
consequences now say so, for the createContainerForSpecimen callers too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se them.

The registration and bulk combined tests inserted
specimen_type_container_type and container_type_unit rows by hand,
which the test README says not to do. createTestSpecimenTypeContainerType
and createTestContainerTypeUnit replace those inserts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…use the collection type unions.

validateContainerWriteFields repeated the "position is required" check
for each grid container type; hasPosition and positionRequired now hold
it once. CreatedCollection's type is built from GridCollectionType and
ParentCollectionType instead of restating them in
RegisteredCollectionType, which nothing else used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the entry-to-specimen map.

The plan unpacked the Source union into a nullable studySubjectId and
controlBatchId pair and keyed Specimen identity with an inline string.
A planned Specimen now keeps its RegistrationSource until the insert,
and SpecimenIdentity with specimenIdentityKey states the identity rule
the way Claim and claimKey state the request claims. entrySpecimen is
renamed specimenIndexOfEntry and useSpecimen, which read like a React
hook, specimenIndexFor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
validateContainerWriteFields read a barcode off paper inputs with
Reflect.get and rejected it, though every route parses containers
with the contract schemas, which reject a barcode on paper, and the
CSV mapper rejects the column (ADR 0006). A route test pins that
POST /specimens/:id/containers refuses it with 400; the unit test
that bypassed the type to reach the module's copy is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion

# Conflicts:
#	packages/api/src/db/README.md
#	packages/api/src/lib/error-handler.ts
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.

1 participant