Skip to content

The receipt stops guessing who wrote the target text - #206

Merged
SearheiParkhamchuk merged 1 commit into
major/translatorfrom
fix/translator-the-receipt-stops-guessing
Oct 22, 2026
Merged

SearheiParkhamchuk merged 1 commit into
major/translatorfrom
fix/translator-the-receipt-stops-guessing

Conversation

@SearheiParkhamchuk

Copy link
Copy Markdown
Contributor

What was wrong

A provenance receipt recorded, per leaf, one of three claims: the source hash, UNANSWERED, or
DECLINED (null). DECLINED was written for every leaf a run saw but did not send, and both
readers took it as claim nothing — so that leaf never read as out of date, and skip_existing
never selected it again, however far its source drifted.

The result was a plugin that protected one kind of hand-written text and destroyed another, with
nothing an editor could see to tell them apart:

Target field Protected before this change
a person filled it, the plugin never wrote it yes, for ever
the plugin wrote it, a person then corrected it no — overwritten once the source moved

What this changes

The receipt becomes a watermark of the source. Per leaf it records where that leaf's source
stood when the run ended, whether the run translated it or left it alone. Two states remain — a hash
and UNANSWERED — and automatic translation takes every leaf whose source moved, without asking who
last touched the target.

Where a field must be left alone, that is an explicit mark, not a guess the plugin makes from
its own records: withFieldTranslation(field, { exclude: true }). Per-document, per-field control
arrives with the picker in #48.

Old receipts self-heal. A null written by 1.0.0-next.3 or earlier is read at the parse
boundary as UNANSWERED, so the leaf is revisited once and re-based. Refusing the value instead was
tried and rejected: one out-of-schema value fails the whole map, and a degraded record answers
nothing about any leaf — so under skip_existing with no empty target the run selects nothing,
writes nothing, and the document stays degraded indefinitely. That was verified by running the
schema, not reasoned about.

This reverses a documented promise, on purpose

README.md said skip_existing "fills only empty target fields". That line, both strategy
tables, the AutoTranslateStrategy docblock and the admin tooltip are corrected, and
docs/DEPRECATIONS.md carries the entry. Every corrected statement now also says what review found
missing: the refresh needs provenance on and a receipt already written for that locale —
without either, changedLeaves returns {} and behaviour is exactly as before.

Affordable because the package is at 1.0.0-next.3 under the next dist-tag and the major does not
ship until epic #192 is complete, so the picker that replaces the lost protection lands in the same
release.

What the review passes found

Six passes ran over this change: three review angles, a stub-and-mutation audit of the suite, an
abstraction audit, two complexity audits and two comment audits. The four that changed the outcome:

  • The README overclaimed. provenance defaults to false; the first draft read as though the
    refresh were unconditional. Five statements corrected.
  • "Source changed" was adopted as the stale label, then reverted. isRecordStale is true in
    four cases and in two of them the source did not change, so the label would have been false on
    screen and in the accessible name.
  • A dead disjunct was removed. leafChanged tested recorded === UNANSWERED || recorded !== current;
    the first term can never decide the result, because the marker can never equal a hash. Keeping it
    suggested a danger that does not exist. The invariant it leaned on is pinned by its own check.
  • Two pre-existing comments had been stranded into lying by the change and were corrected.

staleness.ts came out at 70 lines from 99, having also lost matchesClaim — a verbatim negation
of the function beside it — and an unreachable undefined state.

Not solved here

  • A run that overwrites a person's text still says nothing about it. Owed by D21 of the rewiring
    design; the surface belongs with the picker in [translator] Choose fields, not a strategy — rebuild how a run picks what it translates #48.
  • An emptied-then-refilled source is never re-translated. Pre-existing — record has always
    iterated currentFields, and this diff does not touch that line — but it is the one place where
    "translate everything whose source moved" still does not hold.
  • No integration check seeds a legacy {"title":null} receipt and runs the real handler. The
    migration is covered by six unit checks.

Checks

180 files / 2405 checks · integration 58 files / 235 checks · check-types 0 across all nine
workspaces · oxlint 44 warnings, 0 errors · turbo run build green, which is the real tsc
declaration emit rather than tsgo · sp-diff-checks clean · sp-lint-delta introduced nothing.

Decisions, rejected alternatives and the full review log are in
packages/payload-plugin-translator/docs/plans/2026-10-22-the-receipt-stops-guessing.task.md.

Part of #198

A provenance receipt recorded, per leaf, a source hash, UNANSWERED, or
DECLINED. DECLINED was written for every leaf a run saw but did not send, and
both readers took it as "claim nothing": the leaf never read as out of date,
and skip_existing never selected it again however far its source drifted.

That made the plugin protect one kind of hand-written text and destroy
another, with nothing an editor could see to tell them apart. A target a
person filled before the plugin ever ran was kept for good; a target the
plugin wrote and a person then corrected was overwritten the moment the
source moved.

The receipt now records where each leaf's source stood when the run ended,
whether the run translated that leaf or left it alone. Two states remain, a
hash and UNANSWERED, and automatic translation takes every leaf whose source
moved without asking who last touched the target. Where a field must be left
alone, withFieldTranslation(field, { exclude: true }) is the explicit mark;
per-document control arrives with the field picker.

A null in a receipt written by 1.0.0-next.3 or earlier is read at the parse
boundary as UNANSWERED, so the leaf is revisited once and re-based. Refusing
the value instead would fail the whole map and leave the record answering
nothing about any leaf.

This reverses a documented promise. README said skip_existing "fills only
empty target fields"; that line, the strategy tables, the AutoTranslateStrategy
docblock and the admin tooltip are corrected, and DEPRECATIONS carries the
entry. The refresh needs provenance on and a receipt already written for the
locale, which every corrected statement now says.

Part of #198
@vercel

vercel Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
ideal-cms Ready Ready Preview Oct 22, 2026 2:03pm UTC

Request Review

@SearheiParkhamchuk SearheiParkhamchuk self-assigned this Oct 22, 2026
@SearheiParkhamchuk
SearheiParkhamchuk merged commit 247948c into major/translator Oct 22, 2026
2 checks passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.0.0-next.4 🎉

The release is available on npm package (@next dist-tag)

Your semantic-release bot 📦🚀

@SearheiParkhamchuk
SearheiParkhamchuk deleted the fix/translator-the-receipt-stops-guessing branch October 22, 2026 14:24

This branch was successfully deployed

1 active deployment
Preview — d0bc719b Deployed Oct 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant