ADR 0024: Stable Origin Scope Anchor
Accepted architecture decision record: catalog origins gain an optional stable scope (enclosing component or function), serialized as a file#scope anchor in both PO and FCL.
- Status: Accepted (2.0.0)
- Date: 2026-06-30
- Refines: ADR 0021
Context
ADR 0021 removed source line
numbers from catalog origins because they shift on every edit above a message and
add diff and merge churn without identifying anything the (msgid, msgctxt) key
does not. That left origins as file-only.
A line number is volatile, but the enclosing symbol — the component or function
a message appears in — is stable across edits and genuinely useful: it gives
translators and tooling context ("this string lives in Checkout") and helps
distinguish the same text used in different places. Producers such as Palamedes
can extract it; Ferrocat only needs a neutral slot to carry it.
This scope is origin metadata, not message identity. It is separate from
gettext context / msgctxt, and producers should not derive it from msgctxt
by default. Ferrocat still keys catalog messages by (msgid, msgctxt).
Good scope values are names already present in source code, such as
CheckoutButton, formatInvoiceStatus, SettingsPage, or
InvoicePresenter.
Decision
Add an optional, stable scope to CatalogOrigin:
pub struct CatalogOrigin {
pub file: String,
pub scope: Option<String>, // enclosing component/function, producer-filled
}The model stays typed (scope is its own field, not smuggled into file), while
the wire form is a single anchor token, shared by both formats so a scope always
travels with its file:
- PO:
#: src/Button.tsx#Button - FCL:
r=src/Button.tsx#Button
Parsing and de-duplication
- The anchor splits on the last
#, so a#earlier in a path stays with the file; an absent or empty anchor yieldsscope: None. - A legacy trailing
:lineis still stripped (ADR 0021), so gettext input keeps round-tripping. - Origin de-duplication keys on
(file, scope): the same text in two components is two meaningful origins, while exact duplicates still collapse.
Producers populate scope; until they do it is simply None, and the reference
renders as a bare file.
Consequences
Positive:
- origins carry stable, churn-free context (the symbol) instead of a volatile line number, while the model stays strongly typed
- one uniform
file#scopegrammar across PO and FCL keeps file↔scope pairing intact even when a message has several origins - gettext tooling reads
src/Button.tsx#Buttonas an ordinary reference token, so PO output stays standard-compatible
Negative:
- a
#literally present in a file path with no scope would be misread as a scope boundary — as rare as a:in a path was forfile:line, and accepted - it requires producer support to populate
scope, so the value isNoneuntil extractors emit it
This refines ADR 0021 and respects the Ferrocat/Palamedes boundary: Ferrocat owns the neutral slot and grammar, producers fill it.