Chapter 4
How WIA-BIO-005 describes a cell product as data: the canonical JSON record in spec §8.3.1, the TypeScript model in types.ts, the gap between the two, and the rules that keep a record trustworthy as it moves from the apheresis centre to the manufacturer and on to the infusion ward.
Chapters 2 and 3 described what happens to cells: collection, activation, transduction, expansion, harvest, release testing and freezing. Every one of those steps produces facts, and every fact is written down by someone other than the person who will act on it. The apheresis nurse records the collection; the manufacturing operator records the culture; the QC analyst records viability and sterility; the cryo-inventory technician records the tank position; the hospital pharmacist and the infusion nurse read all of it days or weeks later. For an autologous product, the only acceptable outcome is that the bag infused into a patient contains that patient's own cells, and that every number used to justify the infusion refers to that same bag.
Two ideas capture this requirement. Chain of identity is the unbroken link between a patient (or donor), the starting material, the manufacturing batch and the final container. Chain of custody is the unbroken record of who held the material, where, and under what conditions, at every moment. Paper forms can carry both chains, but only if every party copies identifiers correctly by hand. A machine-readable record shared across organisations removes most of the copying, and it lets software check the chains automatically: an identifier that does not match, a date that runs backwards, or a storage temperature that is too warm can be flagged before a human signs anything.
WIA-BIO-005 addresses this in §8.3 ("Data Formats") with a single JSON example for a cell product, and in the reference SDK with a much richer TypeScript type model. The JSON example is short: it captures an identifier, the cell type, a pseudonymous patient reference, three manufacturing facts, seven quality-control results and three storage facts. That is enough to show the shape of a release record, but not enough to run the five required components of §8.1 on its own. The TypeScript model fills many of the gaps, but it uses different names, different value encodings and some different semantics. This chapter treats both as normative inputs, shows exactly where they diverge, and proposes a mapping discipline so that an implementation can accept the spec's JSON and still work with the SDK.
Scope of this chapter. This chapter is about data shapes only. Formulas for viability, specific lysis and endotoxin per kilogram were derived in Chapter 3, and the algorithms that consume these records (dose calculation, quality grading, batch tracking) are the subject of Chapter 5. Clinical fields such as lymphodepletion regimens appear here only as data structures; no value in an example is a treatment instruction. The current product label and the institutional protocol always take precedence.
The spec's cell product record is reproduced below with annotations. JSON does not allow comments, so the annotations follow in the text below; the code block itself is the exact example from §8.3.1 and parses as valid JSON.
{
"product_id": "CT-2024-001",
"cell_type": "CAR-T CD19",
"patient_id": "PT-12345",
"manufacturing": {
"start_date": "2024-01-15",
"completion_date": "2024-01-29",
"facility": "GMP Suite A"
},
"quality_control": {
"viability": 92.5,
"total_cells": 5.2e8,
"car_positive": 85.3,
"potency": 78.5,
"sterility": "negative",
"endotoxin": 0.25,
"mycoplasma": "negative"
},
"storage": {
"location": "LN2-Tank-3-A5",
"freeze_date": "2024-01-29",
"temperature": -185.5
}
}
The record has three top-level identifiers and three nested groups. Reading it field by field shows both what it says and what a receiving system has to infer.
product_id ("CT-2024-001" in the example) names the product. The spec does not define a format, so the year-plus-sequence pattern should be read as an example only. What matters is that the identifier is unique within the issuing organisation and is never reused, even if a batch fails. cell_type is the free-text string "CAR-T CD19": it combines the product family (CAR-T) with the target antigen (CD19). patient_id ("PT-12345") is a pseudonymous reference to the recipient; it is not a name, a date of birth, or a national identity number, and Section 4.7 explains why it must stay that way.
manufacturing group
start_date and completion_date are calendar dates in the ISO 8601 extended format (YYYY-MM-DD). In the example, 14 days elapse between the two. This fits within the Day 10–14 harvest window described in Chapter 2; the record itself does not state which day was harvest day. facility is a human-readable label for the manufacturing suite. It is useful on a certificate, but it is not an identifier: two sites may both have a "GMP Suite A".
quality_control group
This group holds the release results that Chapter 3 explained. viability (92.5) is a percentage of viable nucleated cells, and passes the ≥85% release criterion of spec §4.2. total_cells (5.2e8) is written in JSON exponent notation, which every conforming JSON parser accepts; it is the total nucleated cell count of the product. car_positive (85.3) is the percentage of cells expressing the CAR, far above the release floor of CAR+ ≥20% for CAR-T products. potency (78.5) is a percentage whose basis the record does not state; under §4.1.3 it would normally be specific lysis at a 10:1 effector-to-target ratio, compared with a ≥70% criterion. sterility and mycoplasma are both the string "negative", meaning that no organism was detected. endotoxin (0.25) is a bare number with no unit, which is the most consequential gap in the record and is discussed in Section 4.5.
storage group
location ("LN2-Tank-3-A5") is a site-specific address inside the cryo-inventory system. freeze_date equals the completion date in the example, which is typical when the product is formulated and frozen on harvest day. temperature (−185.5) is in degrees Celsius. The value is plausible for a vapour-phase liquid nitrogen tank, and it satisfies the §5.3 requirement of −150 °C or colder. Note that a single number can describe only a moment. The record says nothing about excursions between freeze and read-out; that history belongs to the continuous monitoring system, which the record should reference rather than duplicate.
A receiving system that tries to act on this JSON alone quickly finds fields it needs but cannot read: whether the product is autologous or allogeneic; the manufacturing batch identifier; the donor identifier for allogeneic material; the collection date; the viable cell count as measured (only derivable); the identity panel beyond CAR+ (CD3+, for example); the bag volume, without which endotoxin cannot be converted to EU/kg; the number of freeze–thaw cycles; the expiry date; and the signatures that make results into a release decision. Some of these exist in the TypeScript model and some exist nowhere in the standard. An implementer should treat the JSON as the minimum interchange record and should never fill a missing field with a guessed default.
The reference SDK (@wia/bio-005, version 1.0.0) exposes its data model in api/typescript/src/types.ts. The model is broader than the JSON record. It covers the product, its storage, the manufacturing process, quality control, dosing, safety monitoring and cryopreservation. This section walks through the product-side types. The clinical types are covered in Section 4.6.
CellProduct
CellProduct is the central entity. Its required properties are productId, cellType, source, batchId, totalCells, viableCells, viability and dates (with required collection and started, optional completed). Optional properties are patientId, donorId, targetMarker and storage. Three design decisions deserve comment.
cellType is an enumeration with nine values: CAR-T, CAR-NK, TIL, MSC, iPSC, HSC, DC, Treg and Other. The target antigen is not part of the type; it lives in targetMarker.name or in product metadata. The last four values have no counterpart in the spec's §2 product families (Chapter 1 discusses what that gap means).source is required and takes autologous, allogeneic or xenogeneic. The JSON record has no such field, so a converter must obtain it from somewhere else.patientId and donorId are both optional. The type system therefore allows a product with neither. A validator should require patientId for autologous products and donorId for allogeneic ones; this is a business rule the type cannot express.StorageInfo
StorageInfo carries location, temperature (°C), freezeDate, an optional expirationDate and a required freezeThawCycles count. The last two fields have no JSON equivalent, and both matter operationally. A released cryopreserved product should show zero freeze–thaw cycles; any other value is a custody event that QA must explain. expirationDate should come from the product's validated stability data. The spec's stability programme runs to 24 months (§4.3.1), and storage is "validated up to 24 months" (§5.3). That is an upper bound demonstrated for the programme, not a value to stamp automatically on every product.
ManufacturingProcess and ManufacturingStage
ManufacturingProcess describes a batch while it is still being made: batchId, cellType, currentStage, startDate, expectedCompletion, a startingMaterial block (total cells, viability, optional CD3% and CD56%), a parameters block (medium, cytokines, MOI, target expansion, culture days), a currentMetrics block, an array of alerts and the critical process parameters in cpp. currentStage uses the ManufacturingStage enumeration: collection, activation, transduction, expansion, harvest, cryopreservation, complete and failed. The spec's own ManufacturingBatch interface (§8.2.3) lists the first seven stages but not failed. The SDK's extra value is important for data, because a failed batch must remain a record and must never simply disappear. Chapter 6 turns these stages into a state machine with guarded transitions. Here it is enough to note that the stage is a single value; the history of stage changes has to be kept separately if the audit trail is to be reconstructed.
QualityMetrics, IdentityMarkers and PotencyAssay
QualityMetrics is the SDK's release data structure. It includes viability, potency, sterility (boolean), endotoxin (number, commented as EU/mL), mycoplasma (boolean), identity, cellCount (total, viable, optional target-marker-positive) and appearance (colour, clarity, particulates). IdentityMarkers holds two dictionaries, positive and negative, which map marker names to percentages (for example {"CD3": 96.1, "CAR": 85.3}). This shape suits the product-specific panels of spec §4.1.1 better than the spec's own two-number identity object in §8.2.2 (targetMarker, contaminants). PotencyAssay records how potency was measured: type (cytotoxicity, cytokine-secretion, proliferation, other), method, parallel arrays etRatios and specificLysis, an optional cytokine dictionary in pg/mL, a summary potency value, passed and comments.
The pairing of QualityMetrics.potency (one number) with PotencyAssay (the evidence) is a good pattern to copy. A single percentage is convenient for a release dashboard. The assay record is what a reviewer needs to judge whether that percentage means what it claims. When the two are stored together, a lysis curve at several E:T ratios cannot be reduced to one number without anyone noticing which ratio was chosen.
CELLULAR_THERAPY_CONSTANTS collects the thresholds that validators and algorithms use: MIN_VIABILITY 85, MIN_POTENCY 70, MAX_ENDOTOXIN 5 (commented as EU/kg), MIN_RECOVERY 70, dose ranges for CAR-T and MSC in cells/kg, the CPP limits introduced in Chapter 2 and three storage temperatures (CRYOGENIC −150, ULTRA_LOW −80, REFRIGERATED 4). A data layer should import these constants instead of re-typing the numbers. If the thresholds ever change in a new SDK version, one import then updates every validator.
The JSON record and the TypeScript model describe the same product but differ in four systematic ways. First, the naming convention: JSON uses snake_case (product_id), TypeScript uses camelCase (productId). Second, the structure: JSON groups QC results under quality_control on the product, while the SDK keeps them in a separate QualityMetrics object and keeps only viability, counts and target marker on CellProduct. Third, the encoding: JSON writes microbial results as the strings "negative"/"positive", while TypeScript uses booleans whose meaning differs from field to field. Fourth, coverage: each side has fields that the other lacks. The table below is the mapping this book recommends. The "validation rule" column states the checks a receiving system should apply before accepting a value; the thresholds are those of spec §4.2 and §5.3.
| JSON (§8.3.1) | TypeScript | Unit / type | Validation rule |
|---|---|---|---|
product_id | CellProduct.productId | string | Required, non-empty, unique, never reused |
cell_type ("CAR-T CD19") | cellType + targetMarker.name / target antigen | enum + free text | Leading token must match a CellType value; remainder kept as target, not discarded |
patient_id | patientId | pseudonymous string | Required for autologous; must not contain direct identifiers |
| — | source | CellSource | Required in TS; must be supplied by the sender's context, never defaulted |
| — | batchId | string | Required in TS; links to ManufacturingProcess |
| — | donorId | pseudonymous string | Required for allogeneic |
| — | dates.collection | date | Required in TS; ≤ dates.started |
manufacturing.start_date | dates.started | ISO 8601 date → Date | Valid calendar date; ≤ completion |
manufacturing.completion_date | dates.completed | ISO 8601 date → Date | ≥ start; ≤ freeze date |
manufacturing.facility | CertificateOfAnalysis.manufacturing.facility | string label | Informative; pair with a site identifier |
quality_control.viability | viability, QualityMetrics.viability | % (0–100) | Range 0–100; release ≥85 |
quality_control.total_cells | totalCells, QualityMetrics.cellCount.total | cells (number) | Finite, > 0 |
| (derived) | viableCells | cells | Prefer measured value; if derived, flag as derived |
quality_control.car_positive | targetMarker = {name: "CAR", percentage}, identity.positive.CAR | % (0–100) | Range 0–100; CAR-T release ≥20 |
quality_control.potency | QualityMetrics.potency (+ PotencyAssay) | % (basis must be stated) | Range 0–100; ≥70 when basis is specific lysis at 10:1 |
quality_control.sterility ("negative") | QualityMetrics.sterility | boolean, true = pass | Only "negative" maps to true; anything else → false |
quality_control.endotoxin | QualityMetrics.endotoxin | unitless in JSON; EU/mL in TS comment | Reject unless unit and product volume are known (see Section 4.5) |
quality_control.mycoplasma ("negative") | QualityMetrics.mycoplasma | boolean, false = pass | Only "negative" maps to false; anything else → true |
storage.location | storage.location | string | Must resolve in the cryo-inventory system |
storage.freeze_date | storage.freezeDate | ISO 8601 date → Date | ≥ start date |
storage.temperature | storage.temperature | °C | ≤ −150 for cryopreserved product |
| — | storage.freezeThawCycles | integer | Required in TS; ≥ 0; expected 0 at release |
| — | storage.expirationDate | date | From validated stability data, not a fixed offset |
The function below turns a spec-format JSON record into a CellProduct. It is deliberately strict. Fields that the JSON cannot supply (source, batchId, collection date, freeze–thaw count) must be passed in by the caller from an authoritative system, such as the apheresis order or the MES batch record, rather than invented. Any value that fails validation raises CellularTherapyError with code C010 (INVALID_PARAMETERS), the same code the SDK uses for bad inputs to calculateDose.
import {
CellProduct, CellType, CellSource,
CellularTherapyError, CellularTherapyErrorCode,
} from '@wia/bio-005';
// Shape of the spec §8.3.1 record (only the fields this sketch reads)
interface CellProductJson {
product_id: string;
cell_type: string;
patient_id?: string;
manufacturing: { start_date: string; completion_date?: string; facility?: string };
quality_control: { viability: number; total_cells: number; car_positive?: number };
storage?: { location: string; freeze_date: string; temperature: number };
}
// Facts the JSON does not carry; must come from an authoritative system
interface SenderContext {
source: CellSource;
batchId: string;
collectionDate: string; // ISO 8601
donorId?: string;
freezeThawCycles: number; // from cryo-inventory custody log
}
const CELL_TYPES: readonly CellType[] =
['CAR-T', 'CAR-NK', 'TIL', 'MSC', 'iPSC', 'HSC', 'DC', 'Treg', 'Other'];
function invalid(field: string, value: unknown): CellularTherapyError {
return new CellularTherapyError(
CellularTherapyErrorCode.INVALID_PARAMETERS,
`Invalid ${field}`, { field, value });
}
function isoDate(s: string, field: string): Date {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) throw invalid(field, s);
const d = new Date(`${s}T00:00:00Z`); // explicit UTC midnight
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== s) {
throw invalid(field, s); // rejects 2024-02-30
}
return d;
}
function percent(v: unknown, field: string): number {
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 100) {
throw invalid(field, v);
}
return v;
}
export function toCellProduct(j: CellProductJson, ctx: SenderContext): CellProduct {
const [head, ...rest] = j.cell_type.trim().split(/\s+/);
if (!CELL_TYPES.includes(head as CellType)) throw invalid('cell_type', j.cell_type);
const target = rest.join(' ') || undefined; // e.g. "CD19"
if (ctx.source === 'autologous' && !j.patient_id) throw invalid('patient_id', undefined);
if (ctx.source === 'allogeneic' && !ctx.donorId) throw invalid('donorId', undefined);
const qc = j.quality_control;
const viability = percent(qc.viability, 'viability');
if (!Number.isFinite(qc.total_cells) || qc.total_cells <= 0) {
throw invalid('total_cells', qc.total_cells);
}
const collection = isoDate(ctx.collectionDate, 'collectionDate');
const started = isoDate(j.manufacturing.start_date, 'start_date');
const completed = j.manufacturing.completion_date
? isoDate(j.manufacturing.completion_date, 'completion_date') : undefined;
if (collection > started) throw invalid('collectionDate', ctx.collectionDate);
if (completed && completed < started) throw invalid('completion_date', completed);
const product: CellProduct = {
productId: j.product_id,
cellType: head as CellType,
source: ctx.source,
patientId: j.patient_id,
donorId: ctx.donorId,
batchId: ctx.batchId,
totalCells: qc.total_cells,
viableCells: qc.total_cells * viability / 100, // derived; see Table 4-1
viability,
dates: { collection, started, completed },
};
if (qc.car_positive !== undefined) {
product.targetMarker = { name: target ? `CAR (${target})` : 'CAR',
percentage: percent(qc.car_positive, 'car_positive') };
}
if (j.storage) {
product.storage = {
location: j.storage.location,
temperature: j.storage.temperature,
freezeDate: isoDate(j.storage.freeze_date, 'freeze_date'),
freezeThawCycles: ctx.freezeThawCycles,
// expirationDate intentionally left unset: assign from stability data
};
}
return product;
}
A few details in the sketch are intentional. The target antigen that follows "CAR-T" is kept in the marker name rather than thrown away, because a CD19 product and a BCMA product must never be interchangeable in any downstream check. Dates are parsed as explicit UTC midnight and then round-tripped, so that an impossible date such as 30 February is rejected rather than silently rolled over to 1 March, which is what the Date constructor does in V8-based runtimes such as Node.js. The derived viable count is computed but labelled as derived; if the counter reported a measured viable count, that value should be used instead. expirationDate is left unset on purpose, which is safer than copying the SDK's certificate generator (see Section 4.5).
The reverse direction — exporting a CellProduct as spec JSON for a partner who expects §8.3.1 — loses information. source, batchId, donorId and freezeThawCycles have nowhere to go. An exporter should therefore add them as clearly namespaced extension fields (for example under an "x_wia_ext" object agreed with the partner) instead of dropping them. The spec does not define an extension mechanism, so the name used is a bilateral agreement and should be documented as such.
Most failures in data exchange do not come from missing fields. They come from values that are present but mean something different to the reader than to the writer. The conventions below are those that WIA-BIO-005 implies, with the pitfalls that the spec and SDK leave open.
Every percentage in the standard — viability, CAR+, potency, recovery, identity markers — is expressed on a 0–100 scale, never as a fraction on 0–1. The SDK enforces this for viability in calculateDose, where a value outside 0–100 throws C010. The same range check should apply to every percentage field. A value of 0.925 for viability is not "92.5%" typed by a careless user; it is a 0.925% viability that would, correctly, fail release. Validators should hold values in (0, 1] for manual confirmation, with a message that suggests a possible scale error, instead of silently multiplying them by 100.
Cell counts are ordinary numbers written in exponent notation where convenient (5.2e8). An IEEE 754 double represents integers exactly up to 253, far beyond any cell count, so floating-point storage loses nothing. The practical risk lies elsewhere: counts must state their denominator. "Total cells" in §8.3.1 is total nucleated cells in the product; "cells/kg" is a dose; "cells/mL" is a concentration (as in CryopreservationProtocol.cellConcentration and the CPP cell density). The field name should always make the denominator explicit. totalCells and cellDensity do this, but a bare count would not.
All temperatures are in degrees Celsius, including negative storage temperatures. A validator for cryopreserved products should compare against CELLULAR_THERAPY_CONSTANTS.STORAGE.CRYOGENIC (−150), and must remember that "colder" means "more negative": the check is temperature <= -150. A positive value in a storage field is almost certainly a sign error or a unit error, and should be rejected rather than flagged as a warning.
The release criterion in spec §4.2 is endotoxin <5 EU/kg of body weight per dose. The JSON record carries "endotoxin": 0.25 with no unit. QualityMetrics.endotoxin is commented as EU/mL, while CELLULAR_THERAPY_CONSTANTS.MAX_ENDOTOXIN is commented as EU/kg, and the SDK compares the two directly. A concentration and a per-kilogram dose cannot be compared without two more facts: the volume administered and the patient's weight. Chapter 3 derived the conversion (EU/mL × volume in mL ÷ body weight in kg). The data-model consequence is that the product record must carry both the measured concentration with its unit and the final container volume, so that the per-kg figure can be computed when the patient's weight is known.
"quality_control": {
"endotoxin": { "value": 0.25, "unit": "EU/mL" },
"container_volume_ml": 50
}
The fragment above is a recommended extension, not part of §8.3.1; the 50 mL volume is an example value. A receiving system that encounters a bare number should reject the record for release purposes (C010), rather than guess the unit. A guess in the permissive direction releases a product whose endotoxin load is unknown; a guess in the strict direction rejects good product. Neither is acceptable in a QC record. The 5 EU/kg limit is the general limit for parenterally administered products. If a record describes a non-intravenous route — AdministrationRoute includes intrathecal — the applicable limit is much lower (0.2 EU/kg for intrathecal administration), which is one more reason why endotoxin cannot be judged without route and dose context.
JSON dates are ISO 8601 strings; TypeScript uses Date objects. Two traps follow. First, under the ECMAScript specification a date-only string such as "2024-01-15" is parsed as UTC midnight, but a date-time string without an offset is parsed as local time. The same record can therefore yield different instants on servers in different time zones. Second, a UTC-midnight date displayed in a time zone west of Greenwich shows the previous calendar day. The safe convention is to store calendar dates as date-only strings, store instants (freeze start, thaw start, infusion start) as full date-times with an explicit offset or "Z", and never mix the two in one field. For time-critical intervals, such as time from thaw to infusion, which the spec requires to be under 4 hours, only full instants are acceptable.
The SDK's two microbial booleans point in opposite directions. sterility: true means the sterility test passed (no growth), and the SDK checks metrics.sterility === true. mycoplasma: false means mycoplasma was not detected, and the SDK checks metrics.mycoplasma === false. The JSON record avoids the ambiguity by writing "negative" for both, but a converter that maps "negative" to false for both fields will fail every sterile product, and a converter that maps it to true for both will pass every contaminated one. The second failure is silent and dangerous. Table 4-2 fixes the mapping. Where the schema is under your control, prefer an explicit result string for both tests, as the JSON already does.
| Test | JSON (§8.3.1) | SDK boolean | Meaning | Release |
|---|---|---|---|---|
| Sterility | "negative" | true | No growth detected | Pass |
| Sterility | "positive" | false | Growth detected | Fail (C003) |
| Mycoplasma | "negative" | false | Not detected | Pass |
| Mycoplasma | "positive" | true | Detected | Fail |
| Either | missing, "pending", other | — | No final result | Not releasable; do not map to a boolean |
The last row matters most in practice. A sterility culture takes 14 days (Chapter 3), and a product may be shipped under conditions defined by the manufacturer's approved process before that result is final. A record must be able to say "pending" explicitly. A boolean cannot, so an interim record needs a result string or a nullable field. Squeezing "pending" into false or true destroys exactly the information QA needs.
The SDK's generateCertificate fills several fields with fixed values: an expiry 24 months after the freeze date, the approver "QC Manager", the facility "GMP Manufacturing Suite", a storage temperature of −150 when none is given, and the shipping condition "Dry ice or liquid nitrogen vapor phase". None of these is a measured or approved fact. The shipping text is actively wrong for cryopreserved cell products: dry ice sublimates at about −78 °C, far warmer than the ≤−150 °C storage requirement, so only liquid-nitrogen vapour-phase shippers are consistent with the spec. A data layer should treat the certificate generator's output as a template for QA to complete, and should mark each defaulted field as "unset" until a person with authority supplies the real value.
Once a product leaves the manufacturer, the data model shifts from the bag to the patient. types.ts defines a set of clinical and cryobiology structures that sit alongside CellProduct. They are described here as data only. How they are filled and acted on is the subject of Chapters 6 and 7, and every clinical value that appears in them is determined by the current product label and institutional protocol, not by this book.
PatientDose and AdministrationRoute
PatientDose binds a patient to a product: patientId, productId, cellType, the computed dose (a full DoseResult, including the recommended range and its unit, either cells/kg or total cells), route, scheduledDate, optional actualDate, optional preMedications and an optional lymphodepletion regimen. AdministrationRoute enumerates intravenous, intra-arterial, intrathecal, intraperitoneal and local-injection. Storing the whole DoseResult rather than a single number is sound practice. The dose record then also shows which range the dose was judged against and in which unit, and this is what exposes unit problems such as the TIL total-dose issue discussed in Chapter 5.
LymphodepletionRegimen
A regimen has a name, an array of drugs (each with name, a free-text dose string and a list of days), startDate, completionDate and daysBeforeInfusion. The free-text dose is a weakness for automated checking, but it is honest: dosing units vary between agents (for example per square metre of body surface area versus per kilogram), and a string at least forces the prescriber's own wording to be recorded. An implementation that needs machine-checkable doses should add structured value, unit and basis fields rather than parse the string. The example below shows the structure only; dose strings are placeholders.
const regimen: LymphodepletionRegimen = {
name: 'Flu/Cy',
drugs: [
{ name: 'fludarabine', dose: '[per institutional order set]', days: [-5, -4, -3] },
{ name: 'cyclophosphamide', dose: '[per institutional order set]', days: [-5, -4, -3] },
],
startDate: new Date('2024-02-05T08:00:00+09:00'),
completionDate: new Date('2024-02-07T12:00:00+09:00'),
daysBeforeInfusion: 3,
};
// Illustrative structure only. Not a treatment instruction: the current
// product label and institutional protocol determine agents, doses and days.
Note that days uses a relative convention (negative numbers before infusion day 0), while daysBeforeInfusion is a positive count. The type does not enforce consistency between them. A validator can do so: with day 0 as infusion, the gap after the last dosing day should equal daysBeforeInfusion under the site's counting convention, and the convention itself should be documented.
CRSAssessment and ICANSAssessment
Both are time-stamped observations of one patient, keyed by patientId, timestamp and daysPostInfusion, with a grade constrained to 0–4. CRSAssessment records temperature (°C), hypotension, vasopressors (none / single / multiple), hypoxia, oxygenRequirement (none / low-flow / high-flow / ventilator), optional biomarkers (CRP in mg/dL, ferritin in ng/mL, IL-6 and IFN-γ in pg/mL) and an optional CRSTreatment. ICANSAssessment records iceScore, the iceComponents, consciousness, seizures, motorFindings, cerebralEdema and an optional ICANSTreatment.
One data-quality issue needs mention here, because it lies in the type itself. The iceComponents object has four fields — orientation 0–4, naming 0–3, following 0–2, writing 0–1 — and no attention field. The ICE score as defined in the ASTCT 2019 consensus has five domains: orientation (4 points), naming (3), following commands (1), writing (1) and attention (1). The SDK type therefore cannot represent a valid ICE breakdown. An implementation should add an attention field, constrain following to 0–1, and validate that the components sum to iceScore. Chapter 7 covers the clinical side of this erratum.
SafetyEvent
SafetyEvent is the general adverse-event record: eventId, patientId, type (CRS, ICANS, infection, cytopenia, other), severity 1–5, description, onsetDate, optional resolutionDate, treatment and outcome (resolved, resolving, ongoing, fatal), a serious flag and a related causality scale (definite, probable, possible, unlikely, unrelated). Two fields are easily confused. severity is intensity, on a 1–5 scale in the style of common toxicity grading, where 5 conventionally denotes death. serious is a regulatory classification (in the sense of ICH E2A: results in death, is life-threatening, requires or prolongs hospitalisation, and so on). A severe event need not be serious, and a serious event need not be severe. Keeping both fields, as the type does, is correct. A validator can still enforce the obvious cross-check: severity: 5 or outcome: 'fatal' implies serious: true.
SafetyEvent has no productId. For an autologous patient with one infusion this can be inferred through PatientDose; for a patient who has received more than one product, it cannot. An implementation that reports to pharmacovigilance (WIA-PHARMA, Chapter 7) should add the product reference explicitly.
CryopreservationProtocol records a protocolId, a cryoprotectant object (dmso, optional hsa and dextran, all in %, plus free-text other), freezingMethod, optional coolingRate (°C/min), targetTemperature, storageTemperature, containerType (cryovial, cryobag, straw), fillVolume (mL) and cellConcentration (cells/mL). The percentage fields need one rule that the type leaves unstated: whether each value is a final concentration in the frozen product or the proportion of a component solution. Without that rule, two sites can record the same formulation with different numbers. The protocol document should state the basis, and validators should reject combinations that cannot coexist on the declared basis. Chapter 6 describes the formulation qualitatively.
ThawingProtocol records method (water-bath, dry-thaw, bedside), temperature, duration in minutes, washSteps, optional dilutionMedia and restTime, and qualityCheck. PostThawQuality records viability, recovery, potencyRetained, acceptable and optional timeToInfusion in hours. Mixed time units (minutes for thaw duration, hours for time to infusion) are a classic source of error. A data dictionary should state the unit of every duration field, and user interfaces should display the unit next to the value.
The diagram below shows how the product-side and patient-side records connect. Cardinalities follow the most common autologous case; allogeneic products add a donor and allow one batch to serve several patients.
Donor (allogeneic) Patient (pseudonymous patientId)
| 1 | 1 | 1 | 1
| N | N | N | N
+------------+ +---------+ CRSAssessment / SafetyEvent
| | ICANSAssessment (+ productId, ext.)
v v
ManufacturingProcess (batchId) --- 1:N --- Alert
| 1 \
| \ 1:N
| N QualityMetrics / PotencyAssay
CellProduct (productId) (in-process + release)
| 1 | 1 \
| 1 | 0..1 \ 1:0..1
StorageInfo CertificateOfAnalysis PatientDose ---- 1:0..1 --- LymphodepletionRegimen
(+ custody log) |
+-- ThawingProtocol / PostThawQuality
Three edges are not explicit in the SDK types and have to be added by the implementation: a custody log behind StorageInfo (every move, every excursion), the product reference on SafetyEvent, and the link from PatientDose to the thaw and post-thaw records for the specific bag infused.
A receiving system should validate every record in three layers, and should report failures from each layer differently.
patientWeight <= 0), dates are ordered (collection ≤ start ≤ completion ≤ freeze), storage temperatures are ≤ −150 °C for cryopreserved product, the ICE components sum to the ICE score, and the donor or patient reference matches the declared source. These failures also mean that the record is wrong. They are data errors, not quality failures.Keeping layers 2 and 3 apart is the single most useful discipline in this chapter. A viability of 84.9% is valid data that fails release; a viability of 184.9% is invalid data. A system that reports both as "viability failure" hides a transcription error behind a quality event, or the reverse, and each case needs a different investigation.
| Record | Additionally required by business rule | Reason |
|---|---|---|
CellProduct (autologous) | patientId | Chain of identity to the recipient |
CellProduct (allogeneic) | donorId | Traceability to donor screening (Chapter 2) |
CellProduct at release | storage with expirationDate | C009 cannot be checked without it |
QualityMetrics at release | Endotoxin unit and container volume | Criterion is per kg of body weight |
QualityMetrics.potency | Linked PotencyAssay | The percentage is meaningless without method and E:T |
SafetyEvent | Product reference | Attribution when more than one product was given |
| Any time-critical interval | Full date-time with offset | Thaw-to-infusion must be computable |
The spec's example uses "patient_id": "PT-12345", and this book recommends treating that field as a pseudonym by design. The product record travels between organisations — apheresis centre, manufacturer, courier, hospital — and some of them have no need to know who the patient is. They need only to know that the bag in hand belongs to the same person as the order. A pseudonym issued by the treating institution (or by the manufacturer's patient-scheduling system) provides that link. The mapping from pseudonym to real identity stays inside the clinical systems that already hold it. Under the EU GDPR, pseudonymised data is still personal data (the key exists somewhere), so pseudonymisation reduces exposure but does not remove obligations. Comparable reasoning applies under the US HIPAA framework and Korea's Personal Information Protection Act.
Pseudonymous is not the same as meaningless. At the bedside, the identity check (Chapter 6) compares the bag label with the patient in front of the nurse, using identifiers that the hospital recognises. Labelling schemes for medical products of human origin, such as ISBT 128, exist for this purpose, but WIA-BIO-005 does not mandate one. What the standard's data model should guarantee is that the product record's patient reference can always be resolved, by the hospital and only by those entitled to do so, to the person in the bed.
notes and comments should be screened for identifiers before the record leaves the originating system.Cell therapy records are subject to jurisdiction-specific retention requirements, and the correct period depends on the product's regulatory status and the country of use. WIA-BIO-005 does not fix a retention period, and an implementation should not hard-code one. Retention should be a per-jurisdiction configuration owned by QA and regulatory affairs (Chapter 8 discusses the regulatory map). Whatever the period, the audit trail must outlive every record it explains. Each change to a release-relevant field should record the previous value, the new value, who made the change, when, and why. A validator that silently "fixes" a value, such as scaling 0.925 to 92.5, breaks this rule, which is another reason for the strict converter in Section 4.4.
Chapter summary.
types.ts defines a richer model with different names, encodings and coverage.true and mycoplasma false both mean "pass"; "pending" is a third state that a boolean cannot hold.