From 3a89556fbf3b31bb77b55990a567fe3a54a66fc5 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:11:23 -0700 Subject: [PATCH 1/4] docs: add coding standards drawn from the ADRs. Domain modules (ADRs 0006, 0007, 0009, 0010), routes (0004, 0005), and web rules (0001, 0002, 0005, 0008), each citing its ADR so code review can enforce it. Co-Authored-By: Claude Opus 5.5 (1M context) --- CODING_STANDARDS.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index 973bd16..7d953e1 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -20,6 +20,19 @@ Paths are relative to `packages/api/`. - Change the schema with a numbered migration, following the recipe in `src/db/README.md`. DDL lives only in the migrations and `initial_schema.sql`. - Code opens a database through `openOperationalDatabase`. +### Domain modules + +- Create a Specimen or Container through `registerSpecimens` in `src/lib/specimen-registration/`. Validate a request with `checkSpecimenRegistration`, which is the same write rolled back (ADR 0010). +- A request that carries a container validates it with `containerWriteInputSchema` from `@sampledb/contract` (ADR 0006). +- Resolve a Specimen's Source with `resolveSpecimenSource` or `resolveSpecimenSources` from `src/lib/specimens/provenance.ts`, and hand every caller the full `SpecimenSource` shape (ADR 0007). +- Build a container payload (placement, Tags, Specimen, Source) from `loadContainerReadViews` in `src/lib/container-read-view.ts`, then project it (ADR 0009). + +### Routes + +- A route maps Drizzle rows to a wire DTO before `c.json` wherever the shape matters. `omitOnWireMiddleware` drops null keys from every response, so null omission stays with it (ADR 0005). +- A route reports an error by throwing a typed error from `src/lib/error-handler.ts` or returning `handleRouteError(error, c)` (ADR 0004). +- Structured stdout goes through `src/lib/observability.ts`. A failure meant for Admin Error Logs goes through `src/lib/error-logger.ts` (ADR 0004). + ### API tests - Each test gets its own database from `setupTestDatabase()` and closes it with `cleanupTestDatabase()` in `afterEach`. @@ -32,6 +45,11 @@ Paths are relative to `packages/api/`. Paths are relative to `packages/web/`. - Call the API through its domain module, `src/lib/api/`. Its methods resolve to the response body, so read `res.study`. Reference-data `list()` helpers are the exception: their body is `{ data, meta? }` (ADR 0002). +- Read and write settings with `settingsApi.getValue` and `settingsApi.putValue` (ADR 0002). +- A Zod schema for a response marks an unset field `.optional()`, because the wire omits it (ADR 0005). +- Show a query's read error inline with `PageError` or `SectionMessage` from `src/ui/`. Toasts are for mutation errors (ADR 0001). +- A mutation goes through a domain hook that invalidates every query it affects, `dashboardKeys` included (ADR 0001). +- Scan move behaviour lands in the `src/lib/scan-move/` core with plain-data unit tests. The move pages only wire it up (ADR 0008). ### Web tests From 0f2c60531e806183aad2fe7f0d394ca9355e8e1b Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:51:48 -0700 Subject: [PATCH 2/4] docs: scope the ADR rules to new code and match each ADR. Each rule now applies to new or changed code, keeps its ADR's strength, scope and exemptions, and names the paths still migrating. Adds the missing rules from ADRs 0001, 0004, 0005, 0006 and 0008. Co-Authored-By: Claude Opus 5.5 (1M context) --- CODING_STANDARDS.md | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index 7d953e1..0117da6 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -22,16 +22,26 @@ Paths are relative to `packages/api/`. ### Domain modules -- Create a Specimen or Container through `registerSpecimens` in `src/lib/specimen-registration/`. Validate a request with `checkSpecimenRegistration`, which is the same write rolled back (ADR 0010). -- A request that carries a container validates it with `containerWriteInputSchema` from `@sampledb/contract` (ADR 0006). -- Resolve a Specimen's Source with `resolveSpecimenSource` or `resolveSpecimenSources` from `src/lib/specimens/provenance.ts`, and hand every caller the full `SpecimenSource` shape (ADR 0007). -- Build a container payload (placement, Tags, Specimen, Source) from `loadContainerReadViews` in `src/lib/container-read-view.ts`, then project it (ADR 0009). +- In a new write path, create Specimens and Containers with `registerSpecimens` from `src/lib/specimen-registration/` (ADR 0010). Bulk combined import and `/subjects/with-specimens` have migrated. These paths still insert Specimens directly until their slice in ADR 0010 lands: `POST /specimens`, add-container and `/specimens/bulk`, which validate with `validateSpecimenData` or `validateBulkSpecimenRows` (`src/routes/specimens.ts`, `src/lib/registration-orchestrator.ts`); derivations (`src/lib/derivations.ts`); and control batches (`src/lib/controls/batch-with-specimens.ts`). +- In a new write path, validate a registration with `checkSpecimenRegistration`, or wrap the whole write in `withRolledBackTransaction` when the caller writes its own records first (ADR 0010). +- Validate an inbound container with a `@sampledb/contract` schema (`containerWriteInputSchema`, `bulkCombinedContainerSchema` or `optionalContainerInputSchema`), not an endpoint-specific container Zod schema (ADR 0006). +- Map a CSV row to `ContainerWriteInput` with `csvRowToContainerWriteInput` from `@sampledb/contract` (ADR 0006). The web bulk import and control-batch CSVs still map through `flatBulkContainerToWriteInput` and `flatControlBatchContainerToWriteInput` in `packages/web/src/lib/`. +- In a `ContainerWriteInput`, place a paper container on a `collection` of type `sheet`, not on a box or bag `collection` with a sibling `sheetName` (ADR 0006). +- Resolve a Specimen's Source with `resolveSpecimenSource` or `resolveSpecimenSources` from `src/lib/specimens/provenance.ts` (ADR 0007). +- Return the full `SpecimenSource` shape to every caller, not a per-endpoint subset (ADR 0007). +- Load a container read payload (placement, Tags, Specimen, Source) with `loadContainerReadViews` or `loadContainerReadViewsByIds` from `src/lib/container-read-view.ts` (ADR 0009). +- Project the loaded view to its read path's type: `EnrichedContainerApi` for list, derivations and PATCH, `EnrichedStorageContainer` for collection detail, and CSV row fields for Container export (ADR 0009). ### Routes -- A route maps Drizzle rows to a wire DTO before `c.json` wherever the shape matters. `omitOnWireMiddleware` drops null keys from every response, so null omission stays with it (ADR 0005). -- A route reports an error by throwing a typed error from `src/lib/error-handler.ts` or returning `handleRouteError(error, c)` (ADR 0004). -- Structured stdout goes through `src/lib/observability.ts`. A failure meant for Admin Error Logs goes through `src/lib/error-logger.ts` (ADR 0004). +- In new or changed route code, report an error by throwing a typed error from `src/lib/error-handler.ts` or returning `handleRouteError(error, c)`, not with `return c.json({ error }, 4xx)` (ADR 0004). +- Throw `ExpectedNotFoundError` for a 404 caused by user input, such as an export preflight miss or a user-supplied lookup, and `NotFoundError` for a missing resource on a direct GET or a stale link (ADR 0004). +- Write no route-level `catch` block that calls `console.error`; return `handleRouteError(error, c)` from it instead (ADR 0004). +- In new code, write log lines to stdout with `logDebug`, `logInfo`, `logWarn` or `logError` from `src/lib/observability.ts`, not `console.*`. Startup banners and schema-evolution CLI output stay plain `console.log` (ADR 0004). The `console.warn` in `logTemplateError` in `src/routes/qpcr-experiments.ts` predates this rule. +- Persist a failure to the `error_logs` table with `src/lib/error-logger.ts`, not `observability.ts`. Both export `logError`, so check the import path (ADR 0004). +- In a new or changed route handler, do not spread a Drizzle row into a `c.json` payload (ADR 0005). `...newSpecimen` in `src/routes/specimens.ts` and `...locationRecord` in `src/routes/locations.ts` predate this rule. +- Map a response that has a schema in `@sampledb/contract/wire` (containers today) to that wire DTO before `c.json` (ADR 0005). +- Leave null omission to `omitOnWireMiddleware`: do not call `toWireJson()` in a route handler (ADR 0005). ### API tests @@ -45,11 +55,15 @@ Paths are relative to `packages/api/`. Paths are relative to `packages/web/`. - Call the API through its domain module, `src/lib/api/`. Its methods resolve to the response body, so read `res.study`. Reference-data `list()` helpers are the exception: their body is `{ data, meta? }` (ADR 0002). -- Read and write settings with `settingsApi.getValue` and `settingsApi.putValue` (ADR 0002). -- A Zod schema for a response marks an unset field `.optional()`, because the wire omits it (ADR 0005). -- Show a query's read error inline with `PageError` or `SectionMessage` from `src/ui/`. Toasts are for mutation errors (ADR 0001). -- A mutation goes through a domain hook that invalidates every query it affects, `dashboardKeys` included (ADR 0001). -- Scan move behaviour lands in the `src/lib/scan-move/` core with plain-data unit tests. The move pages only wire it up (ADR 0008). +- Read a setting with `settingsApi.getValue(key)` or `settingsApi.get(key).value`, never `api.get` on `/settings/:key` (ADR 0002). +- Write a setting with `settingsApi.putValue`, `settingsApi.update`, or the bulk wrappers `exportConfigurationsApi.update`, `scannerConfigurationsApi.update` and `tableViewConfigurationsApi.update`, never `api.put` on `/settings/:key` (ADR 0002). +- In a Zod parser for a container response, mark an unset field `.optional()`, not `.nullish()` (ADR 0005). Other response parsers are out of scope, such as `parseSettingsEnvelope` in `src/lib/api/parse-response.ts`. +- On a screen ADR 0001 lists as migrated, show a query's read error inline with `PageError` or `SectionMessage` from `src/ui/`, not a toast (ADR 0001). Workflow local state (CSV parse, server validate, submits) keeps its inline or toast errors. +- Prefer to send a new mutation through a domain hook, such as `useCreateStudy`, that invalidates the queries the mutation changes, including `dashboardKeys` when it changes dashboard data (ADR 0001). Direct `*Api.create`, `update` and `delete` calls in `Locations.tsx`, `QpcrExperimentDetail.tsx`, `StudyDetail.tsx`, `SubjectForm.tsx` and `ControlDefinitionForm.tsx` predate this rule. +- Put new Scan move behaviour in the `src/lib/scan-move/` core with plain-data unit tests, not in `ContainerMoveMicronix.tsx` or `ContainerMoveCryovial.tsx` (ADR 0008). The paper move page, `ContainerMovePapers.tsx`, is not a Scan move. +- Pass a Scan move effect a `ScanMoveGateway` instead of importing `collectionsApi` into it (ADR 0008). +- Test Scan move effects with plain stub gateway functions, not `vi.mock` (ADR 0008). +- Open a modal with `Modal` from `src/ui/`, not `ModalPortal` (ADR 0001). `CommandPalette` and `SearchModal` still use `ModalPortal` directly. ### Web tests From e6b4815aa416f301c6c3a64af23fafbbda7ea94e Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:25:01 -0700 Subject: [PATCH 3/4] docs: list the registration paths still migrating after slice 1. Co-Authored-By: Claude Opus 5.5 (1M context) --- CODING_STANDARDS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index ef0c3c4..1276613 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -22,7 +22,7 @@ Paths are relative to `packages/api/`. ### Domain modules -- In a new write path, create Specimens and Containers with `registerSpecimens` from `src/lib/specimen-registration/` (ADR 0010). Bulk combined import and `/subjects/with-specimens` have migrated. These paths still insert Specimens directly until their slice in ADR 0010 lands: `POST /specimens`, add-container and `/specimens/bulk`, which validate with `validateSpecimenData` or `validateBulkSpecimenRows` (`src/routes/specimens.ts`, `src/lib/registration-orchestrator.ts`); derivations (`src/lib/derivations.ts`); and control batches (`src/lib/controls/batch-with-specimens.ts`). +- In a new write path, create Specimens and Containers with `registerSpecimens` from `src/lib/specimen-registration/` (ADR 0010). Bulk combined import, `/subjects/with-specimens` and `/specimens/bulk` have migrated, and every container write goes through `createContainerForSpecimen`, which wraps it. These paths still insert Specimens directly until their slice in ADR 0010 lands: `POST /specimens`, which validates with `validateSpecimenData` (`src/routes/specimens.ts`); derivations (`src/lib/derivations.ts`); and control batches (`src/lib/controls/batch-with-specimens.ts`). - In a new write path, validate a registration with `checkSpecimenRegistration`, or wrap the whole write in `withRolledBackTransaction` when the caller writes its own records first (ADR 0010). - Validate an inbound container with a `@sampledb/contract` schema (`containerWriteInputSchema`, `bulkCombinedContainerSchema` or `optionalContainerInputSchema`), not an endpoint-specific container Zod schema (ADR 0006). - Map a CSV row to `ContainerWriteInput` with `csvRowToContainerWriteInput` from `@sampledb/contract` (ADR 0006). The web bulk import and control-batch CSVs still map through `flatBulkContainerToWriteInput` and `flatControlBatchContainerToWriteInput` in `packages/web/src/lib/`. From e9d00c793add41af2e24a03218971f8e62ec06a1 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Thu, 29 Oct 2026 22:22:58 -0700 Subject: [PATCH 4/4] docs(standards): match the logging, wire, mutation and registration rules to their ADRs and name the code that predates them. Co-Authored-By: Claude Opus 5.5 --- CODING_STANDARDS.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index bd32f78..936c4dc 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -22,24 +22,26 @@ Paths are relative to `packages/api/`. ### Domain modules -- In a new write path, create Specimens and Containers with `registerSpecimens` from `src/lib/specimen-registration/` (ADR 0010). Bulk combined import, `/subjects/with-specimens` and `/specimens/bulk` have migrated, and every container write goes through `createContainerForSpecimen`, which wraps it. These paths still insert Specimens directly until their slice in ADR 0010 lands: `POST /specimens`, which validates with `validateSpecimenData` (`src/routes/specimens.ts`); derivations (`src/lib/derivations.ts`); and control batches (`src/lib/controls/batch-with-specimens.ts`). +- In a new write path, create Specimens and Containers with `registerSpecimens` from `src/lib/specimen-registration/` (ADR 0010). Bulk combined import, `/subjects/with-specimens` and `/specimens/bulk` have migrated. The paths in ADR 0010's later slices still create the Specimen themselves and add each Container through `createContainerForSpecimen` from `src/lib/container-creation.ts`, a wrapper over `registerSpecimens` that reports only the first error: `POST /specimens` and `POST /specimens/:id/containers` (`src/routes/specimens.ts`), derivations and the derivation CSV (`src/lib/derivations.ts`, `src/lib/derivations-csv.ts`), and control batches (`src/lib/controls/batch-with-specimens.ts`). Call `createContainerForSpecimen` only from those paths. - In a new write path, validate a registration with `checkSpecimenRegistration`, or wrap the whole write in `withRolledBackTransaction` when the caller writes its own records first (ADR 0010). - Validate an inbound container with a `@sampledb/contract` schema (`containerWriteInputSchema`, `bulkCombinedContainerSchema` or `optionalContainerInputSchema`), not an endpoint-specific container Zod schema (ADR 0006). - Map a CSV row to `ContainerWriteInput` with `csvRowToContainerWriteInput` from `@sampledb/contract` (ADR 0006). The web bulk import and control-batch CSVs still map through `flatBulkContainerToWriteInput` and `flatControlBatchContainerToWriteInput` in `packages/web/src/lib/`. - In a `ContainerWriteInput`, place a paper container on a `collection` of type `sheet`, not on a box or bag `collection` with a sibling `sheetName` (ADR 0006). +- Accept an inbound container field only under its `ContainerWriteInput` name; map no alias for a name ADR 0006 removed (`type`, `containerBarcode`, flat `collectionName`, `collectionType`, `collectionLocationId`, `sheetName`, `sheetParentType`, `sheetParentName`). A schema may reject a removed name with a message naming its replacement, as `src/lib/controls/batch-schemas.ts`, `src/lib/derivation-schemas.ts` and `packages/contract/src/bulk-combined.ts` do. - Resolve a Specimen's Source with `resolveSpecimenSource` or `resolveSpecimenSources` from `src/lib/specimens/provenance.ts` (ADR 0007). - Return the full `SpecimenSource` shape to every caller, not a per-endpoint subset (ADR 0007). -- Load a container read payload (placement, Tags, Specimen, Source) with `loadContainerReadViews` or `loadContainerReadViewsByIds` from `src/lib/container-read-view.ts` (ADR 0009). -- Project the loaded view to its read path's type: `EnrichedContainerApi` for list, derivations and PATCH, `EnrichedStorageContainer` for collection detail, and CSV row fields for Container export (ADR 0009). +- Load a Container's read view (placement, identity, Tags, unit, location, Specimen, Source) with `loadContainerReadViews` or `loadContainerReadViewsByIds` from `src/lib/container-read-view.ts`, not with per-endpoint queries (ADR 0009). +- Map the read view to its read path's type: `EnrichedContainerApi` for list, derivations and PATCH, `EnrichedStorageContainer` for collection detail, and CSV row fields for Container export (ADR 0009). ### Routes - In new or changed route code, report an error by throwing a typed error from `src/lib/error-handler.ts` or returning `handleRouteError(error, c)`, not with `return c.json({ error }, 4xx)` (ADR 0004). +- Throw `RouteError` from `src/lib/error-handler.ts` when the client-visible error body must stay the same, as export's `"Failed to export …"` responses do (ADR 0004). - Throw `ExpectedNotFoundError` for a 404 caused by user input, such as an export preflight miss or a user-supplied lookup, and `NotFoundError` for a missing resource on a direct GET or a stale link (ADR 0004). - Write no route-level `catch` block that calls `console.error`; return `handleRouteError(error, c)` from it instead (ADR 0004). -- In new code, write log lines to stdout with `logDebug`, `logInfo`, `logWarn` or `logError` from `src/lib/observability.ts`, not `console.*`. Startup banners and schema-evolution CLI output stay plain `console.log` (ADR 0004). The `console.warn` in `logTemplateError` in `src/routes/qpcr-experiments.ts` predates this rule. -- Persist a failure to the `error_logs` table with `src/lib/error-logger.ts`, not `observability.ts`. Both export `logError`, so check the import path (ADR 0004). -- In a new or changed route handler, do not spread a Drizzle row into a `c.json` payload (ADR 0005). `...newSpecimen` in `src/routes/specimens.ts` and `...locationRecord` in `src/routes/locations.ts` predate this rule. +- Write structured stdout (request lines, diagnostics) with `logDebug`, `logInfo`, `logWarn` or `logError` from `src/lib/observability.ts`, and persist a failure to the `error_logs` table with `src/lib/error-logger.ts`; use each module only for its own sink (ADR 0004). Both export `logError`, so import the observability one as `logObservabilityError`, as `src/lib/error-handler.ts` does. +- Use plain `console.*` only for human-readable bootstrap, migration and script output: the startup lines in `src/index.ts`, the open and schema-evolution modules in `src/db/`, `src/scripts/`, and the CLI entry of `src/lib/demo-seed.ts` (ADR 0004). In new or changed request-time code, use `observability.ts`. Two request-time uses predate this rule: the `console.warn` in `logTemplateError` in `src/routes/qpcr-experiments.ts`, and the seeding progress lines in `POST /setup/initialize` in `src/routes/setup.ts`. +- In a new or changed route handler, do not spread a Drizzle row into a `c.json` payload (ADR 0005). `...newSpecimen` in `src/routes/specimens.ts`, `...locationRecord` in `src/routes/locations.ts` and `...subject` in `GET /studies/:id/subjects` (`src/routes/studies.ts`) predate this rule. - Map a response that has a schema in `@sampledb/contract/wire` (containers today) to that wire DTO before `c.json` (ADR 0005). - Leave null omission to `omitOnWireMiddleware`: do not call `toWireJson()` in a route handler (ADR 0005). @@ -58,11 +60,11 @@ Paths are relative to `packages/web/`. - Call the API through its domain module, `src/lib/api/`. Its methods resolve to the response body, so read `res.study`. Reference-data `list()` helpers are the exception: their body is `{ data, meta? }` (ADR 0002). - Read a setting with `settingsApi.getValue(key)` or `settingsApi.get(key).value`, never `api.get` on `/settings/:key` (ADR 0002). - Write a setting with `settingsApi.putValue`, `settingsApi.update`, or the bulk wrappers `exportConfigurationsApi.update`, `scannerConfigurationsApi.update` and `tableViewConfigurationsApi.update`, never `api.put` on `/settings/:key` (ADR 0002). -- In a Zod parser for a container response, mark an unset field `.optional()`, not `.nullish()` (ADR 0005). Other response parsers are out of scope, such as `parseSettingsEnvelope` in `src/lib/api/parse-response.ts`. -- On a screen ADR 0001 lists as migrated, show a query's read error inline with `PageError` or `SectionMessage` from `src/ui/`, not a toast (ADR 0001). Workflow local state (CSV parse, server validate, submits) keeps its inline or toast errors. -- Prefer to send a new mutation through a domain hook, such as `useCreateStudy`, that invalidates the queries the mutation changes, including `dashboardKeys` when it changes dashboard data (ADR 0001). Direct `*Api.create`, `update` and `delete` calls in `Locations.tsx`, `QpcrExperimentDetail.tsx`, `StudyDetail.tsx`, `SubjectForm.tsx` and `ControlDefinitionForm.tsx` predate this rule. -- Put new Scan move behaviour in the `src/lib/scan-move/` core with plain-data unit tests, not in `ContainerMoveMicronix.tsx` or `ContainerMoveCryovial.tsx` (ADR 0008). The paper move page, `ContainerMovePapers.tsx`, is not a Scan move. -- Pass a Scan move effect a `ScanMoveGateway` instead of importing `collectionsApi` into it (ADR 0008). +- Parse a container response with the wire schemas from `@sampledb/contract/wire`, as `src/lib/api/parse-response.ts` does (ADR 0005). In a field you add to those schemas, mark an unset value `.optional()`, not `.nullish()` or `.nullable().optional()`. The existing `.nullable().optional()` fields in `packages/contract/src/wire/container-wire.ts` predate this rule, and parsers of other responses, such as `parseSettingsEnvelope`, are out of its scope. +- On a screen ADR 0001 lists as migrated, show a query's read error inline with `PageError` or `SectionMessage` from `src/ui/`, not a toast (ADR 0001). Show a mutation's error with a toast, or with the inline mutation banner where the screen already has one. Workflow local state (CSV parse, server validate, export and import submits, wizard review and submit) keeps its inline or toast errors. +- In new code, send a mutation that changes queried data through a domain hook, such as `useCreateStudy`, that invalidates the queries it changes, including `dashboardKeys` when it changes dashboard data (ADR 0001 says "should"). Two kinds of call stay direct: workflow submit and resolve calls, which keep local state (ADR 0001's workflow write paths, ADR 0008), and settings writes through `settingsApi` and the `*ConfigurationsApi` wrappers in the settings forms and managers under `src/components/`. Direct `*Api` mutations on CRUD screens predate this rule, among them `Locations.tsx`, `QpcrExperimentDetail.tsx`, `StudyDetail.tsx`, `ControlBatchDetail.tsx`, `AdminUsers.tsx`, `SubjectForm.tsx` and `ControlDefinitionForm.tsx`. +- Put new Scan move behaviour in the `src/lib/scan-move/` core with plain-data unit tests, not in `ContainerMoveMicronix.tsx` or `ContainerMoveCryovial.tsx` (ADR 0008). `ContainerMovePapers.tsx` (interactive paper move) and `CollectionMove.tsx` (moving a Collection to a new Location) are not Scan moves, and this rule and the two below do not cover them. +- Pass a Scan move effect a `ScanMoveGateway` instead of importing `collectionsApi` into it (ADR 0008). Only the production adapter, `src/lib/scan-move/gateway.ts`, imports `collectionsApi`. - Test Scan move effects with plain stub gateway functions, not `vi.mock` (ADR 0008). - Open a modal with `Modal` from `src/ui/`, not `ModalPortal` (ADR 0001). `CommandPalette` and `SearchModal` still use `ModalPortal` directly.