Skip to content

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 yields scope: None.
  • A legacy trailing :line is 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#scope grammar across PO and FCL keeps file↔scope pairing intact even when a message has several origins
  • gettext tooling reads src/Button.tsx#Button as 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 for file:line, and accepted
  • it requires producer support to populate scope, so the value is None until extractors emit it

This refines ADR 0021 and respects the Ferrocat/Palamedes boundary: Ferrocat owns the neutral slot and grammar, producers fill it.