---
title: "vxAnnotate ↔ vxData sync"
description: "ID mapping between vxAnnotate and vxData, and why mask RLE decoding belongs in vxa-sdk, not in consumer jobs."
image: "https://docs.virdx.dev/img/virdx-social-card.png"
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.virdx.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# vxAnnotate ↔ vxData sync

Context: `apps/vxdata-jobs/src/vxdata/jobs/f_20260702_vxannotate_sync` (mono
PR #161) syncs vxAnnotate submissions (PIRADS/PIQUAL scoring, annotations,
comments, artifacts) into vxData.

## ID mapping (the part that's expensive to rediscover)

- An annotation's `series_id` = the vxAnnotate **`Series.id` UUID**.
- Retrieve that series: `Series.series_identifier` = `"{volume_uid}_{description}"`.
- vxData `Volume.identifier` = `"volume/{volume_uid}"`.
- `VxAnnotateCaseRecord.included_resources` = the case's Volume identifiers.
- Artifact comments target the mask's series Volume; annotator comments
  target the case's `ImagingStudy` (`parent_identifier`).
- `reference_mri` comes from `mask.series_id` — never derive it from a
  T2/transversal heuristic.
- `PIRADSLesionAssessment.derived_from` links to the lesion's `VoxelMap`.

## Score payload shapes

- **PIRADS** is per-lesion, camelCase keys (`T2Score`, `DWIScore`,
  `laterality`, `zones`, …); `label_name` = the lesion mask id. `zones` is a
  JSON-encoded string array, not a native array.
- **PIQUAL** is case-level, snake_case.
- vxAnnotate's `pirads` extension is **lesion-only** — vxData's global
  `PIRADSAssessment` (`index_lesion_id`, `score`, `free_text`) has no
  annotation source from this sync and would need a v2.1 derivation; it is
  intentionally left unproduced.
- Turn silent fallbacks into loud raises for: missing annotator email,
  missing `label_name`, unmapped submission status, non-str `zones`.

## Mask truth model — use the SDK, don't hand-roll RLE

- `MaskAnnotation.rle` (NiiVue PackBits, native-resolution, binary per mask;
  class lives in `label_value`) is the **authoritative** source.
- The export's `session.mask_niftis` (CBOR-baked) is **best-effort only** —
  it can come back empty or at the wrong resolution. Root cause found
  2026-07-09: vxAnnotate frontend `generateMaskNiftis`
  (`radiology/page.tsx`) picked `series.find(s => s.image_type != "SEG")` —
  the first non-SEG series — as the reference volume for **all** masks,
  ignoring `mask.seriesId`; `decodeRLE(rle, wrongVoxelCount)` then truncated
  the native-res drawing into a background sub-region. Fixed in draft PR
  `virdx/vxannotate.com#51` (per-series reference).
- **Use `vxa-sdk >= 0.1.5`'s `client.exports.mask_niftis`** (reuses the
  stored NIfTI if it matches the RLE, else rebuilds from RLE +
  `series.file`) and the public `client.series.file`. Do not reconstruct
  RLE decoding in a consumer job — a job-local `rle_helper.py` workaround
  was built and then thrown away the moment SDK 0.1.5 shipped it properly.
  When the correct fix belongs in the SDK/frontend layer and is plausibly
  imminent, coordinate before building a workaround in your own job.

## Known non-applicable path

`essen01` (MRI project id `c7cc1c0b-289e-4525-a2a1-6e9638f5b55a`) has
NIfTI-uploaded series (no `DICOMSeries`), so the DICOM-bridge integration
path (`f_20260305_vxannotate_integration`) does not apply to it.

## Hygiene note

Never `git add <dir>` on a job directory that contains untracked
creds/PHI (`run.sh`, `run_debug.sh`, `vxa.json`) — it silently stages them,
and a post-commit `git reset` does not undo an already-pushed commit
(history rewrite via `--amend --force-with-lease` doesn't un-expose a
pushed secret either — rotate it). Keep such files gitignored per job.

## Job layout and SDK-surface split (verified 2026-08-06)

- `f_20260702_vxannotate_sync` has a `handlers/` **directory** (not a
  `handlers.py`); the `PROJECT_HANDLERS` registry lives in `main.py`.
- The job isolates its heavy imaging stack in a dedicated pixi feature
  (`[tool.pixi.feature.vxannotate.dependencies]`, own solve-group) and depends
  on the **published conda** `vxa-sdk >=0.1.5,<0.2` rather than a source build.
  Reuse that shape for any job pulling a large third-party stack.
- Two SDK surfaces coexist in `apps/vxdata-jobs`: ~51 older jobs still build
  `ResourceCreateRequest` objects, which **no longer exist** in current
  `vxdata-schemas`, so those jobs cannot run as written. Copy
  `f_20260612_essen01_histo` instead — it is the current-surface template
  (typed `Client` namespaces, flat schemas). See
  [vxdata-sdk-and-schema-model.md](/knowledge/wiki/workstreams/infrastructure/sops/vxdata-sdk-and-schema-model).

Source: https://docs.virdx.dev/knowledge/wiki/workstreams/infrastructure/sops/vxannotate-vxdata-sync/index.mdx
