XLIFF state attribute lost on round trip

Four thousand segments were reviewed and signed off. The catalogue went out to a tool that works in PO, came back, and every segment now shows as unfinished. The translations are intact — the record that a human checked them is not.

PO has no field for review state, so a conversion that does not encode it somewhere else discards it. On the way back, the absence becomes a default, and the default is “not started”.

A round trip that erases review state An XLIFF segment marked reviewed is converted to PO, which has no equivalent field, so the state is not carried. Converting back to XLIFF produces a segment whose state defaults to initial, and every reviewed translation is presented as unfinished work. Where the state is lost XLIFF in Converter PO XLIFF out state="reviewed" PO has no state field converted back state defaults to initial
Nothing errors. The translations survive; the knowledge that they were checked does not.

Root cause: the two formats model progress differently

XLIFF 2 gives each segment a state attribute with four defined values: initial, translated, reviewed, final. That is a workflow model — it says how far a segment has travelled through a process involving more than one person.

PO has no such field. It has a fuzzy flag, which means “there is a target here but it may not be right”, and the presence or absence of a target string. That is a two-and-a-half-state model, and mapping four states onto it loses information by construction.

The loss is asymmetric and that is what makes it expensive. Going from XLIFF to PO, reviewed and final both become “not fuzzy” and are indistinguishable. Coming back, “not fuzzy” has to become something, and a converter picking translated demotes every signed-off segment, while one picking final promotes every unreviewed one. Both are wrong; the second is dangerous, because it marks work as approved that nobody approved.

The four XLIFF 2 segment states An initial segment has no target or only a draft and is not exportable. A translated segment has an answer from a translator, and whether it may ship is a policy decision. A reviewed segment has been checked by a second person. A final segment is signed off and should not be modified further. XLIFF 2 state values and what they mean Means Exportable initial no target yet, or a draft no translated a translator has answered policy decision reviewed a reviewer has checked it usually yes final signed off, do not change yes
The state is the only field that says whether a translation is finished — losing it loses that answer.

Minimal reproducible example

<!-- Before: a reviewed segment -->
<unit id="checkout.submit">
  <segment state="reviewed">
    <source>Place order</source>
    <target>Bestellung aufgeben</target>
  </segment>
</unit>
# After conversion to PO — the state has nowhere to live
msgid "Place order"
msgstr "Bestellung aufgeben"
<!-- After converting back: the review is gone -->
<unit id="checkout.submit">
  <segment state="initial">
    <source>Place order</source>
    <target>Bestellung aufgeben</target>
  </segment>
</unit>

The fix: encode the state in a comment, and read it back

PO’s extracted comments (#.) survive most tooling and are the conventional place to carry metadata a format does not model. Writing the exact state there, alongside the coarse fuzzy mapping, preserves the information without breaking any PO consumer.

#. x-state: reviewed
#. x-reviewer: 2026-05-12
msgid "Place order"
msgstr "Bestellung aufgeben"

The return conversion prefers the comment and falls back to the flag:

function stateFromPo(entry: PoEntry): XliffState {
  const declared = entry.extractedComments
    .find((c) => c.startsWith('x-state:'))?.slice('x-state:'.length).trim();

  if (declared && ['initial', 'translated', 'reviewed', 'final'].includes(declared))
    return declared as XliffState;

  // No declaration: infer conservatively. Never promote to reviewed or final.
  if (entry.flags.includes('fuzzy')) return 'initial';
  return entry.msgstr ? 'translated' : 'initial';
}

The conservative default is the important line. An unknown segment that is demoted costs a reviewer a second look; one that is promoted ships unreviewed copy, and nothing downstream will catch it because every gate treats state as authoritative.

Five steps for preserving review state through PO The coarse distinction maps onto the PO fuzzy flag, the exact value is stored in an extracted comment, the return conversion reads that comment in preference to the flag, an absent comment defaults to translated rather than final, and the round-trip test asserts that the state set is unchanged. Carrying state across a format boundary 1 Map state onto the PO fuzzy flag initial and draft become fuzzy 2 Store the exact value in a comment #. x-state: reviewed 3 Read the comment back on the return trip comment wins over the flag 4 Default conservatively when absent translated, never final 5 Assert state parity in the round-trip test states in equal states out
Step four matters: guessing upward marks unreviewed work as signed off.

The same problem, for every field PO does not have

Review state is the most damaging instance of a general pattern: XLIFF 2 carries several fields that PO cannot express, and each needs the same treatment.

Segment identifiers. XLIFF units have an id that is stable across content changes. PO identifies entries by source text plus context, so a conversion that does not store the id loses the ability to match a segment whose source was edited — the identity problem described in safely renaming a translation key.

Notes with categories. XLIFF notes carry an appliesTo and a category; PO comments are flat text. Prefixing preserves the distinction well enough to reconstruct it.

Match quality. A segment pre-filled from translation memory at 85% carries that score in XLIFF metadata. Lost, it becomes indistinguishable from a human translation, and the review policy that depends on the score stops working — which matters for the machine-translation states described in machine translation pre-fill workflows.

The general rule is that a conversion should be lossless by construction and verified by round trip: everything the source format expresses is written somewhere in the target, and a test proves that converting there and back reproduces the original.

Recovering a review state that has already been lost

If the demotion has already happened and four thousand segments now read as unfinished, the state is usually recoverable — but only from a system that still holds a record, and those records expire.

The most reliable source is the translation system audit history. Most systems record who changed a segment and when, including approvals, and that history outlives the state on the segment itself. Exporting the approval events and replaying them against the current file restores the distinction without asking anyone to re-review.

The second source is the previous export. If an earlier XLIFF file exists — in version control, in a backup, in an email attachment — its state attributes are authoritative for every segment whose target has not changed since. Matching on segment id and target text is enough to carry the state forward safely: a segment whose target is byte-identical to a previously reviewed one is still reviewed.

The third is the memory, which is weaker evidence but not worthless. A segment whose translation matches an approved memory unit exactly was almost certainly approved at some point, and treating that as grounds for translated rather than initial at least removes it from the top of the queue.

What should not happen is a blanket promotion to reviewed on the assumption that most segments were fine. It saves review effort now and destroys the meaning of the field permanently, because nobody will trust it afterwards — and the next time a genuine question arises about whether something was checked, there will be no answer.

Whichever source is used, the recovery is worth doing before translators start working against the demoted file. Once new work lands on top, distinguishing recovered state from newly produced state becomes another matching problem.

Verification

# Round trip and compare the state distribution
npx tsx convert.ts xliff-to-po  in.xlf  out.po
npx tsx convert.ts po-to-xliff  out.po  back.xlf

diff <(xmllint --xpath '//segment/@state' in.xlf   | grep -o 'state="[^"]*"' | sort | uniq -c) \
     <(xmllint --xpath '//segment/@state' back.xlf | grep -o 'state="[^"]*"' | sort | uniq -c)

# Expected — identical counts per state
#   1204 state="final"
#   2810 state="reviewed"
#    186 state="translated"

Comparing distributions rather than whole files is deliberate: attribute order and whitespace legitimately differ after a round trip, and a full diff drowns the signal. The state counts are the property that must hold exactly.

When to escalate

If states survive the conversion and are still wrong in the translation system, the importer is applying its own workflow rules — some systems reset state on import as a policy, regardless of the file. That is a configuration question in the system rather than a conversion bug, and it is worth confirming before rewriting a converter.

If only some segments lose state, look for the ones with inline markup or plural forms. Those take different code paths through most converters, and a metadata branch that was implemented for the simple case is frequently missing from the complex ones.

If the round trip is lossless but review effort is still being repeated, the problem may be segmentation rather than state: a segment split differently on the return trip is a new segment, and no state can attach to it.

FAQ

Why convert to PO at all?

Because a tool or a translator in the chain requires it. Many established localization tools, and most open-source translation workflows, are built around PO — so the conversion exists to reach them, and the goal is to make it lossless rather than to avoid it.

Is fuzzy a reasonable proxy for state?

For the coarse distinction, yes: fuzzy means “do not trust this yet”, which maps onto initial and draft. It cannot express the difference between translated, reviewed and final, which is exactly the distinction a review workflow depends on.

Should the state comment use a standard prefix?

Use an x- prefixed key, as with any non-standard metadata. It signals clearly that the field is an extension, and it avoids colliding with the conventional comment forms that tools already interpret.

What if the PO tool strips comments?

Then that tool cannot participate in a lossless round trip, and the state has to be held outside the file — in the translation system, keyed by segment id. That is a worse arrangement, and knowing it is necessary is better than discovering the loss afterwards.

Does XLIFF 1.2 use the same state values?

No, and mixing the two versions is its own source of loss. XLIFF 1.2 uses a different and larger vocabulary — values such as needs-translation, needs-review-translation and signed-off — which does not map one-to-one onto the four values in version 2. A converter handling both must translate between the vocabularies explicitly rather than passing the string through, or the state will be silently dropped as unrecognised.

Should a build gate read the state?

Yes, and it is one of the more useful gates available: refusing to ship a locale whose segments are below a required state is a stronger check than a raw coverage percentage, because it counts finished work rather than filled fields. It only works while the state is trustworthy, which is the whole reason the round trip has to preserve it.

Part of PO & XLIFF Format Bridging.