Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c2b2256
refactor(api): register specimens and containers through one module.
m-murphy Oct 6, 2026
4ebfa8c
fix(db): give each request its own transaction on the shared connection.
m-murphy Oct 6, 2026
1b73036
docs(db): document withRolledBackTransaction and the transaction lock.
m-murphy Oct 6, 2026
dd73488
fix(imports): keep every error on a bulk combined 409.
m-murphy Oct 6, 2026
753fcd4
fix(api): reply 409 when a container write hits a constraint.
m-murphy Oct 6, 2026
40f483f
fix(registration): blame a collection insert conflict on the row that…
m-murphy Oct 6, 2026
e2c8ef8
fix(registration): name the identifier that found a plate with anothe…
m-murphy Oct 6, 2026
244e1a1
fix(registration): reject a location that contradicts the collection'…
m-murphy Oct 7, 2026
b6320fd
refactor(api): validate and register /specimens/bulk through Specimen…
m-murphy Oct 7, 2026
bb9bbe5
refactor(registration): drop collectionsToCreate from the check result.
m-murphy Oct 7, 2026
4b33093
refactor(registration): remove the duplicated container type, error c…
m-murphy Oct 7, 2026
402ebf5
refactor(registration): map container types to collections and tables…
m-murphy Oct 7, 2026
87aa5c5
refactor(registration): replace string claim keys, -1 sentinels, and …
m-murphy Oct 7, 2026
941bc0b
refactor(registration): rename only() to insertedRow().
m-murphy Oct 7, 2026
e772047
refactor(registration): word errors for any caller and let the CSV im…
m-murphy Oct 7, 2026
74b0df2
test(imports): check validate against per_subject imports and documen…
m-murphy Oct 7, 2026
74f775a
Merge branch 'chore/agent-guardrails' into refactor/specimen-registra…
m-murphy Oct 7, 2026
c6b04b3
fix(imports): validate per_subject imports one subject at a time.
m-murphy Oct 30, 2026
9404f22
docs(adr): say that ADR 0010 amends ADR 0006.
m-murphy Oct 30, 2026
c3e03b8
docs(adr): state that collection identifiers must now agree.
m-murphy Oct 30, 2026
eaede3b
test(api): add factories for allowed container types and units, and u…
m-murphy Oct 30, 2026
c67fc14
refactor(registration): check a required position in one place and re…
m-murphy Oct 30, 2026
b07abf8
refactor(registration): plan a new Specimen with its Source and name …
m-murphy Oct 30, 2026
d5aae40
refactor(registration): leave the paper barcode check to the schema.
m-murphy Oct 30, 2026
adc06ca
Merge branch 'chore/agent-guardrails' into refactor/specimen-registra…
m-murphy Oct 30, 2026
eff66d6
Merge branch 'chore/agent-guardrails' into refactor/specimen-registra…
m-murphy Oct 30, 2026
353e95d
Merge branch 'chore/agent-guardrails' into refactor/specimen-registra…
m-murphy Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ _Avoid_: Specimen; Derivation (aliquots do not require a transformation record)
Where a specimen's material originated. The primary source is a **Subject** (via a study). Alternate sources include **Control batch**, **Reagent**, **Cell line**, **Plasmid**, and **Standard**.
_Avoid_: Origin (too vague); parent (reserved for derivations — use **Parent container**)

**Specimen registration**:
Recording a **Specimen** from a known **Source**, together with zero or more **Containers** (aliquots) and their **Container placement**. One collection event from one Source is one Specimen: registering the same Source, specimen type, and collection date again adds containers to the existing Specimen instead of creating a second one. A registration either records everything it was given or nothing.
_Avoid_: Import (the CSV workflow that leads to registration); creating a second Specimen for the same collection event

### Controls (quality control specimens)

SampleDB supports multiple **Control** types for QC, calibration, and validation. Control specimens use the same **Specimen** and storage hierarchies as study specimens, but provenance runs through definitions and batches instead of studies and subjects.
Expand Down
9 changes: 8 additions & 1 deletion docs/adr/0006-unified-container-inbound-write.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,19 @@ Affected routes include specimen container create, bulk combined import, control

## Single API write executor

All persistence funnels through `createContainerForSpecimen` (accepting `ContainerWriteInput`) after `resolveContainerPlacement`. Delete parallel implementations:
All persistence funnels through `createContainerForSpecimen` (accepting `ContainerWriteInput`) after `resolveContainerPlacement`.

> **Amended by [ADR 0010](./0010-specimen-registration.md):** persistence funnels through `registerSpecimens` in `lib/specimen-registration/`. `createContainerForSpecimen` is a one-container wrapper around it for callers not yet migrated, and `resolveContainerPlacement` is gone.

Delete parallel implementations:

- `batch-with-specimens` `prepareContainerData` / `createContainerSync`
- Inline subtype inserts in `derivations.ts` for child containers

Placement resolution (sheet find-or-create within box/bag, plate/box resolve) lives in one module — not duplicated across registration, control batch, and derivation CSV.

> **Amended by [ADR 0010](./0010-specimen-registration.md):** that module is `lib/specimen-registration/collections.ts`, and `createContainerForSpecimen` now wraps `registerSpecimens`. It replaced `container-write-placement.ts`, whose lookup fell back from a missing collection barcode to the name; that file is deleted. The derivation CSV and control batch validators still look collections up themselves until their ADR 0010 slices replace them.

## CSV

One contract mapper — `csvRowToContainerWriteInput` — maps flat CSV rows to `ContainerWriteInput`, then Zod validates.
Expand Down Expand Up @@ -104,6 +110,7 @@ Run `bun run ci:verify` before merge.
- [ADR 0005: Wire DTO layer and omit-on-wire JSON](./0005-wire-dto-omit-on-wire.md) — outbound wire shape this mirrors
- [ADR 0002: HTTP client response unwrap](./0002-http-client-response-unwrap.md) — web imports shared contract types
- [ADR 0003: SQLite schema evolution](./0003-sqlite-schema-evolution.md) — storage layer unchanged; this is request-shape and routing only
- [ADR 0010: Specimen registration](./0010-specimen-registration.md) — amends this ADR: the single write executor and placement module are `lib/specimen-registration/`

## Non-goals

Expand Down
44 changes: 44 additions & 0 deletions docs/adr/0010-specimen-registration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Specimen registration: one module, validation as a rolled-back write

---
status: accepted
---

Every write path that creates a **Container** ran its own checks before calling the shared writer `createContainerForSpecimen`: bulk combined import checked the same rows in four places (`bulk-combined-validate.ts`, its prepare step, an in-transaction revalidate, and the writer), `/specimens/bulk` had its own prepare module, and the derivation CSV had a hand-written validator. The checks drifted from the write. Validation looked plate collections up by barcode without falling back to name while the write fell back, so an occupied well passed validation and failed on import. Validation rejected two Papers on one **Sheet** that the write accepted. The derivation CSV validator assumed `micronix_tube` when no container type was given while the import rejected the row.

We put **Specimen registration** (see [CONTEXT.md](../../CONTEXT.md)) behind one module, `packages/api/src/lib/specimen-registration/`, and make validation the write itself, rolled back.

## Interface

- `registerSpecimens(db, entries, options)` plans every entry, refuses with all errors if any, otherwise writes. Called inside the caller's transaction, it joins it.
- `checkSpecimenRegistration(db, entries, options)` runs the same write in a transaction that is always rolled back and returns the errors. Callers whose own writes come first (bulk combined creates **Subjects**) wrap their whole write in `withRolledBackTransaction` instead.
- An entry is a resolved **Source** (`subjectId` or `controlBatchId`; derivations add a parent container later), a specimen type, an optional collection date, and `ContainerWriteInput`s ([ADR 0006](./0006-unified-container-inbound-write.md)) with optional unit and quantity. An entry may instead name an existing specimen to add containers to it.
- Errors are `{ entryIndex, containerIndex?, field?, message }`, keyed to the module's input. Callers map indexes back to CSV rows or form fields.

## Rules the module owns

- **Specimen identity**: (Source, specimen type, collection date), a missing date matching only a missing date. A match is reused and reported as `reused`; entries with the same key in one request become one Specimen.
- **Collection identity**: every identifier given (id, barcode, name) must resolve to the same **Collection**. One that misses while another matches, or two that match different collections, is an error. A Collection is created only when nothing matches and a location is given. A location given for a Collection that exists, or that the request already creates, must be the one it has, so one new plate given two locations fails however the request is split into calls. Sheets are created under their box or bag when missing.
- **Placement**: one **Grid position** per container per collection, checked against the database and the rest of the request by resolved collection, after normalizing positions (`A2` → `A02`). Any number of Papers share a Sheet; a duplicate sublabel on one Sheet is an error.
- **Units**: an omitted unit takes the configured default for the container type; an unknown or disallowed unit is an error. No hardcoded container-type default.
- **Atomicity**: one call writes everything or nothing. Atomicity across calls is the caller's choice: bulk combined `per_subject` mode registers each subject in its own `withWriteTransaction`: a transaction of its own when called at the top level, a savepoint when an outer transaction is open (validate, or a caller's transaction). A failed subject leaves nothing behind, its Subject row included.
- **Errors**: problems found while planning are a `SpecimenRegistrationError` with status 400 and the full error list. A constraint violation while writing is the same error with status 409.

## Considered options

- **Validate as a read-only plan, write separately**: rejected. "Validate passes ⇒ write succeeds" then holds only as far as the plan anticipates the schema, which is how the copies drifted. The rolled-back write costs a second set of inserts, which SQLite does in milliseconds for a CSV of thousands of rows.
- **Plan outside the transaction, execute inside**: rejected. The plan goes stale, which is why bulk combined needed an in-transaction revalidate.
- **Module owns Subjects and control batches too**: rejected. Each caller would need its own variant and the interface would grow back to the size of its callers. Callers create their Source records in the same transaction and pass ids.
- **First matching identifier wins**: rejected. It silently placed a tube in a plate whose barcode contradicted the row.

## Consequences

- Validate endpoints keep their wire shapes; each route maps module errors to its existing response.
- Behaviour change for every container write, including the callers still on `createContainerForSpecimen` (add-container, `POST /specimens`, derivations, control batches): the collection identifiers a request gives (`id`, `barcode`, `name`, and a sheet's `id` and `name`) must all name the same Collection. Before, an `id` won and a `barcode` or `name` beside it was ignored, and a barcode that matched nothing fell back to the name. Now a request whose identifiers disagree, or whose barcode matches no collection while its name does, is refused with 400 and a message naming the identifier that disagrees. A client that sends a stale name or barcode next to an id must correct it or leave it out.
- Bulk combined validate runs the import in its `atomicMode` and rolls it back. For `per_subject` it runs each subject in its own savepoint inside the rolled-back transaction, rolling back a subject that fails and keeping the ones that pass until the end, as the import does. So validate flags exactly the subjects the import rejects, including a subject that relies on a collection a failed subject would have created.
- Migration is in slices, each deleting the code it replaces: bulk combined import, `/subjects/with-specimens`, and `/specimens/bulk`; `POST /specimens` and add-container; derivations and the derivation CSV; control batches. `/subjects/with-specimens` and `/specimens/bulk` moved into the first slice: the first shares bulk combined's code, and the second's validator was the last user of the old placement resolver (ADR 0006).

## Related ADRs

- [ADR 0006: Unified container inbound write](./0006-unified-container-inbound-write.md) — amended by this ADR. Its single write executor is now `registerSpecimens`, with `createContainerForSpecimen` a wrapper for callers not yet migrated, and its one placement module is `lib/specimen-registration/collections.ts`, which replaced `container-write-placement.ts`. `ContainerWriteInput` stays the container shape registration takes.
- [ADR 0007: Specimen source resolution](./0007-specimen-source-resolution.md) — the read side of **Source**; registration takes a Source already resolved to a record id.
1 change: 1 addition & 0 deletions packages/api/src/__tests__/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Available factories:
- `createTestControlDefinition`
- `createTestSpecimen`
- `createTestUnit`
- `createTestSpecimenTypeContainerType`, `createTestContainerTypeUnit` (allow a container type for a specimen type, and a unit for a container type)
- `createTestStudy`
- `createTestStudySubject`
- `createTestControlBatch`
Expand Down
17 changes: 17 additions & 0 deletions packages/api/src/__tests__/helpers/constraint-race.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Database as SQLiteDatabase } from 'bun:sqlite'

/**
* Make every insert into `table` fail with a UNIQUE violation, the way a write fails when a
* concurrent request took the row after the plan checked it. Use it to reach the 409 path of a
* write that validates first.
*/
export function failInsertsWithUniqueViolation(sqlite: SQLiteDatabase, table: string): void {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS test_constraint_race (taken TEXT UNIQUE);
INSERT OR IGNORE INTO test_constraint_race VALUES ('row');
CREATE TRIGGER test_constraint_race_${table} BEFORE INSERT ON ${table}
BEGIN
INSERT INTO test_constraint_race VALUES ('row');
END;
`)
}
25 changes: 25 additions & 0 deletions packages/api/src/__tests__/helpers/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import {
cryovialTube,
staticWell,
paper,
specimenTypeContainerType,
containerTypeUnit,
} from '../../db/schema'
import type { ContainerType } from '../../lib/container-creation'
import { utcNow } from '../../lib/datetime'

/**
Expand Down Expand Up @@ -96,6 +99,28 @@ export async function createTestUnit(db: Database, data: {
return result
}

/**
* Allow a container type for a specimen type (specimen_type_container_type)
*/
export async function createTestSpecimenTypeContainerType(db: Database, data: {
specimenTypeId: number
containerType: ContainerType
}) {
const [result] = await db.insert(specimenTypeContainerType).values(data).returning()
return result
}

/**
* Allow a unit for a container type (container_type_unit)
*/
export async function createTestContainerTypeUnit(db: Database, data: {
containerType: ContainerType
unitId: number
}) {
const [result] = await db.insert(containerTypeUnit).values(data).returning()
return result
}

/**
* Create a test storage container (for testing "in use" scenarios)
* Note: storageContainer requires specimenId and unitId
Expand Down
68 changes: 68 additions & 0 deletions packages/api/src/__tests__/integration/control-batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,16 @@ import {
} from '../helpers/authenticated-route-test'
import {
createTestControlDefinition,
createTestLocation,
createTestMicronixPlate,
createTestSpecimenType,
createTestStorageType,
createTestUnit,
} from '../helpers/factories'
import { createControlsRoutes } from '../../routes/controls'
import { setContainerDefaults } from '../../lib/settings'
import { containerTypeUnit, controlBatch, specimenTypeContainerType } from '../../db/schema'
import { failInsertsWithUniqueViolation } from '../helpers/constraint-race'

describe('Control Batch Creation Integration Tests', () => {
let ctx: AuthenticatedRouteTestContext
Expand Down Expand Up @@ -65,4 +73,64 @@ describe('Control Batch Creation Integration Tests', () => {
const data = await response.json() as { error: string }
expect(data).toHaveProperty('error')
})

describe('when a container write hits a constraint', () => {
let definitionId: number
let plateId: number

beforeEach(async () => {
const unit = await createTestUnit(ctx.db, { symbol: 'uL', name: 'microliter', category: 'volume' })
await setContainerDefaults(ctx.db, {
micronix_tube: { totalQuantity: 1, remainingQuantity: 1, defaultUnitSymbol: 'uL' },
})
await ctx.db.insert(containerTypeUnit).values({ containerType: 'micronix_tube', unitId: unit.id })
definitionId = (await createTestControlDefinition(ctx.db, { name: 'Def1' })).id
const specimenType = await createTestSpecimenType(ctx.db, { name: 'DNA' })
await ctx.db.insert(specimenTypeContainerType).values({ specimenTypeId: specimenType.id, containerType: 'micronix_tube' })
const storageType = await createTestStorageType(ctx.db, { name: 'Freezer' })
const location = await createTestLocation(ctx.db, { name: 'Loc', storageTypeId: String(storageType.id) })
plateId = (await createTestMicronixPlate(ctx.db, { name: 'Plate1', locationId: location.id })).id
failInsertsWithUniqueViolation(ctx.sqlite, 'micronix_tube')
})

function specimens() {
return [
{
specimenTypeName: 'DNA',
containers: [{ containerType: 'micronix_tube', barcode: 'MT001', collection: { type: 'micronix_plate', id: plateId, position: 'A01' } }],
},
]
}

it('create-with-specimens returns 409', async () => {
const res = await ctx.request('/api/blood-controls/batches/create-with-specimens', {
method: 'POST',
json: { batch: { controlDefinitionId: definitionId, name: 'Batch1' }, specimens: specimens() },
})

expect(res.status).toBe(409)
expect(await ctx.db.select().from(controlBatch)).toEqual([])
})

it('create-many-with-specimens returns 409', async () => {
const res = await ctx.request('/api/blood-controls/batches/create-many-with-specimens', {
method: 'POST',
json: { batches: [{ batch: { controlDefinitionId: definitionId, name: 'Batch1' }, specimens: specimens() }] },
})

expect(res.status).toBe(409)
expect(await ctx.db.select().from(controlBatch)).toEqual([])
})

it('adding specimens to a batch returns 409', async () => {
const [batch] = await ctx.db.insert(controlBatch).values({ controlDefinitionId: definitionId, name: 'Existing' }).returning()

const res = await ctx.request(`/api/blood-controls/batches/${batch.id}/specimens/bulk`, {
method: 'POST',
json: { specimens: specimens() },
})

expect(res.status).toBe(409)
})
})
})
22 changes: 22 additions & 0 deletions packages/api/src/__tests__/integration/derivations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { setContainerDefaults } from '../../lib/settings'
import { specimenTypeContainerType, containerTypeUnit, storageContainer, micronixTube } from '../../db/schema'
import type { Database } from '../../db/client'
import { utcNow } from '../../lib/datetime'
import { failInsertsWithUniqueViolation } from '../helpers/constraint-race'

const BASE = '/api/derivations'

Expand Down Expand Up @@ -135,6 +136,27 @@ describe('Derivation Workflow Integration Tests', () => {
expect(list.derivations).toHaveLength(1)
})

it('returns 409 when the child container write hits a constraint', async () => {
const { parentContainerId, targetPlateId } = await createParentContainerFixture(ctx.db)
failInsertsWithUniqueViolation(ctx.sqlite, 'micronix_tube')

const res = await ctx.request(`${BASE}/containers/${parentContainerId}/derive`, {
method: 'POST',
json: {
derivationType: 'aliquot',
specimenTypeName: 'DNA',
container: {
containerType: 'micronix_tube',
barcode: 'MT-CHILD',
collection: { type: 'micronix_plate', id: targetPlateId, position: 'A01' },
},
},
})

expect(res.status).toBe(409)
expect(await ctx.db.select().from(storageContainer)).toHaveLength(1)
})

it('returns derivation source for the child container', async () => {
const { parentContainerId, targetPlateId } = await createParentContainerFixture(ctx.db)

Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/bun-sqlite.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ declare module 'bun:sqlite' {
run(sql: string, ...params: any[]): void
serialize(): Uint8Array
loadExtension(path: string): void
inTransaction(): boolean
readonly inTransaction: boolean
transaction<T>(fn: () => T): T
}

Expand Down
Loading
Loading