ADR 0027: Preserve Translator Comments and Flags as Opaque Round-Trip State
Accepted architecture decision record: the catalog layer keeps translator comments and per-entry flags attached to the message identity through update and combine without giving them any semantic meaning.
- Status: Accepted, partially superseded by ADR 0028
- Date: 2026-07-30
- Partially supersedes: ADR 0023
- Relates to: ADR 0006
Context
ADR 0023 removed the
gettext flag concept from the catalog layer and collapsed the two comment kinds
into one notes list. That decision was about semantics, and it still holds:
Ferrocat did not produce a fuzzy status, and printf format flags say nothing
about an ICU catalog. ADR 0028 later narrows the first statement: an opt-in
report projection recognizes the exact fuzzy marker without exposing the
general flag list.
It also had a side effect that was not the point of the decision. Because the
catalog model had nowhere to put them, a high-level update destroyed this
metadata: translator-written # comments were rewritten as extractor-owned
#. comments, and per-entry flags disappeared. A hand-maintained PO file lost
information every time a source-first update ran.
Hosts worked around it. Palamedes parsed the original PO, stripped translator
comments and flags before calling Ferrocat, then parsed the rendered output and
restored them by (msgctxt, msgid) — a full extra parse/serialize pass whenever
any entry in a catalog carried this metadata. The workaround reimplements, less
efficiently, an identity join Ferrocat already performs internally during merge.
The two concerns are separable. "Ferrocat does not act on flags" and "Ferrocat throws flags away" are different claims, and only the first one is a decision worth keeping.
Decision
Keep translator comments and per-entry flags attached to the message identity through the catalog layer, as opaque round-trip state.
CanonicalMessagegainstranslator_comments: Vec<String>andflags: Vec<String>. Both are internal. The publicCatalogMessageis unchanged and does not expose them; they are storage detail, not part of the catalog's semantic shape.- PO import keeps
#comments intranslator_commentsinstead of merging them into the canonical notes list. Extractor-owned#.comments keep the existing pipeline (placeholder split, then notes). Per-entry#,flags go toflagsverbatim, including unknown ones such asx-custom. - PO export writes gettext's canonical order:
#translator comments first, then#.extracted and placeholder comments,#@metadata,#:references, and finally a single#, flag1, flag2line when the entry has flags. The obsolete#~prefix applies to all of them as elsewhere. - Update moves both fields across for a matched identity. They survive the transition to obsolete and back, because they live on the message rather than beside it. A new identity always starts empty and never inherits from an unrelated entry.
- Combine attaches them to the definition that wins the entry value. They move as one block; they are never unioned across inputs, because a union would invent a set of comments and flags that no single input declared.
- FCL gains two optional per-entry tags:
tc=for one translator comment andf=for one flag, both0..n, escaped like every other value. The canonical tag order becomesr(sorted),c,tc,f,o,lock,ai.
Repeating an unchanged update is byte-stable: feeding an update's output back in as the existing catalog with the same extractor input reproduces the same bytes.
What this does not change
This ADR originally granted no meaning to any of it. ADR 0028 partially
supersedes that rule for the exact fuzzy marker in an opt-in report
projection:
CatalogMessageStatus::Fuzzy,CatalogAuditChecks::fuzzy_flags,catalog.fuzzy_flag, and the coveragefuzzycounter are available when catalogs are parsed withparse_catalog_for_review;- the default public parse projection still drops all flags, while update and combine continue to preserve them opaquely;
- no flag other than exact
fuzzyacquires semantic meaning; - the comment-kind split drives no behavior. Ferrocat does not read translator comments differently from extracted ones; it only knows which line prefix they came from so it can write them back the same way.
What ADR 0023 said about the two comment kinds having "no consumer" remains true. The consumer is the file itself.
Faithful low-level PO is still unchanged
As in ADR 0023, this is a catalog-layer decision. The low-level parse_po /
stringify_po round-trip already preserved PoItem::flags and both comment
kinds verbatim and is untouched.
Consequences
Positive:
- a source-first update no longer destroys translator-owned metadata, which was the one genuinely lossy part of the catalog round-trip
- downstream hosts drop the strip-and-restore pass entirely, along with its extra parse and serialize of every catalog that carries this metadata
- preservation follows the same
(msgctxt, msgid)identity as everything else, so it stays correct across obsolete transitions and context-disambiguated entries for free - the public API surface does not grow: no new option, no new public field, and no new semantics to document or support
Negative:
- catalogs whose translator comments were previously flattened into
#.will be imported and exported differently from now on; the first update after this change moves those lines back to#. That is the intended correction, but it is a visible one-time diff - FCL files that carry the new
tc=/f=tags need a reader with this change. The reader hard-errors on unknown tag keys by design, so an older strict reader rejects such a file. Files without the new tags are unaffected and parse exactly as before. This is the same forward-compatibility trade theorder=collatedheader tag made in ADR 0026 - the canonical model carries two fields Ferrocat deliberately does not reason about, which is a small exception to the "every field is something Ferrocat understands" framing of ADR 0021 through ADR 0023. The exception is explicit: they are round-trip state, kept internal precisely so they cannot leak into catalog semantics