# Pinning to a release

  Terminology changes upstream, on somebody else's schedule, without a deployment on your side.
  The version you read is the one control you have over that, and the default is the wrong
  one.

## The default reads drafts

Omit the version and, in the usual case, you read the working copy. That is correct when you are
authoring terminology, since an editor needs to see the change they just made. It is wrong in every
integration, because a draft concept that nobody approved can reach a patient-facing screen, be
picked, and be submitted on a claim.

"In the usual case" is doing real work in that sentence. An unversioned read serves the working copy
only while the artefact is unpinned; under a manual pin it already serves that frozen version without
being asked. So omitting the version leaves you at the mercy of somebody else's pin setting. The
same call returns live rows on one vocabulary and frozen content on another, and nothing in your
code says which. Send `serving` and the question stops mattering.

Three ways to read the same call, in the order you should reach for them:

<Wire
  rows={[
    { call: "?version=serving", does: "The version the artefact currently resolves to: the owner's pin if there is one, otherwise the newest released version effective today. A frozen read without having to look up a number first. This is what an integration sends on every runtime call.", keep: "the version it resolved to" },
    { call: "?version=v1.2.0", does: "Exactly that release, by its version_id (or its numeric id). Only released versions are honoured. A pinned read like this is cacheable indefinitely.", keep: "the version_id" },
    { call: "?version=draft", does: "The pending draft, for reviewers. Gated on the reviewer permission, and not something an integration sends.", keep: "—" },
    { call: "omitted", does: "Depends on the artefact: the working copy including drafts while it is unpinned, or the pinned version if somebody has set one. Right when authoring terminology, wrong when integrating with it, not because it is always live but because you cannot tell.", keep: "—" },
  ]}
/>

  `serving` means the version the owner is serving. It is not the newest edit, and it is not
  always the newest release either, as the next section shows. Approval is a deliberate act by a
  terminology author or a regulator, and sending `serving` means you follow that decision rather
  than whatever was typed this morning.

## What `serving` actually resolves to

"Latest" is the wrong mental model here, so this part is worth being precise about. Every versioned
artefact carries a `pin_mode`, and that decides the answer:

<Wire
  rows={[
    { call: "pin_mode: MANUAL", does: "The owner has pinned a specific release. serving resolves to exactly that version and stays there, because a newer release does not move it. This is how a regulator holds a vocabulary steady while a later version is prepared, or holds consumers on a known-good release after a problem.", keep: "pinned_version" },
    { call: "pin_mode: AUTO_LATEST", does: "No pin. serving resolves to the newest released version whose effective date has arrived. This is the common case, and it is what lets a new approved code reach your pickers without a deployment on your side.", keep: "the newest release" },
  ]}
/>

Two things follow from that, and both are intended behaviour rather than quirks.

A future-dated release is not served early. Effective dates are wall-clock dates set by an author,
so a version dated next Monday is invisible to `serving` until Monday, with no coordination needed
on your side. "Today" means today in the deployment's own timezone rather than UTC. That is on
purpose: comparing against UTC would leave a release dated today unservable until three in the
morning local time.

A pin that can no longer be served does not break the read. If a pinned version is retired, the
read falls through to the newest release and says so: the artefact carries a `pin_warning`, and the
version ladder shows the state. You get terminology back rather than an error, along with a signal
that somebody's pin needs attention.

### Reading which version you are on

<TryIt
  title="The version ladder and the pin"
  path="/code-systems/IDENTIFIER-TYPES/versions/"
  keep="version_id, is_serving, pin_mode"
>
  Start with `serving` at the top of the response. It is the `version_id` that `?version=serving`
  resolves to, or `null` when nothing is servable. Beside it, `pin_mode`, `pinned_version` and
  `pin_warning` say whether that answer was somebody's deliberate choice or simply the newest release.
  Each row then carries `is_serving` and `is_head_draft`, so you never need a second call to work out
  which is which.
</TryIt>

## Which to send where

`serving` and a fixed version answer different questions, and most integrations need both, in
different places.

<Wire
  rows={[
    { call: "Send serving on every runtime read", does: "Pickers, validation, expansions. You want the terminology the owner is standing behind, and you want a newly approved code to appear without a release on your side.", keep: "serving" },
    { call: "Store the resolved version with the code", does: "When you store a coded value, store which version you read it at. Interpreting a two-year-old claim means reading the vocabulary as it stood then, and a fixed ?version= is how you do that.", keep: "version_id, beside the code" },
    { call: "Send a fixed version when a run must be reproducible", does: "A migration, a reconciliation, a regulatory extract. Pinning explicitly means running it again next month gives the same answer. The read is also cacheable indefinitely, which a long job notices.", keep: "the version_id" },
  ]}
/>

That second habit is what makes an old record defensible. Without it, a code that has since been
withdrawn looks like a data error rather than a code that was correct on the day.

  A code system release that predates the service's content-versioning backfill carries no internal
  ordinal, so there is nothing to read frozen content at. Rather than fail, the read serves the live
  working copy, at `200`, with nothing in the body or the headers to say it happened. A response
  in this state is indistinguishable from genuinely frozen content.

  So if your reasoning is "`serving`, therefore frozen, therefore safe to cache forever", this is the
  case that breaks it, and you cannot detect it on the response.

  What you can check is the artefact. A ladder row carries a `sequence`, an internal ordinal you
  should never render but which tells you what you need here: for a code system, a released version
  with `sequence: null` has no frozen content to serve. Check `/versions/` before caching a pinned
  read for a long time, and treat a null-ordinal release as live.

  Build the check rather than waiting for the condition to disappear. A missing ordinal on an old
  release is a supported state in the service's own schema, not a data defect somebody is about to
  clean up, so these releases will keep behaving this way.

  One boundary, so the check is not misapplied: value set and concept map versions have no ordinal at
  all, so `sequence` is always `null` on their rows and means nothing there. This test is only
  meaningful for code systems.

  `version_id` is a display label. Its numbering is not monotonic across the paths a version can
  be minted through, so `v1.10.0` is not reliably newer than `v1.9.0` and sorting a ladder by it will
  eventually put the wrong release at the top. The service orders by an internal `sequence` where the
  model has one and by creation time otherwise; take the order as given, and use `is_serving` rather
  than "the first row" to decide what is live.

  A `?version=` read of a released version answers `Cache-Control: immutable` with a one-year
  lifetime, because frozen content cannot change. An unversioned read answers `no-cache`, because its
  body flips the moment something is released and nothing in the rows you would cache on has changed.
  If your caching layer treats those two the same, it is serving stale terminology on one of them.

## Seeing what changed

<ApiRef method="GET" path="/code-systems/{system}/changed/" to="/terminology-api/versioned-reads">
  The working copy against the serving version. A modified row carries both the before and after
  objects, so you can show what actually changed rather than that something did.
</ApiRef>

<Wireframe
  title="Terminology sync · pending release"
  actor="Integration owner"
  rows={[
    [{ label: "INTERVENTIONS", from: "cached at version 7", kind: "text", span: 2 },
     { label: "Version 8 released", note: "2 days ago", tone: "attention", span: 2 }],
    [{ label: "Added", from: "3 concepts", tone: "positive", span: 2 },
     { label: "Withdrawn", from: "1 concept", tone: "negative", span: 2 }],
    [{ label: "INT-CARD-ECHO-TOE withdrawn", note: "In use on 4 unsubmitted claims, so these need attention before the sync", kind: "text", span: 4 }],
    [{ label: "Sync to version 8", kind: "action", span: 2 },
     { label: "Stay on 7", kind: "action", span: 2 }],
  ]}
  caption="The withdrawn concept is the row that matters. A sync that silently replaced the cache would have left four claims carrying a code the payer no longer accepts, and nothing would have said so until submission."
/>

<Mermaid chart={`sequenceDiagram
    autonumber
    participant J as Your sync job
    participant T as Terminology service
    participant D as Your cache

    J->>T: GET /code-systems/{system}/versions/
    T-->>J: The ladder: releases, and which one is serving
    J->>D: Which version are we holding?
    D-->>J: 7
    Note over J: 8 exists. Do not replace yet.

    J->>T: GET /concepts/?system={system}&version=8
    T-->>J: The frozen membership of release 8
    Note over J: Diff against 7.<br/>Withdrawn codes are the interesting half.
    J->>D: Store 8 alongside 7, then switch
    Note over J,D: Anything unsubmitted that references<br/>a withdrawn code is flagged, not rewritten.
`} />

<Mermaid chart={`flowchart TD
    A["Scheduled sync"] --> B["GET /versions/"]
    B --> C{"A newer release than<br/>the one cached?"}
    C -- No --> D["Nothing to do"]
    C -- Yes --> E["Fetch ?version=&lt;version_id&gt;<br/>for the new release"]
    E --> F["Diff against the cached version"]
    F --> G{"Any concepts withdrawn?"}
    G -- No --> H["Switch the cache over"]
    G -- Yes --> I["Find unsubmitted records<br/>using a withdrawn code"]
    I --> J{"Any found?"}
    J -- No --> H
    J -- Yes --> K["Flag them for a human.<br/>Do not rewrite a clinical code<br/>automatically."]
    K --> H
    H --> L["Log the version you switched to,<br/>and when"]
`} />

## Withdrawn is not deleted

A withdrawn code still comes back, marked inactive, and this is deliberate: a record from last
year that references it still has to render. Two rules follow, and they pull in opposite
directions:

<Wire
  rows={[
    { call: "Filter for pickers", does: "is_active: true and status PUBLISHED is what belongs in a dropdown. Offering a withdrawn code produces a rejection nobody can explain to the operator who picked it.", keep: "is_active" },
    { call: "Do not filter for history", does: "When interpreting stored data, read everything. A filter applied here makes old records render as blanks, which looks like data loss.", keep: "the full set" },
  ]}
/>

One list, two reads. If your terminology layer exposes a single `getValueSet(system)` that always
filters, the history path has no way to ask for what it needs.

## Building so a release is not an incident

<BuildSequence
  steps={[
    {
      do: "Send version=serving on every runtime read",
      detail:
        "In one place, in your HTTP client for the terminology service, so no call site can forget it. A missing version parameter should be impossible rather than caught in review.",
    },
    {
      do: "Store the version with every code you store",
      detail:
        "One extra column. It is the difference between explaining an old claim and guessing about it.",
    },
    {
      do: "Diff before you switch the cache over",
      detail:
        "Read /versions/, fetch the new release by its version_id, and compare. The withdrawn half of the diff is what needs a human.",
    },
    {
      do: "Never auto-rewrite a clinical code",
      detail:
        "A withdrawn diagnosis on an unsubmitted claim is a clinical decision to revisit, not a mapping to apply. Flag it and let somebody look.",
    },
    {
      do: "Log the version you switched to, and when",
      detail:
        "When a coded value stops being accepted, the first question is which version you were reading and since when. Make that answerable in one query.",
    },
  ]}
/>

## What breaks here

<CostTable
  rows={[
    {
      where: "A picker, intermittently",
      symptom: "\"A code appeared that nobody approved\"",
      cause:
        "The version parameter was omitted, so the read included drafts. Send serving.",
    },
    {
      where: "Submission, after a quiet upstream release",
      symptom: "\"Claims started rejecting on Tuesday and we changed nothing\"",
      cause:
        "That reading is usually right. You changed nothing, and the vocabulary did. Read /versions/ for the release and its effective date, then diff it.",
    },
    {
      where: "Reporting on old claims",
      symptom: "\"Historical records show blanks where a diagnosis used to be\"",
      cause:
        "The history path is filtering on is_active, so withdrawn codes resolve to nothing. Filter for pickers only.",
    },
    {
      where: "An audit",
      symptom: "\"We cannot say which version of the vocabulary this claim was coded against\"",
      cause:
        "The version was never stored beside the code. It cannot be reconstructed afterwards.",
    },
    {
      where: "A sync job",
      symptom: "\"The cache silently replaced itself and four claims broke\"",
      cause:
        "A sync that fetches and overwrites, with no diff step. The withdrawn concepts are the ones that need to be seen before the switch.",
    },
  ]}
/>

**Next:** [Getting the data you need](/rail/reference/Terminology-Getting-Data) ·
[Validating a code](/rail/reference/Terminology-Validating-Codes) · or the whole surface at
[the terminology service](/rail/reference/Terminology-Service)
