# `PhoenixKitCatalogue.TranslationStatus`
[🔗](https://github.com/BeamLabEU/phoenix_kit_catalogue/blob/v0.44.2/lib/phoenix_kit_catalogue/translation_status.ex#L1)

Freshness of catalogue AI translations: a per-(resource, language, FIELD)
fingerprint of the source text, and the state it implies.

## Fingerprints

`field_fingerprint/1` sha256-hexes a single trimmed source value.
`field_fingerprints/1` applies it to every entry of a `source_fields/2`-
shaped map, producing one hash per field. This is the per-field
narrowing decision from the design source (§4.4, §12.2): staleness, and
the write path's decision to touch a field at all, are computed FIELD BY
FIELD, not over the whole resource collapsed into one hash — so a hand-
corrected translation of one field survives a re-translate that only
changed a sibling field.

(`fingerprint/1` remains: a single order-independent hash over an entire
`source_fields/2` map. It predates the per-field model and is kept for
callers that still want a whole-resource digest — nothing in this module
writes it to storage any more.)

The fingerprint must reflect the source text **as read at translation
time**, not whatever it looks like when the translation is written back —
a sync can land on the row in between (design source doc §4.1). So
`capture_fingerprint/3` stashes the PER-FIELD hashes in the calling
process's dictionary when `source_fields/2` runs, keyed by
`{resource_type, uuid}`; `put_translation/4` in each adapter reads them
back via `captured_fingerprint/2` and writes the touched ones under the
target language's key — falling back to hashing the freshly-locked row's
OWN current source only when nothing was captured (a direct call
bypassing `source_fields/2`).

Storage (catalogue-owned keys, additive JSONB — see the block-6 plan's
amendment vs the original design source), now a MAP of field name to
hash per language rather than a single hash:

  * `%Item{}` / `%Category{}` →
    `data["_translation_fingerprints"][lang][field]`
  * `%PhoenixKitEntities{}` (a catalogue set's blueprint) →
    `settings["translation_fingerprints"][lang][field]` (single field,
    `"label"`)
  * `%PhoenixKitEntities.EntityData{}` (a set's value) →
    `metadata["translation_fingerprints"][lang][field]` (single field,
    `"title"`)

### Legacy rows (pre-per-field rollout)

Resources translated before this model shipped store a single hex
string at `[...]["_translation_fingerprints"][lang]` instead of a map
(the whole-resource `fingerprint/1` digest from the original rollout).
Every reader here (`stored_fingerprint_map/2`, `field_state/3`, …)
treats a non-map value at that key as **absent** — the field falls back
to `:unknown` (given an existing translation) rather than `:stale`.

This is a deliberate choice over the alternative (comparing the legacy
whole-resource hash against each field's fresh hash, which would
virtually never match since the two hash different inputs, and so would
report `:stale` for nearly every field). `:stale` feeds the sweep
worker's automatic candidate list — turning every legacy row `:stale` on
deploy would silently enqueue a re-translation storm across every
legacy resource the moment this ships, spending AI tokens nobody
authorized (design source §13: "a mass run happens only by separate
owner decision"). `:unknown` is the model's existing "we don't know,
ask a human" bucket, is never auto-swept, and matches what the write
path already does for these rows regardless of which interpretation
`state/2` reports: since neither interpretation ever finds a matching
per-field hash for a legacy row, the write-narrowing table's "no stored
fingerprint" and "hash mismatch" branches agree — both write + stamp.
So `:unknown` costs nothing at write time and avoids an unauthorized
automatic AI bill at read time.

## States

`state/2` folds a (resource, language) pair into one of four states —
the WORST across every field that currently has non-empty source text
(a field whose source is empty right now is excluded from the fold
entirely, per design source §4.1; its translation, if any, is left
alone). `field_state/3` answers the same question for one field alone
(or `nil` when that field currently has no source).

  * `:missing` — the source is non-empty but there is no translation
  * `:unknown` — a translation exists but has no recorded fingerprint
    (pre-existing translations from before this model shipped, one
    written outside `put_translation/4`, or a legacy whole-resource
    fingerprint — see above)
  * `:stale`   — the translation's fingerprint no longer matches the
    current source
  * `:fresh`   — the translation's fingerprint matches the current source

Fold order (worst wins): `missing` > `stale` > `unknown` > `fresh`.

`unknown` is deliberately never auto-swept (see the sweep worker,
Task 4) — an operator decides its fate via `stamp_fresh/2`/`3` ("the
current source is the reference") or an explicit retranslate.

# `state`

```elixir
@type state() :: :missing | :stale | :unknown | :fresh
```

# `capture_fingerprint`

```elixir
@spec capture_fingerprint(String.t(), Ecto.UUID.t(), map()) :: :ok
```

Records the PER-FIELD fingerprints of `source_fields` for
`{resource_type, uuid}` in the CALLING PROCESS's dictionary. Meant to be
called from inside a `source_fields/2` implementation, right before the
value is handed to the AI engine — see the moduledoc.

# `captured_fingerprint`

```elixir
@spec captured_fingerprint(String.t(), Ecto.UUID.t()) ::
  %{required(String.t()) =&gt; String.t()} | nil
```

Reads back the per-field fingerprints captured earlier in THIS process, or `nil`.

# `field_fingerprint`

```elixir
@spec field_fingerprint(String.t()) :: String.t()
```

sha256 hex digest of a single trimmed field value.

# `field_fingerprints`

```elixir
@spec field_fingerprints(map()) :: %{required(String.t()) =&gt; String.t()}
```

Applies `field_fingerprint/1` to every entry of a `source_fields/2`-shaped map.

# `field_state`

```elixir
@spec field_state(struct(), String.t(), String.t()) :: state() | nil
```

The freshness state of a single FIELD of `resource`'s translation into
`lang`. `nil` when `field` currently has no non-empty source text — such
a field is excluded from `state/2`'s fold and its translation, if any,
is left untouched by the write path.

# `fingerprint`

```elixir
@spec fingerprint(map()) :: String.t()
```

sha256 hex digest of `source_fields`, order-independent: fields are
sorted by key, each rendered as `"field=trimmed value"`, joined by `"\n"`.

Kept for callers that want a single whole-resource digest (and for the
legacy-format discussion in the moduledoc); the per-field model below
(`field_fingerprint/1` / `field_fingerprints/1`) is what gets written to
storage now.

# `list`

```elixir
@spec list(:item | :category | :set_label | :set_value, keyword()) :: [map()]
```

Lists (resource, language) rows for `type`, one per language in
`opts[:langs]`.

## Options

  * `:langs` — target languages to report on (default `[]` — an empty
    list yields no rows; callers pick the languages that matter to them,
    e.g. the enabled non-default set or a single language filter)
  * `:state` — one state atom or a list of them; unfiltered when absent
  * `:catalogue_uuid` — scope `:item`/`:category` rows to one catalogue
    (ignored for `:set_label`/`:set_value`, which are catalogue-wide)
  * `:page` / `:per_page` — 1-indexed pagination (defaults `1` / `50`)

# `reset_baseline`

```elixir
@spec reset_baseline(struct(), String.t(), String.t() | [String.t()]) ::
  {:ok, struct()} | {:error, term()}
```

"Reset baseline" (design source §4.4): deletes the stored fingerprints
of `fields` (a field name or a list of them) for `lang`, WITHOUT
touching the translation itself or calling the AI. A field with no
stored fingerprint is left alone (no-op for that field). The pair drops
to `:unknown` for each reset field until the next successful write —
the sweep never auto-picks up `:unknown`, so a forced re-translate stays
a deliberate, visible operator action rather than a sweep-driven loop.

# `stamp_all_translated`

```elixir
@spec stamp_all_translated(struct()) :: struct()
```

Stamps every language that currently holds a translation of `resource`
as `:fresh` against the CURRENT source — the create-time baseline. A
resource saved from the form with translations already in hand (a
value-mode AI translate on a not-yet-saved item, a secondary-language
name typed by hand) was translated against the source it is created
with, so it must not be born `:unknown` — a state the sweep never
picks up and an operator would otherwise clear by hand, language by
language (week review, 2026-09-13). Item and category only; anything
else returns unchanged. Never raises: a stamp that fails leaves that
pair `:unknown`, which is what it was.

# `stamp_fresh`

```elixir
@spec stamp_fresh(struct(), String.t()) :: {:ok, struct()} | {:error, term()}
```

Operator action: "the current source is canonical" — writes the CURRENT
source's fingerprint under `lang` for every field that is both currently
sourced and translated, without calling the AI. Flips the resource to
`:fresh` for `lang` (or leaves it alone — see the guard below: a
resource with no translation for `lang` has nothing to stamp).

# `stamp_fresh`

```elixir
@spec stamp_fresh(struct(), String.t(), String.t() | [String.t()]) ::
  {:ok, struct()} | {:error, term()}
```

Field-narrowed `stamp_fresh/2`: stamps only `fields` (a field name or a
list of them), leaving every other field's stored fingerprint as-is. A
field is skipped (no-op) unless it is both currently sourced AND
translated; the call as a whole fails with `{:error, :no_translation}`
only when NONE of the requested fields qualify.

# `stamp_preimage`

```elixir
@spec stamp_preimage(struct(), String.t(), %{required(String.t()) =&gt; String.t()}) ::
  {:ok, struct()} | {:error, term()}
```

Sync/operator action: "the value I'm about to overwrite is the
reference" — writes fingerprints computed from `previous_source_fields`
(a map of ENGINE field name => the value being REPLACED, not the
resource's current value) under `lang`, for exactly the fields named
there. Every other field's stored fingerprint is left untouched, same
as `stamp_fresh/3`'s narrowing.

For a caller (a Shopify sync, say) that is about to overwrite the
PRIMARY-language source of one or more fields and already knows the
value it's replacing: stamping that value FIRST turns the (resource,
lang) pair `:stale` once the sync's own write lands — "this
translation was made against real prior text" — instead of `:unknown`,
which the sweep worker never auto-picks up (see the moduledoc).

Refuses exactly like `stamp_fresh/2`: `{:error, :no_translation}` when
`lang` has no translation at all. Locked `FOR UPDATE`, same as
`stamp_fresh/2` and `/3`.

# `state`

```elixir
@spec state(struct(), String.t()) :: state()
```

The freshness state of `resource`'s translation into `lang`, folded
(worst wins) across every field that currently has non-empty source
text. `:missing` when no field currently has source text at all but
something is translated for `lang` would be surprising — that resource
simply reports `:missing` (nothing eligible to translate) unless it is
itself translated, in which case `:unknown` (see moduledoc).

# `stored_fingerprint_map`

```elixir
@spec stored_fingerprint_map(struct(), String.t()) :: %{
  required(String.t()) =&gt; String.t()
}
```

The WHOLE per-field fingerprint map stored for `resource`/`lang`
(`%{field => hash}`), or `%{}` if there is none. A legacy single-hash
string at that key (see moduledoc) is treated as `%{}` — absent, not a
match for any field.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
