Skip to content

feat: a run names its fields, and the plugin stops deciding who has access - #209

Merged
SearheiParkhamchuk merged 25 commits into
major/translatorfrom
feat/translator-fields-not-a-strategy
Oct 26, 2026
Merged

SearheiParkhamchuk merged 25 commits into
major/translatorfrom
feat/translator-fields-not-a-strategy

Conversation

@SearheiParkhamchuk

@SearheiParkhamchuk SearheiParkhamchuk commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Closes #208

Part of #48, which keeps the field-picker tree and the admin forms.

The second half of this branch — one permission decision point — has no issue of its
own. The nearest is #113, translated writes bypass collection access control, closed on
2026-10-02 by the partial fix it describes; what lands here is the rest of it, measured as
six gaps in docs/plans/2026-10-25-asking-payload-everywhere.design.md.

Two bodies of work, twenty commits, one branch. They landed together because the
second rests on the first: a run that names its fields is also a run whose fields have
an owner, and the permission work asks about that owner per document.

The branch name only says the first half.

Part 1 — a run names its fields, not a strategy

1a143e71 … c792ac33

A translation run used to carry a strategy — overwrite, or skip_existing. The
strategy existed because nobody ever asked the editor which fields they meant, so a rule
had to guess, and two of its clauses were guesses about state the caller never stated:
"is the target empty", and "did the source move".

A run now carries the list of leaves to translate, or nothing, plus a list of named
narrowing rules. One rule exists, stale, answered by provenance. Ticks set the starting
list — every translatable leaf when nobody ticked anything — and each rule narrows it.
Narrowing is intersection, so rule order does not matter.

TranslationStrategy is gone as a port, with both implementations, from the pipeline,
the HTTP input, the stored row, the host-facing task and both admin forms. The chain is
assembled per target locale instead of once at boot, because a rule named on one locale
and not another cannot be served by a list decided at startup.

Two defects the selection exposed are fixed here: the receipt no longer re-bases leaves a
run did not send (which would have silenced the out-of-date marker for a field nobody
translated), and the staleness route reports which leaves drifted, since the refresh
button that used to stand in for that is retired with the strategy.

Part 2 — one permission decision point

bc761f64 … HEAD

The plugin never decides who has access. It asks twice and answers neither question
itself:

  • the host's AccessGuard is asked whether this kind of request is allowed at all,
    now with the action named — check({ req, action });
  • Payload's own access.update rule is asked about each document.

Nine routes, the queue, the two admin widgets and the translation chain sit behind one or
both. The queue's three methods take branded parameters that only the plugin's own
handlers can mint, so work cannot reach it without an ask having happened — the brands are
deliberately not exported, because an exported minter would be a way around the thing being
prevented.

Three routes that used to act on an opaque handle now resolve it to a document first:
POST /run/:id, DELETE /cancel, DELETE /cancel-by-collection/:slug. A client cannot
supply that document itself — it would be claiming which document someone else's handle
points at — so the mapping is read from the runner, which is the only thing that owns the
job storage.

Two narrower holes close with it. A request whose user carries no collection used to
read as no user, and a write with no user runs with Payload's checks off; it is now a
refusal. And a member of the translation chain used to receive the whole payload and
could write the very document under translation, past every ask above; it now receives a
view whose writes to the run's own collection throw.

Breaking changes

Each is documented in docs/DEPRECATIONS.md with what a host sees and what to do.

TranslationStrategy, its two implementations and createTranslationStrategy removed
AutoTranslateStrategy, AutoTranslateConfig.strategy removed — an automatic run now covers the whole document
AccessGuard.check receives { req, action }; only true allows, and a throw refuses instead of becoming a 500
the ./client/shared/types/AccessGuard deep import gone — the names come from the package root
TaskRunner.enqueue / cancel / run take branded parameters; implementing is unaffected, calling a runner directly no longer compiles
TaskRunner.assignmentsOf required, so a custom runner stops compiling until it is added
vitest a new optional peer dependency, for the entry point below
DELETE /cancel can answer 200 { refused } where it only ever answered 204
a chain module writing the translated collection throws, and the run fails loudly rather than half-writing

One addition worth naming

@focus-reactive/payload-plugin-translator/testing is a new entry point exporting
assertTaskRunnerContract — the suite that asserts every obligation TaskRunner owes its
callers. TaskRunner's own docblock has been telling runner authors to call it while it
sat in a directory the build excludes, so the promise could not be kept; now it can. No
brand minter is exported with it.

It also closes a hole this branch opened. The brand on AuthorisedWork is a unique symbol, which makes the built copy and the source copy unrelated types, and the one file
that imported both — apps/dev's contract test — broke repo-wide check-types the moment
the brand landed. Nothing caught it, because each step verified the package rather than the
repository. Both imports now resolve to one copy.

Left open, deliberately

  • The field-picker tree in the admin, built in a parallel line of work. This branch is the
    server side it will talk to.
  • Auto-translate's own control over what triggers a run and what a run covers. Until it
    lands, an automatic run covers the whole document.
  • A rule that adds leaves to what a person ticked. The shape admits narrowing only;
    widening it later is a redesign, not a new name.
  • A registry of narrowing rules. It arrives with the second rule, which will be its second
    caller.
  • POST /field still bypasses the runner, writes no receipt and does not clear staleness.
  • turbo.json has check-types depending on ^check-types rather than ^build, so the
    repo-wide type-check needs a dist/ that already exists. On a clean clone it fails with
    an unresolved module instead of a type error. Worth changing, and it touches every
    package's task graph, so not here.
  • DELETE /cancel drops a handle the runner cannot place and answers 204 when every
    handle was unplaceable. Saying so needs a third category beside cancelled and refused,
    which is an endpoint-contract change rather than part of this one.

Verification

Run from packages/payload-plugin-translator unless stated.

bunx vitest run 206 files / 2599 checks
bun run check-types 0 errors
bunx oxlint . 43 warnings, 0 errors — unchanged from the base
bunx turbo run build (repo root) green, run for real rather than relying on the type-checker
bun run test:integration (apps/dev) 226 passed, 8 skipped
bunx turbo run check-types (repo root) 9 of 9 — green for the first time on this branch

apps/dev's own build script fails on a SeoFieldPaths mismatch coming from
packages/payload-plugin-seo, and on a vitest config overload. Both pre-date this branch
and neither is touched by it.

The per-leaf decision collapses to "is this leaf translatable, is it in the
selection, and is its source non-empty". `TranslationStrategy`, both of its
implementations and `StrategyContext` are deleted, and the target's current
value stops deciding anything.

`sourceChangedByLeaf` is replaced by the selection list, so `ChangedLeaves` stops
travelling through the pipeline context. The selection algebra arrives beside it:
`narrowSelection` intersects a starting list with each answer, a
`TranslationFilter` is either the addresses a request named or the name of a
rule, and a `LeafAnswer` is complete in itself.
Each target locale carries its own filters: the leaf addresses a request named,
or the name of a narrowing rule. The stored row follows — it lives inside
Payload's single `input` column, so the shape changes without a migration — and
`target_lngs` becomes a list of entries rather than bare locale strings.

`pickHost` loses its strategy condition, so two requests over one document that
differ only in what they translate now share a single run.

A row written before 1.0 still reads: `strategy: "skip_existing"` maps to the
drift rule, which is the nearest meaning rather than the same one.

BREAKING CHANGE: `POST /enqueue` refuses a body still carrying `strategy`,
`selection` or `criteria` with a 400 that names what to send instead. See
docs/DEPRECATIONS.md#the-strategy-leaves-the-run.
The handler expands the row's filters into leaf addresses and hands the pipeline
a finished list; the pipeline knows nothing about filters. One rule exists,
`stale`, answered through the criteria the plugin was built with — an unknown
name fails the run rather than widening it to the whole document.

What a run is fixed on becomes a named context built once per call and never held
on the handler, because the queue runs jobs in batches and one instance serves
them all.

The permission checks leave the handler for the target document: every read
carries the requester's identity, and the single write demands a value only the
check can mint, so a write cannot be sent without the collection's rules having
been asked about exactly what it carries.
Provenance keeps its one job, reporting: it answers which leaves drifted and the
run decides. The recorder stops re-basing a leaf the run did not send, which a
selection makes wrong — a field nobody translated would otherwise start claiming
it was made from the current source, and the out-of-date marker would go quiet
for it.

The receipt holds a per-leaf map or nothing, so a pre-0.15.0 whole-document
digest now reads as "claims nothing about any leaf". The staleness route reports
which leaves drifted, because the refresh button that stood for "bring this
locale up to date" goes with the strategy.
An automatic run names no filters, so it covers the whole document until
auto-translate gains its own control over what a run translates.

BREAKING CHANGE: `AutoTranslateStrategy` is no longer exported and
`AutoTranslateConfig.strategy` is removed. A host that set `skip_existing` had
automatic runs that left translated fields alone; those runs now re-translate
whole documents, at full provider cost, over reviewed text. The option is refused
rather than ignored. See docs/DEPRECATIONS.md#the-strategy-leaves-the-run.
The strategy select goes from both forms, and with it the refresh action on the
status list — its job, "bring this locale up to date", is now a request naming
the drift rule.
The suites run the real queue runner and read the stored row, so they carry the
shape change: filters per target locale, the drift rule answered in the worker,
and a selection that leaves its siblings untouched.
Two entries in DEPRECATIONS — the strategy leaving the run, and the stored row's
`target_lngs` and `strategy` pair — plus the task contract for each step of the
work and the corrected verification paragraph in the rewiring plan.
`TranslateDocumentHandler` is replaced by `translateDocument(deps, payload,
input, scope)`. Four concerns that were private methods become siblings —
`selectLeaves`, `runModules`, `translateOrWrap`, `reportNothingSent` — and what
a run is fixed on moves to `run.ts`, so the entry's body is the order and
nothing else: nine statements, one per step.

Reading the source and asking the collection's rules about it become one call,
`readSourceIfAllowed`, which returns a value only it can mint. Narrowing, the
module chain and the provider call all demand that value, so paying the provider
before the rules were asked no longer compiles. The brand's key is a symbol this
module never exports, so unlike a string brand no literal can forge it.

- `Handler<TInput, TOutput>` is deleted: one implementer, and the interface
  declared two parameters while the real call takes three — hiding `scope`,
  which carries the caller's transaction and identity
- `model.ts` is deleted; `TaskHandlerInput` already said the same thing one
  layer down, and the wiring stops re-spelling the input field by field
- `scope` becomes a required parameter, so a run cannot be started by accident
  with no requester and Payload's access checks off

Nothing published changes: `src/index.ts` names neither the class nor the
interface, and `exports` allows no deep import, so the removed names were
reachable only from inside this package.
`mayWrite`, `rebuildRequester` and `checkTranslationPermission` leave
`features/translate-document/` for `server/shared/access/permission.ts`, and
`PermissionCheckFailed` and `RequesterMissing` move with them — leaving the two
error classes behind would have made `shared/` import `features/`, the upward
edge the move exists to remove.

`visibleIds` and `getAllCollectionIds` move from `features/_lib/` to
`server/shared/payload/collections.ts`; `isCollectionAvailable` stays.

Both moves exist so that the entry points which must start asking the host's
collection rules can reach the function that asks, without importing across a
feature boundary. No behaviour changes: the bodies travelled unedited, and the
only difference in the moved asker is two import lines.

Four test suites mock by module path and were re-pointed; no assertion changed.
POST /translate/field and POST /translate/stale/dismiss ask the collection's
update rule before any read, any provider call and any write. The field route
no longer pays for a caller it would refuse; dismiss no longer lets a reader
re-base the provenance receipt for everyone.

Both ask through mayInitiate, which puts the empty-data probe in one place:
mayWrite requires the data the write will send, and these two calls break that
on purpose, so the exception is named once rather than repeated.

An absent document raises no objection rather than failing the ask. Payload's
evaluator loads the document itself when data is empty, and without
disableErrors a missing id threw, turning a 404 into a 500.

An actor-less request is still not refused; step 8 decides that.
AccessGuard.check gains an action: a closed, published union of the nine
route actions. Nine routes and both admin widgets name theirs, so a host
can allow enqueueing and refuse cancelling without writing two guards.
Implementing check({ req }) is unaffected; the property is additive.

The answer is now read in one place. Only a literal true allows, where any
truthy value used to; a guard that throws refuses instead of escaping the
error handler and breaking the request. A framework navigation signal is
not an answer and still propagates.

Three things owe a release note, which step 10 writes: the stricter
reading, the throw becoming a refusal, and the removal of the
undocumented deep import of client/shared/types/AccessGuard.
A request that names document ids had nobody ask, per document, whether
the caller may change them. The update rule is now asked about each named
id before anything is queued; refused ids are dropped and named in the
response under `refused`, so a bulk selection of fifty with two refused
still queues the forty-eight the caller is entitled to.

One question per document, not one per document and locale: Payload's
access rules receive no locale and it has no per-locale permissions.

select_all is unchanged. It is already narrowed to what the caller may
read, which Payload folds into one query, and asking the update rule
there would cost one evaluation per document in the collection. The
worker refuses before it spends either way.

An empty string is no longer accepted as a document id. Payload skips the
document load for one, so the rule was asked about nothing.
Running, cancelling and cancelling-by-collection acted on jobs with no
check of whose work they were. A job is the plugin's own object and the
host has no rule about it, so the question is reduced to one the host has
already answered: a job may be cancelled by anyone who may change its
document, and run by them too.

TaskRunner gains an optional assignmentsOf(handles), so a handle can be
placed on a document and a requester. Both runners implement it; a runner
that does not still satisfies the interface, and the routes that need it
refuse rather than proceed.

run/:id also refuses a job nobody is recorded as having started. Without a
requester the worker writes with the host's rules off, so letting any
caller trigger one by naming its handle is the escalation this closes. A
handle that resolves to nothing stays a 404, as it was — absence is not a
refusal.

cancel drops the handles it may not touch and names them, keeping 204 when
none were refused. cancel-by-collection asks with the rows it already
holds and needs no new read.
Steps before this one added asks. Each was a call a later edit could
delete, and nothing would have noticed. Now enqueue, cancel and run accept
only a value one of four named paths produces, so forgetting to ask does
not compile. Narrowing the three methods surfaced 29 type errors across
seven files, which was every place that reached the queue.

Two paths ask: authorisedWork for documents, authorisedHandles for jobs.
Two do not, and say so by name rather than by the absence of a call.
authorisedByTheSave carries why the auto-translate hook must not ask -
a host callback that throws inside the editor's afterChange rolls back
their save. authorisedByTheReadFilter carries why select_all does not -
Payload already folded the read rule into the query that found those
documents.

A refusal is reachable only through its collection, so two collections
holding the same document id cannot be confused for one another.

BREAKING CHANGE: code that calls TaskRunner.enqueue, cancel or run
directly must obtain its argument from one of those paths. Implementing
TaskRunner is unaffected: TypeScript compares method parameters
bivariantly, so a runner declaring the wider types still satisfies the
interface.
identityOf answered with a requester or null, and that null covered three
different situations: the request carried no user, it carried one with no
id, or it carried one whose auth collection this project cannot determine.
The last two were getting the trust the first was granted on purpose, and
the code already knew it - it logged that the write would go through
without a permission check, and let it.

Identity is now three-valued, and a scope can only be built from two of
them, so an actor nobody can name has nowhere to act. The placeable kinds
are listed rather than subtracted, which means a kind added later is
refused until someone decides otherwise.

Enqueue answers 403. The auto-translate hook skips instead, because inside
the save's transaction a throw would roll the editor's own save back, and
it says in the log why it skipped. Both unnameable branches now explain
themselves; one of them used to answer silently.

The no-user case is untouched, down to the shape of the scope it produces.
Its pinned integration test passes unmodified.

Payload's own auth always stamps the collection, so this is reachable only
through a host's custom auth strategy or a local-API caller passing a
hand-made user.
A module on the translation-run chain was handed the whole payload, so it
could write the very document the run was translating - past the asks the
routes and the queue had just been taught. The chain now hands each module
a view whose writes refuse the run's own collection. Reads pass through,
and so do writes anywhere else.

Guarded: create, update, delete, duplicate and restoreVersion. The design
named the first three; the other two were found by checking Payload's
surface, and both take a collection and write. payload.db reaches the
database directly and is not covered - the docblock says so rather than
implying the seam is sealed.

The refusal is a runtime guard, not a type. The run's collection is a
value, so "any collection but this one" cannot be stated in a signature,
and a narrowed type would have broken provenance, which passes the seam's
payload into a factory declaring the full Payload. Provenance needed no
change, as the design said; nothing breaks at compile time, so this is not
the big bang the design expected.
Nine routes pass a literal action to the guard, and nothing pinned which
literal each one passes: a route could ask about "enqueue" where it meant
"cancel" and every check stayed green. The table that pins it is keyed by
TranslatorAction, so a tenth action does not compile until the route that
answers for it is named.

An unattributed job refused by POST /run/:id is now pinned end to end,
against the real queue runner, which step 6 owed.

The release notes name every host-visible change of this work: the action
on the guard and only `true` allowing, enqueue dropping documents the
caller may not change, a job resolving to its document, the queue taking
only branded work, an unnameable actor no longer read as no actor, and a
chain module refused the run's own collection.

@SInCE 1.0.0 on the three published additions. The brands and
JobAssignment get none: they are not re-exported, so the package rule
does not reach them.
A runner that cannot say which document a handle names cannot be asked
about it, and Payload's rule is asked about a document, never a handle.
Optionality bought only a worse failure: a project that builds while run
and cancel answer 403, found from a status code instead of the compiler.

BREAKING CHANGE: `TaskRunner.assignmentsOf` is required. A custom runner
stops compiling until it is added. Its parameter is `readonly JobHandle[]`,
an alias of `string`, so nothing about assignability moves. The `@since`
tag is dropped — `TaskRunner` is not re-exported from `src/index.ts`, only
`TaskRunnerProvider` is. See docs/DEPRECATIONS.md, "one permission
decision point".
`TaskRunner`'s own docblock told a runner author to call
`assertTaskRunnerContract`, while the suite lived in `__tests__`, which the
build excludes — the promise could not be kept. It moves to `src/testing`
and ships from a new `./testing` entry point. No minter is exported:
`asWork` and `asHandle` stay private and the brand types appear nowhere in
the emitted declarations.

This also fixes repo-wide `check-types`, red since the brand landed. The
`apps/dev` contract test took the runner factory from the built package and
the suite from a path into `src/`, and a `unique symbol` makes those two
copies unrelated types. Both now resolve to one copy.

`vitest` becomes an optional peer dependency: the suite imports it at module
level, so it is needed at import time, not at the call.
@vercel

vercel Bot commented Oct 26, 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 26, 2026 4:17pm UTC

Request Review

…ages

`mayInitiate` asks with empty `data`, which makes Payload load the document itself to
answer — and a loaded document comes back in whichever locale the ask names. Three routes
named three different ones: `POST /enqueue` the project default, `POST /field` and
`POST /stale/dismiss` the locale being translated into.

So a host rule that reads the document answered per language. Before a first translation
the target locale holds nothing (`fallbackLocale: null`), so such a rule refused the very
request that would have filled it, while the same rule allowed the same person through
`POST /enqueue` for the same document.

Rights in Payload have no language. `mayInitiate` now names the default locale itself and
`targetLocale` leaves its signature, so naming the wrong one stops compiling. `mayWrite`
keeps the parameter: that ask has to match the write it precedes.

Two checks pin the locale, proven by replacing the choice with a literal.
…sked

`POST /enqueue` asked the host's `update` rule about every named document — or, on
`select_all`, read the whole collection — and only then refused a request carrying a user
it could not place in an auth collection.

Payload does not refuse such a user. `createLocalReq` fills a missing `user.collection`
with the admin collection and says so in its own source ("TODO (4.0): Instead of silently
falling back, throw an error"). So the rule was evaluated as whoever happens to share that
id there — a stranger — and a rule that throws turned a plain 403 into a 500 after
`docAccessOperation` had already killed the transaction.

The refusal now comes first. `handler.unnameable.test.ts` could not see the ordering
because it replaces `authorisedWork` wholesale; the new file leaves the ask in place and
watches whether it happened.
…apper drops its cast

`getAllCollectionIds` defaulted its `user` parameter to `null`, and `enforcedAtTheRead`
reads that as "nobody is asking" — so the read ran with the host's `read` rule not applied
at all. `authorisedByTheReadFilter` then stamped every id it returned as work the rules
had been asked about, which is the one claim the brand exists to make. One omitted
argument was enough to make the claim false, and the suite stayed green when it was.
The default is gone; forgetting the reader is now `TS2554`.

`withErrorHandler` was generic over `(...args: any[])` and cast its own return with
`as T`. No route ever passed anything but a request: the generality existed for two checks
that exercised the generality. Those checks now state the contract instead — the wrapper
hands the handler the request, and hands the handler's own response back — and the
signature says `RouteHandler`, so the `any` and the cast are both unnecessary.

Four checks cover what the two reads actually ask Payload for; each is proven by a
mutation that ignores the reader.
Each check below was written against a mutation that the suite passed in silence, and is
proven by that same mutation going red.

- Every route the plugin registers answers the guard, and a navigation a guard asks for
  reaches the framework. Routes are composed in three separate places, so the existing
  table of factories cannot see a fourth; this boots the plugin and counts what it
  registered. Swapping the two wrappers on one route — which nothing caught — now names
  the route and the status it answered instead.
- `getAllCollectionIds` and `visibleIds` name their reader to Payload. Nothing mentioned
  `enforcedAtTheRead` anywhere, across five callers.
- A refused document is named once however many locales asked for it. Every fixture that
  refused anything asked for one locale, where a per-task list and a per-document list
  cannot be told apart.
- A row carrying both the pre-1.0 locale list and the current targets answers from the
  current shape. Appending a locale to an old row produces exactly that row, and reversing
  the branch order left all 325 checks green.
- A selection naming a leaf the schema does not offer sends nothing. The floor was covered
  only where the selection came from the schema, never where it came from a request.
- A leaf the provider answered in part is not claimed as current.

`legacy-row-upgrade.int.test.ts` carries the same upgrade path end to end, through the
real queue runner: no integration suite replayed a row written before 1.0.

The positive controls in both pin files become named exports, so what they assert is in
the name rather than in a comment above an unguarded call.
@SearheiParkhamchuk SearheiParkhamchuk changed the title feat!: a run names its fields, and the plugin stops deciding who has access feat: a run names its fields, and the plugin stops deciding who has access Oct 26, 2026
Fifty-eight comment blocks were judged with fresh eyes against the project's own rule —
only non-obvious why. Eight said something false, which is worse than verbose: a reader
builds on it.

- `authorisedByTheReadFilter` promised the host's `read` rule had been folded into the
  query that produced its documents. Dropping the reader at the call site left every
  check green, so the guarantee was not one. The text now states the obligation on the
  caller, which is what the signature actually carries.
- `PermissionCheckFailed` claimed the only `mustPropagate = true`. There are two.
- `AuthorisedHandle` linked `{@link TaskRunner.cancel}`; `TaskRunner` is not imported
  into that file, so neither link resolved.
- Two comments cited payload 3.84.1 against an installed 3.90.1. Both underlying claims
  were re-checked and survive, so only the citations moved.
- `AccessGuard`'s `@example` still showed `check: ({ req })` after this branch added
  `action` — the one copyable line in the published docs taught a guard that ignores the
  parameter the release exists to add.
- `AccessGuardRequest` still said "API call" after the gate grew to cover widget renders.
- A wire type claimed snake_case "like every other translation endpoint";
  `get-document-status` returns `relationTo`.
- A deprecation note named a version, which the ledger's own rules forbid.

Net 16 lines fewer, with every fact kept: what went is motivation, history and
re-derivation. `withAccessCheck`'s docblock said only what its signature says and what
a pin already holds.
@SearheiParkhamchuk
SearheiParkhamchuk merged commit 5990b2a into major/translator Oct 26, 2026
2 checks passed
@github-actions

Copy link
Copy Markdown

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

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

Your semantic-release bot 📦🚀

This branch was successfully deployed

1 active deployment
Preview — 5b6130ac Deployed Oct 26, 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.

[translator] A run carries fields, not a strategy — the server half

1 participant