Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 32 additions & 0 deletions .changeset/first-minor-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@cosyte/astm": minor
---

**This is 0.1.0, the first release whose public API we treat as settled.**

What is covered, and what you can build against:

- Reading ASTM E1394 (CLSI LIS02-A2) records: `H`, `P`, `O`, `R`, `C`, `Q`, `M`, `S` and `L`, with
the delimiters each header declares, escape sequences decoded before a value is split, values and
units surfaced exactly as sent, abnormal flags and result status interpreted without guessing, and
every tolerated deviation reported as a stable warning code instead of a thrown error.
- Framing and transport per ASTM E1381 (CLSI LIS01-A2): checksum-verified frame decoding and
composing, the 240-byte split, framed versus raw detection, and a pure receiver state machine for
the `ENQ`/`ACK`/`NAK`/`EOT` exchange that never acknowledges a frame it could not verify.
- Emitting spec-clean records and frames (`buildAstmMessage`, `serializeAstmRecords`,
`serializeFramedAstm`), which write only the values you supply and never a default clinical value.
- Mapping analyzer local codes to LOINC through a LIVD catalog you supply, and date conversions
(`toObject`, `toISO`, `toDate`) that never assume a timezone.

What the version promises. The exported names, options, return shapes and warning codes are the
surface we keep stable. While the package is below 1.0, a breaking change bumps the minor version
(0.1 to 0.2) and is called out in this changelog with its migration; a fix that changes no public
value ships as a patch. Upgrading from 0.0.x is itself breaking in the LIVD mapping and in what
`primaryCode()` returns: read the entries below before you upgrade.

What is not covered yet. No named per-vendor profile ships: the built-in set is `default` and
`referenceCorpus`. No LOINC, SNOMED or LIVD data is bundled, and no LOINC is validated. The
transfer protocol's timers (contention, timeouts, retransmit) are yours to drive. The wire is read as
Latin-1. Three behaviors (forward scoping of redeclared delimiters, the Latin-1 encoding and the
reserved-byte set) are reasoned from this package's reader rather than cited to the standard's
purchase-gated text.
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,25 @@ jobs:
uses: cosyte/.github/.github/workflows/ci.yml@main
with:
run-phi-scan: true

# The runnable examples under examples/, checked the way a consumer meets them: each imports
# `@cosyte/astm` by name, which resolves through package.json `exports` to the BUILT dist/, so
# the build runs first. Each example asserts its own output, so `pnpm examples` goes red when one
# drifts from the package. The PHI scan's default walk (src/, test/, scripts/, docs-content/)
# does not reach examples/, so the example files are named to it here.
examples:
runs-on: ubuntu-latest
steps:
# Actions are pinned to a commit rather than to a tag that can move.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm typecheck:examples
- run: pnpm lint:examples
- run: pnpm examples
- run: pnpm phi-scan:examples
30 changes: 22 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,39 @@ this file is maintained by hand (Changesets handles the version bump and publish

## [Unreleased]

**What 0.1 means for you.** This release is the first whose public API we treat as settled: the
exported names, options, return shapes and warning codes are the surface we keep stable. What it
covers is reading ASTM E1394 (CLSI LIS02-A2) records with the delimiters each header declares,
checksum-verified E1381 (CLSI LIS01-A2) framing with a pure receiver state machine for the
`ENQ`/`ACK`/`NAK`/`EOT` exchange, spec-clean emit of records and frames that writes only the values
you supply, LOINC mapping through a LIVD catalog you supply, and date conversions that never assume
a timezone. While the package is below 1.0, a breaking change bumps the minor version and is called
out here with its migration; a fix that changes no public value ships as a patch. Upgrading from
0.0.x is itself breaking in the LIVD mapping and in what `primaryCode()` returns, as the entries
below set out. Not covered yet: no named per-vendor profile ships (the built-in set is `default` and
`referenceCorpus`), no LOINC, SNOMED or LIVD data is bundled and no LOINC is validated, the transfer
protocol's timers are yours to drive, and three behaviors (forward scoping of redeclared delimiters,
the Latin-1 wire encoding and the reserved-byte set) are reasoned from this package's reader rather
than cited to the standard's purchase-gated text.

**The pending changeset set is classified `minor`, not `patch`, so the next release is a minor
release.** Seven changesets are pending and five of them arrived carrying `patch`. Three of those
release.** Eight changesets are pending and five of them arrived carrying `patch`. Three of those
five were reclassified against their own text: one removes public values and changes what an
exported function returns, and two add public values. None of the three is a fix that adds nothing
and removes nothing, which is the only thing a `patch` may claim, and only those three bump lines
moved. The other two keep `patch`: one changes repository tooling and install configuration, the
other changes what a release carries beside the package, and neither touches a published value or
emits a byte differently, so `patch` is what each one's own text supports. The last two arrived
emits a byte differently, so `patch` is what each one's own text supports. Two more arrived
carrying `minor` already, each written against this same rule, so both are applied as written rather
than reclassified. A set carrying a `patch` beside a `minor` still resolves to the
minor channel.
than reclassified. The eighth is the release statement summarized above, which carries `minor` as the
class of the release it describes and changes no public value. A set carrying a `patch` beside a
`minor` still resolves to the minor channel.

The breaking change below therefore ships in the minor channel of the pre-1.0 ladder, which is where
a break belongs before `1.0.0`, rather than as a patch. Every break a consumer of the last published
version would see is enumerated with its migration in `documentation/release-readiness.md`, together
with the public export surface this release would certify as settled. **Each break awaits a decision
before any release, and nothing publishes yet**: the release environment gate stays as it is, and no
version number is written here, because Changesets owns the bump and `scripts/sync-version.mjs`
mirrors it.
with the public export surface this release certifies as settled. No version number is written in
this section's entries, because Changesets owns the bump and `scripts/sync-version.mjs` mirrors it.

The entries the pending set carries are the date conversion surface entry, the LIVD units entry, the
LIVD publication metadata entry and the vocabulary-attribution entry under Added, and the LIVD
Expand Down
59 changes: 37 additions & 22 deletions documentation/release-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,15 @@ The published version is the `version` field of `package.json`, `0.0.22`, on the
ladder. The pending set below is every file matching `.changeset/*.md` other than `README.md`
(`config.json` is the tool's configuration and carries no bump).

Five of the seven pending changesets arrived classified `patch`, and two arrived carrying `minor`.
Five of the eight pending changesets arrived classified `patch`, and three arrived carrying `minor`.
Three of the five are reclassified to `minor` here, each for a reason taken from its own text; their
bump lines are the only lines that were rewritten, and no changeset's prose was edited. The other
two stay `patch`, because neither one's text removes a public value, adds one, or changes any
exported behaviour: reclassification runs in both directions or it is not a reading, and a tooling
change written up as a feature would overstate the release exactly as a removal written up as a fix
understates one. The two that arrived carrying `minor` were each written against this same rule, so
both are applied as written rather than reclassified.
understates one. Two that arrived carrying `minor` were each written against this same rule, so
both are applied as written rather than reclassified. The third, the release statement, is read last
below.

<!-- audit:begin -->

Expand All @@ -46,6 +47,7 @@ both are applied as written rather than reclassified.
| `publish-a-dependency-inventory-with-each-release.md` | `patch` | `patch` |
| `say-which-vocabulary-a-letter-was-graded-against.md` | `patch` | `minor` |
| `shared-date-conversion-surface.md` | `minor` | `minor` |
| `first-minor-release.md` | `minor` | `minor` |

<!-- audit:end -->

Expand Down Expand Up @@ -193,6 +195,16 @@ is not a break either.
A feature that adds public values is `minor` by the same rule that keeps a real fix at `patch`, so
this one carries `minor` as written and needs no reclassification.

### `first-minor-release.md`: `minor`

This is the release statement rather than a change to the surface: its own text adds no public
value, removes none, and changes no exported behaviour. It tells a consumer what the resolved version
means: the surface section 3 certifies, the promise that below 1.0 a break bumps the minor and is
called out with its migration, the breaks an upgrade from `0.0.x` carries (section 4), and what is
not covered yet. It carries `minor` as the class of the release it describes. Applying it moves
nothing, because the four changesets above that add or remove public values already resolve the set
to `0.1.0`.

## 2. Why no changeset in this set is classified `major`

No changeset in the set is `major`, and this is a decision rather than an omission.
Expand Down Expand Up @@ -641,9 +653,9 @@ The certification is scoped, deliberately, and the scope is the whole of its hon
## 4. Break candidates, awaiting the operator

Every entry below is a change a consumer of `0.0.22` will see. **None of them is decided here.**
The operator decides, per repo, before any release; this file surfaces them so that decision has
something to read. Nothing in this list may be read as approval, and nothing publishes until
section 5's precondition is met.
They were held for the operator, and this file surfaces them so that decision had something to
read. Nothing in this list is itself an approval: the operator's direction to publish `0.1.x`, which
ships them, is recorded in section 6.

Entries 1 to 4 are the public values the LIVD changeset removes or redefines, and `primaryCode()`
is the one that produces **no compile error at all**.
Expand Down Expand Up @@ -855,22 +867,25 @@ modules behind it.

<!-- unresolved:end -->

## 6. Publication is blocked, and the bump is prepared rather than published

**Nothing here publishes anything, and the bump prepared on this branch is not a release.**

- **The precondition that is not met.** Publication is blocked until the release-frequency policy
work lands (`S0161-release-frequency-policy` in the meta-repo). Until it does, nothing publishes,
by the operator's own decision of 2026-08-28, which also declined the offered route of
hand-approving the release environment to relieve deadline pressure.
- **What merging this branch does do.** `.github/workflows/release.yml` fires on a push to `main`
and calls the shared release pipeline, which parks on the `release` environment gate. That is the
current state of every merge into this repo and this work does not change it. This work must not
approve, drain, retrigger or otherwise relieve that gate, and does not.
- **So the honest reading of this branch** is: the pending set now resolves to `0.1.0` instead of
`0.0.23`, the surface that number would certify is on record, and the breaks a consumer would see
are enumerated and waiting on a decision. The version in `package.json` is still `0.0.22` and will
stay `0.0.22` until Changesets writes the next one.
## 6. Publication

**Nothing in this file publishes anything, and the bump prepared here is not a release until the
release pipeline cuts it.**

- **The earlier block, and the direction that replaced it.** Publication was blocked by the
operator's decision of 2026-08-28 until the release-frequency policy work landed
(`S0161-release-frequency-policy` in the meta-repo). On 2026-09-25 the operator directed that
every package be put on `0.1.x` and published, in these words: "I need every single package on
v0.1.x and published." For this package the only `0.1.x` is the pending set above, so the breaks
in section 4 ship in `0.1.0` as enumerated there, each with its migration in `CHANGELOG.md`.
- **What merging a change here does.** `.github/workflows/release.yml` fires on a push to `main`
and calls the shared release pipeline, which opens or updates the "Version Packages" pull request
and parks the publish on the `release` environment gate. Merging that pull request and approving
that gate belong to whoever runs the release, never to a change that edits this file.
- **So the honest reading** is: the pending set resolves to `0.1.0` instead of `0.0.23`, the
surface that number certifies is on record, and the breaks a consumer will see are enumerated. The
version in `package.json` is still `0.0.22` and stays `0.0.22` until Changesets writes the next
one.

### The follow-on this bump creates, recorded so it is not lost

Expand Down
26 changes: 26 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Examples

Small runnable programs, one per job the package does. Each one imports `@cosyte/astm` by its
published name, so it runs against the built package exactly as a consumer installs it, prints what
it read, and checks its own output: a mismatch exits non-zero. Every record in them is synthetic.

Build once, then run them all:

```bash
pnpm install
pnpm build
pnpm examples
```

| File | What it shows | Run |
| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| [`read-results.ts`](read-results.ts) | Parse a de-framed record stream and read each result's value, units, flag and status, plus the two patient IDs kept distinct. | `pnpm tsx examples/read-results.ts` |
| [`build-and-frame.ts`](build-and-frame.ts) | Build spec-clean records from typed input, frame them with checksums, read them back, and see a corrupted frame refused. | `pnpm tsx examples/build-and-frame.ts` |
| [`receive-over-ltp.ts`](receive-over-ltp.ts) | Drive the receiver state machine: `ACK` good frames, `NAK` a damaged one, accept the retransmission, deliver the records. | `pnpm tsx examples/receive-over-ltp.ts` |
| [`map-local-codes-to-loinc.ts`](map-local-codes-to-loinc.ts) | Map analyzer local codes to LOINC with your own LIVD catalog: one candidate, two candidates settled by units, and an unmapped code. | `pnpm tsx examples/map-local-codes-to-loinc.ts` |

`data/result-stream.astm` is the input `read-results.ts` reads: one synthetic patient, one order and
two results, copied from the repository's test fixtures.

CI runs `pnpm typecheck:examples`, `pnpm lint:examples` and `pnpm examples` after `pnpm build` on
every pull request, so an example that drifts from the package fails the build.
96 changes: 96 additions & 0 deletions examples/build-and-frame.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Build spec-clean records, frame them for the wire, and read them back.
*
* `buildAstmMessage` emits only the values you supply: an omitted result status stays empty and
* reads back as `unspecified`, never as a final result. `serializeFramedAstm` wraps each record in
* a numbered frame with a computed checksum, and `parseFramedAstm` decodes the frames and parses
* the records they carry. A frame whose checksum fails is reported and its record never reaches
* the parser. Every value here is synthetic.
*
* Run from the repository root after `pnpm build`:
*
* pnpm tsx examples/build-and-frame.ts
*/

import assert from "node:assert/strict";

import {
buildAstmMessage,
parseAstmRecords,
parseFramedAstm,
results,
serializeFramedAstm,
} from "@cosyte/astm";

const text = buildAstmMessage({
header: { fields: ["", "", "analyzer^1"] },
records: [
{ type: "P", practiceAssignedId: "PRAC-0001", laboratoryAssignedId: "LAB-0009" },
{ type: "O", specimenId: "SPEC-7", universalTestId: ["", "", "", "687"] },
{
type: "R",
universalTestId: ["", "", "", "687"],
value: "28.6",
units: "U/L",
referenceRange: "10-40",
abnormalFlags: "N",
resultStatus: "F",
},
// No status supplied, so none is written.
{ type: "R", universalTestId: ["", "", "", "688"], value: "5.1", units: "mmol/L" },
],
terminationCode: "N",
});

console.log("Records as built (one per line):");
console.log(text.trimEnd().split("\r").join("\n"));

// Frame the records: STX, frame number, text, ETX, checksum, CR LF.
const framed = serializeFramedAstm(parseAstmRecords(text));
const decoded = parseFramedAstm(framed);
console.log("Frames:", decoded.frames.length);
console.log(
"Every checksum valid:",
decoded.frames.every((f) => f.checksum.valid),
);
console.log(
"Results read back:",
results(decoded.message).map(
(r) => `${String(r.value)} ${String(r.units)} (${r.status.meaning})`,
),
);

// Corrupt one byte of the first result's value in transit: that frame is refused, not trusted.
const damaged = framed.slice();
damaged[Buffer.from(damaged).indexOf("28.6")] = "3".charCodeAt(0);
const fromDamaged = parseFramedAstm(damaged);
console.log(
"After corrupting one byte:",
fromDamaged.frameWarnings.map((w) => w.code),
"results left:",
results(fromDamaged.message).map((r) => r.value),
);

// The checks that make this file a test: `pnpm examples` fails if any of them does not hold.
assert.equal(
text,
"H|\\^&|||analyzer^1\rP|1|PRAC-0001|LAB-0009\rO|1|SPEC-7||^^^687\r" +
"R|1|^^^687|28.6|U/L|10-40|N||F\rR|2|^^^688|5.1|mmol/L\rL|1|N\r",
);
assert.equal(decoded.frames.length, 6);
assert.ok(decoded.frames.every((f) => f.checksum.valid && f.trusted));
assert.deepEqual(
results(decoded.message).map((r) => [r.value, r.status.meaning]),
[
["28.6", "final"],
["5.1", "unspecified"],
],
);
assert.deepEqual(
fromDamaged.frameWarnings.map((w) => w.code),
["ASTM_FRAME_BAD_CHECKSUM"],
);
assert.deepEqual(
results(fromDamaged.message).map((r) => r.value),
["5.1"],
);
1 change: 1 addition & 0 deletions examples/data/result-stream.astm
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
H|\^&|||analyzer^cobas^1|||||host||P|1|20240315093000P|1|PRAC-0001|LAB-0009||DOE^JANE^Q||20200101|FO|1|ACC-42|SPEC-7|^^^687\^^^688|RR|1|^^^687|28.6|U/L|10-40|N||F||tech01|20240315091500|20240315093000|COBAS-01R|2|^^^688|5.1|mmol/L|3.5-5.1|H||F||tech01|20240315091500|20240315093000|COBAS-01L|1|N
Expand Down
5 changes: 5 additions & 0 deletions examples/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import cosyte from "@cosyte/eslint-config";

// The examples are programs that print, not library code with a public API, so they are linted as
// an application: every type-safety rule applies, and `no-console` and the JSDoc gate do not.
export default cosyte(import.meta.dirname, { files: ["*.ts"], library: false });
Loading
Loading