diff --git a/apps/dev/docs/multi-db-verification.md b/apps/dev/docs/multi-db-verification.md index 8c0182dee..c0164fc01 100644 --- a/apps/dev/docs/multi-db-verification.md +++ b/apps/dev/docs/multi-db-verification.md @@ -23,7 +23,7 @@ bun run verify:provenance:postgres ``` `scripts/verify-provenance.ts` boots Payload against the active adapter and asserts, via the local API -(no OpenAI call): the sidecar collection exists, `dismissedFingerprint: null` round-trips, the composite +(no OpenAI call): the sidecar collection exists, a receipt round-trips, the composite key `(collectionSlug, documentId, targetLocale)` is unique, and `deleteByDocument` (the query the `afterDelete` hook runs) clears a document's rows across all locales. The SQLite run uses a throwaway `.verify-sqlite.db` (push-created, gitignored) so it never touches your working `dev.db`. diff --git a/apps/dev/scripts/verify-provenance.ts b/apps/dev/scripts/verify-provenance.ts index feb8750e9..0a7b05e77 100644 --- a/apps/dev/scripts/verify-provenance.ts +++ b/apps/dev/scripts/verify-provenance.ts @@ -27,7 +27,7 @@ try { process.exit(1); } -// 2. dismissedFingerprint: null round-trips (the deferred cross-DB null check). +// 2. A receipt round-trips through the active adapter. const created = await payload.create({ collection: SLUG, data: { @@ -37,14 +37,11 @@ const created = await payload.create({ sourceLocale: "en", sourceFingerprint: "fp-verify", translatedAt: new Date().toISOString(), - dismissedFingerprint: null, }, }); const reread = await payload.findByID({ collection: SLUG, id: created.id }); -if (reread.dismissedFingerprint === null) ok("dismissedFingerprint: null round-trips"); -else fail(`dismissedFingerprint expected null, got ${JSON.stringify(reread.dismissedFingerprint)}`); if (reread.sourceFingerprint === "fp-verify" && reread.targetLocale === "de") - ok("other fields round-trip"); + ok("receipt fields round-trip"); else fail("field round-trip mismatch"); // 3. Composite key (collectionSlug, documentId, targetLocale) uniqueness. Hard check on SQL; on Mongo @@ -62,7 +59,6 @@ try { sourceLocale: "en", sourceFingerprint: "fp-dup", translatedAt: new Date().toISOString(), - dismissedFingerprint: null, }, }); duplicateAllowed = true; @@ -92,7 +88,6 @@ for (const locale of ["de", "fr"]) { sourceLocale: "en", sourceFingerprint: "fp-doc", translatedAt: new Date().toISOString(), - dismissedFingerprint: null, }, }); } diff --git a/apps/dev/src/integration/translator/access-control-queue.int.test.ts b/apps/dev/src/integration/translator/access-control-queue.int.test.ts index 7f7d2e87b..8e267ee8f 100644 --- a/apps/dev/src/integration/translator/access-control-queue.int.test.ts +++ b/apps/dev/src/integration/translator/access-control-queue.int.test.ts @@ -38,7 +38,12 @@ const jobsFor = async (documentId: string) => { ); }; -const enqueueAs = async (id: string, user: { id: string; collection: string } | null) => { +type Assignment = { handle: string }; + +const enqueueAs = async ( + id: string, + user: { id: string; collection: string } | null +): Promise => { const res = await callEndpoint(ctx.payload, "post", "/translate/enqueue", { user, body: { @@ -46,11 +51,12 @@ const enqueueAs = async (id: string, user: { id: string; collection: string } | target_lng: ["de"], collection_slug: "docs", collection_id: [id], - strategy: "overwrite", + publish_on_translation: false, }, }); expect(res.status).toBe(200); + return (res.data as { data: { assignments?: Assignment[] } }).data.assignments ?? []; }; const seed = async (title: string, tagline: string) => { @@ -129,4 +135,17 @@ describe("a queued translation is decided by whoever asked for it", () => { "de:Legacy" ); }); + + it("will not run on demand a row that recorded nobody — its write would skip every field rule", async () => { + const id = await seed("Orphan", "Orphan line"); + const [assignment] = await enqueueAs(id, null); + expect(assignment, "nothing was queued, so the run route was never reached").toBeDefined(); + + const res = await callEndpoint(ctx.payload, "post", "/translate/run/:id", { + user: editor, + routeParams: { id: String(assignment?.handle) }, + }); + + expect(res.status).toBe(403); + }); }); diff --git a/apps/dev/src/integration/translator/auto-translate-skip-existing.int.test.ts b/apps/dev/src/integration/translator/auto-translate-skip-existing.int.test.ts deleted file mode 100644 index 74ebcc712..000000000 --- a/apps/dev/src/integration/translator/auto-translate-skip-existing.int.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; - -import { bootTestPayload } from "./bootTestPayload"; -import type { TestPayload } from "./bootTestPayload"; - -// bootTestPayload caches one Payload per process, so a boot with different options needs its own file. - -const tr = (locale: string, text: string) => `${locale}:${text}`; - -describe("auto-translate on a collection that chose skip_existing", () => { - let auto: TestPayload; - - beforeAll(async () => { - auto = await bootTestPayload({ - autoTranslate: { targets: ["de"], strategy: "skip_existing" }, - }); - }); - afterAll(async () => { - await auto?.cleanup(); - }); - - it("refreshes the leaf whose source moved and leaves a hand correction alone", async () => { - const made = await auto.payload.create({ - collection: "docs", - locale: "en", - data: { _status: "published", title: "Skip title", note: "Skip note" }, - }); - const id = String(made.id); - - await auto.payload.update({ - collection: "docs", - id, - locale: "de", - data: { _status: "published", title: "KORRIGIERT" }, - }); - await auto.payload.update({ - collection: "docs", - id, - locale: "en", - data: { _status: "published", title: "Skip title", note: "Skip note, rewritten" }, - }); - - const de = (await auto.payload.findByID({ collection: "docs", id, locale: "de" })) as Record< - string, - unknown - >; - expect(de.note, "its source moved").toBe(tr("de", "Skip note, rewritten")); - expect(de.title, "its source did not — and here the strategy is honoured").toBe("KORRIGIERT"); - }); -}); diff --git a/apps/dev/src/integration/translator/blocks-structure.int.test.ts b/apps/dev/src/integration/translator/blocks-structure.int.test.ts index 880a5f11d..9de610135 100644 --- a/apps/dev/src/integration/translator/blocks-structure.int.test.ts +++ b/apps/dev/src/integration/translator/blocks-structure.int.test.ts @@ -8,18 +8,24 @@ import { callEndpoint } from "./callEndpoint"; // localized leaves — the recommended regime: block rows are SHARED across locales, paired by `id` // (not position) and re-emitted in SOURCE order. These specs pin that contract through the real // pipeline: same-type blocks each get their OWN translation, reordering the source re-mirrors order -// without cross-contaminating leaves or corrupting an already-translated locale, and skip_existing -// respects per-block edits — matched by id even when only some blocks are filled ("partially differ"). +// without cross-contaminating leaves or corrupting an already-translated locale, and a filter +// naming one block's leaf leaves its siblings' target text where it stands — paired by id. const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s); type Block = { id?: string; blockType: string; heading?: string; caption?: string }; +const filtersFrom = (rules?: string[], addresses?: string[]) => [ + ...(rules ?? []).map((name) => ({ kind: "rule", name })), + ...(addresses ? [{ kind: "leaves", addresses }] : []), +]; + const enqueue = ( ctx: TestPayload, id: string, target: string, - strategy: "overwrite" | "skip_existing" = "overwrite" + rules?: string[], + addresses?: string[] ) => callEndpoint(ctx.payload, "post", "/translate/enqueue", { body: { @@ -27,7 +33,7 @@ const enqueue = ( target_lng: target, collection_slug: "docs", collection_id: [id], - strategy, + filters: filtersFrom(rules, addresses), publish_on_translation: true, }, }); @@ -136,7 +142,7 @@ describe("blocks — id-based pairing, ordering, partial differences", () => { ]); }); - it("skip_existing fills empty block leaves but keeps a manually-edited one (matched by id)", async () => { + it("pairs blocks by id when a run takes every leaf, keeping their order", async () => { const created = await ctx.payload.create({ collection: "docs", locale: "en", @@ -167,10 +173,53 @@ describe("blocks — id-based pairing, ordering, partial differences", () => { }, }); - await enqueue(ctx, id, "de", "skip_existing"); + await enqueue(ctx, id, "de"); + + const de = await sectionsOf(ctx, id, "de"); + expect(de.map((b) => b.heading)).toEqual([ + tr("de", "Src A"), + tr("de", "Src B"), + tr("de", "Src C"), + ]); + }); + + it("sends one block's leaf and leaves its siblings' target text alone", async () => { + const created = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { + _status: "published", + title: "Selected block", + sections: [ + { blockType: "hero", heading: "Src A" }, + { blockType: "hero", heading: "Src B" }, + ], + }, + }); + const id = String(created.id); + const en = await sectionsOf(ctx, id, "en"); + + await enqueue(ctx, id, "de"); + const first = await sectionsOf(ctx, id, "de"); + expect(first.map((b) => b.heading)).toEqual([tr("de", "Src A"), tr("de", "Src B")]); + + await ctx.payload.update({ + collection: "docs", + id, + locale: "en", + data: { + _status: "published", + sections: [ + { id: en[0].id, blockType: "hero", heading: "Src A, edited" }, + { id: en[1].id, blockType: "hero", heading: "Src B, edited" }, + ], + }, + }); + + await enqueue(ctx, id, "de", undefined, [`sections.${en[1].id}:hero.heading`]); const de = await sectionsOf(ctx, id, "de"); - // Empty siblings filled from source; the manually-edited middle block kept — paired by id. - expect(de.map((b) => b.heading)).toEqual([tr("de", "Src A"), "MANUAL B", tr("de", "Src C")]); + expect(de[0]?.heading, "its address was not named").toBe(tr("de", "Src A")); + expect(de[1]?.heading, "named by its own id, not its position").toBe(tr("de", "Src B, edited")); }); }); diff --git a/apps/dev/src/integration/translator/bootTestPayload.ts b/apps/dev/src/integration/translator/bootTestPayload.ts index 10cf4886b..c780c4586 100644 --- a/apps/dev/src/integration/translator/bootTestPayload.ts +++ b/apps/dev/src/integration/translator/bootTestPayload.ts @@ -81,7 +81,7 @@ export type TestPayload = { * empty, not as the default locale's text. Localization-level, so it applies to the whole boot. */ export async function bootTestPayload(opts?: { - autoTranslate?: { targets: string[]; strategy?: "overwrite" | "skip_existing" }; + autoTranslate?: { targets: string[] }; collections?: CollectionConfig[]; fallback?: boolean; exclusiveQueue?: boolean; diff --git a/apps/dev/src/integration/translator/cancel-after-giving-up.int.test.ts b/apps/dev/src/integration/translator/cancel-after-giving-up.int.test.ts index 8dba22a4b..c7666b9fb 100644 --- a/apps/dev/src/integration/translator/cancel-after-giving-up.int.test.ts +++ b/apps/dev/src/integration/translator/cancel-after-giving-up.int.test.ts @@ -49,7 +49,7 @@ describe("cancelling a run that has already given up", () => { target_lng: ["de", "fr"], collection_slug: "docs", collection_id: [String(made.id)], - strategy: "overwrite", + publish_on_translation: false, }, }); @@ -83,7 +83,7 @@ describe("cancelling a run that has already given up", () => { target_lng: ["de", "fr"], collection_slug: "docs", collection_id: [String(made.id)], - strategy: "overwrite", + publish_on_translation: false, }, }); diff --git a/apps/dev/src/integration/translator/cancel-announcement.int.test.ts b/apps/dev/src/integration/translator/cancel-announcement.int.test.ts index 03fc1f45b..4deddde8a 100644 --- a/apps/dev/src/integration/translator/cancel-announcement.int.test.ts +++ b/apps/dev/src/integration/translator/cancel-announcement.int.test.ts @@ -54,7 +54,7 @@ describe("cancelling announces what it stopped, to a host that registered nothin target_lng: ["de", "fr", "es"], collection_slug: "docs", collection_id: [String(made.id)], - strategy: "overwrite", + publish_on_translation: false, }, }); diff --git a/apps/dev/src/integration/translator/cancel-while-running.int.test.ts b/apps/dev/src/integration/translator/cancel-while-running.int.test.ts index fa5d4abe0..1fffeabdf 100644 --- a/apps/dev/src/integration/translator/cancel-while-running.int.test.ts +++ b/apps/dev/src/integration/translator/cancel-while-running.int.test.ts @@ -54,7 +54,7 @@ describe.skipIf(!SQLITE)("cancelling a run that is already translating", () => { target_lng: ["de", "fr"], collection_slug: "docs", collection_id: [String(made.id)], - strategy: "overwrite", + publish_on_translation: false, }, }); diff --git a/apps/dev/src/integration/translator/completed-handle.int.test.ts b/apps/dev/src/integration/translator/completed-handle.int.test.ts index 7d0f253be..1f26f91d3 100644 --- a/apps/dev/src/integration/translator/completed-handle.int.test.ts +++ b/apps/dev/src/integration/translator/completed-handle.int.test.ts @@ -35,7 +35,7 @@ describe("the handle the enqueue answer named is the handle onCompleted carries" target_lng: ["de", "fr"], collection_slug: "docs", collection_id: [String(made.id)], - strategy: "overwrite", + publish_on_translation: false, }, }); diff --git a/apps/dev/src/integration/translator/data-integrity.int.test.ts b/apps/dev/src/integration/translator/data-integrity.int.test.ts index 1a2d450e0..6a882be58 100644 --- a/apps/dev/src/integration/translator/data-integrity.int.test.ts +++ b/apps/dev/src/integration/translator/data-integrity.int.test.ts @@ -46,7 +46,7 @@ const enqueue = (ctx: TestPayload, id: string, target: string, publish = true) = target_lng: target, collection_slug: "docs", collection_id: [id], - strategy: "overwrite", + publish_on_translation: publish, }, }); diff --git a/apps/dev/src/integration/translator/document-translation.int.test.ts b/apps/dev/src/integration/translator/document-translation.int.test.ts index a3ab767e5..8131dc0e8 100644 --- a/apps/dev/src/integration/translator/document-translation.int.test.ts +++ b/apps/dev/src/integration/translator/document-translation.int.test.ts @@ -42,7 +42,7 @@ describe("document translation (manual enqueue, en -> de/fr)", () => { target_lng: target, collection_slug: "docs", collection_id: [id], - strategy: "overwrite", + publish_on_translation: true, }, }); diff --git a/apps/dev/src/integration/translator/draft-safe-writes.int.test.ts b/apps/dev/src/integration/translator/draft-safe-writes.int.test.ts index b16cf5389..efb8c01fc 100644 --- a/apps/dev/src/integration/translator/draft-safe-writes.int.test.ts +++ b/apps/dev/src/integration/translator/draft-safe-writes.int.test.ts @@ -13,7 +13,7 @@ const PUBLISHED_NOTE = "PUBLISHED NOTE"; const PUBLISHED_PRICE = 200; const DRAFT_PRICE = 999; const FOREIGN_DRAFT = "FR SECRET DRAFT"; -const CLEARED_TO_GIVE_THE_RERUN_WORK = ""; +const CLEARED_BY_A_PERSON = ""; type Slug = "docs" | "plain" | "auto"; type Locale = "en" | "de" | "fr"; @@ -58,10 +58,12 @@ const readAt = (draft: boolean) => (collection: Slug, id: string, locale: Locale const live = readAt(false); const asDraft = readAt(true); +const ruleFilters = (rules?: string[]) => rules?.map((name) => ({ kind: "rule", name })); + async function translate( collection: Slug, id: string, - opts: { publish: boolean; strategy?: "overwrite" | "skip_existing" } + opts: { publish: boolean; rules?: string[] } ): Promise { const res = await callEndpoint(payload, "post", "/translate/enqueue", { body: { @@ -69,7 +71,7 @@ async function translate( target_lng: "de", collection_slug: collection, collection_id: [id], - strategy: opts.strategy ?? "overwrite", + filters: ruleFilters(opts.rules), publish_on_translation: opts.publish, }, }); @@ -159,7 +161,7 @@ describe("draft-safe and per-locale-safe writes (#102)", () => { expect((await live("auto", id, "de")).title).toBe(TRANSLATED); }); - it("skip_existing preserves a human's correction to a draft translation", async () => { + it("leaves a draft translation alone when the source behind it did not move", async () => { const id = await create("docs", "published"); await translate("docs", id, { publish: false }); @@ -168,14 +170,16 @@ describe("draft-safe and per-locale-safe writes (#102)", () => { id, locale: "de", draft: true, - data: { title: "HUMAN FIX", subtitle: CLEARED_TO_GIVE_THE_RERUN_WORK }, + data: { title: "HUMAN FIX", subtitle: CLEARED_BY_A_PERSON }, }); - await translate("docs", id, { publish: false, strategy: "skip_existing" }); + await translate("docs", id, { publish: false, rules: ["stale"] }); const de = await asDraft("docs", id, "de"); - expect(de.title).toBe("HUMAN FIX"); - expect(de.subtitle).toBe(tr("de", SUBTITLE)); + expect(de.title, "the correction stands: its source did not move").toBe("HUMAN FIX"); + expect(de.subtitle, "a cleared leaf is not refilled: its source did not move").toBe( + CLEARED_BY_A_PERSON + ); }); it("publish mode takes the current draft live, including edits the translation did not touch", async () => { @@ -215,11 +219,11 @@ describe("draft-safe and per-locale-safe writes (#102)", () => { expect((await live("docs", id, "en")).nonLocalizedNote).toBe(PUBLISHED_NOTE); }); - it("publish mode with skip_existing actually publishes", async () => { + it("publish mode publishes a run narrowed to what moved", async () => { const id = await create("docs", "published"); await translate("docs", id, { publish: false }); - await translate("docs", id, { publish: true, strategy: "skip_existing" }); + await translate("docs", id, { publish: true, rules: ["stale"] }); const de = await live("docs", id, "de"); expect(de.title).toBe(TRANSLATED); diff --git a/apps/dev/src/integration/translator/enqueue-handles.int.test.ts b/apps/dev/src/integration/translator/enqueue-handles.int.test.ts index dba0f82d6..61491cd1e 100644 --- a/apps/dev/src/integration/translator/enqueue-handles.int.test.ts +++ b/apps/dev/src/integration/translator/enqueue-handles.int.test.ts @@ -30,7 +30,7 @@ const enqueue = async (ids: string[], locales: string[]): Promise target_lng: locales, collection_slug: "docs", collection_id: ids, - strategy: "overwrite", + publish_on_translation: false, }, }); diff --git a/apps/dev/src/integration/translator/exclusive-queue.int.test.ts b/apps/dev/src/integration/translator/exclusive-queue.int.test.ts index 7ebe095b7..b1a2ee9f6 100644 --- a/apps/dev/src/integration/translator/exclusive-queue.int.test.ts +++ b/apps/dev/src/integration/translator/exclusive-queue.int.test.ts @@ -11,7 +11,7 @@ type RunResult = { jobStatus?: Record }; type Job = { processing?: boolean; - input?: { collection_id?: string; target_lngs?: string[] }; + input?: { collection_id?: string; targets?: { lng: string }[] }; }; let ctx: TestPayload; @@ -60,7 +60,7 @@ const enqueue = (id: string, targets: string[]) => target_lng: targets, collection_slug: "docs", collection_id: [id], - strategy: "overwrite", + publish_on_translation: false, }, }); @@ -104,7 +104,7 @@ describe("with the host's concurrency control on", () => { const queued = await jobsFor(id); expect(queued.length, "the request did not get a job of its own").toBe(2); expect( - queued.find((job) => !job.processing)?.input?.target_lngs, + queued.find((job) => !job.processing)?.input?.targets?.map((t) => t.lng), "the second job should carry only the locale that was asked for" ).toEqual(["fr"]); diff --git a/apps/dev/src/integration/translator/field-selection.int.test.ts b/apps/dev/src/integration/translator/field-selection.int.test.ts new file mode 100644 index 000000000..364ec745d --- /dev/null +++ b/apps/dev/src/integration/translator/field-selection.int.test.ts @@ -0,0 +1,150 @@ +import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload, CRON_BATCH_LIMIT } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +let ctx: TestPayload; + +const tr = (locale: string, text: string) => `${locale}:${text}`; + +beforeAll(async () => { + ctx = await bootTestPayload({ runner: createPayloadJobsRunner({ autoRun: false }) }); +}); + +afterAll(async () => { + await ctx?.cleanup(); +}); + +const create = async (data: Record) => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", ...data }, + }); + return String(made.id); +}; + +const enqueue = (body: Record) => + callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: "de", + collection_slug: "docs", + collection_id: [body.id], + publish_on_translation: true, + ...body, + }, + }); + +const leaves = (...addresses: string[]) => [{ kind: "leaves", addresses }]; + +const work = () => ctx.payload.jobs.run({ queue: "translations", limit: CRON_BATCH_LIMIT }); + +const lastJobError = async () => { + const { docs } = await ctx.payload.find({ + collection: "payload-jobs", + sort: "-createdAt", + limit: 1, + }); + return JSON.stringify(docs[0]?.log ?? []); +}; + +const german = async (id: string) => + (await ctx.payload.findByID({ collection: "docs", id, locale: "de" })) as Record; + +describe("a run carries the fields it was asked for, through the stored job row", () => { + it("translates the named leaves and leaves every sibling untouched", async () => { + const id = await create({ title: "Pick me", note: "Leave me" }); + + await enqueue({ id, filters: leaves("title") }); + await work(); + + const de = await german(id); + expect(de.title).toBe(tr("de", "Pick me")); + expect( + de.note, + "untranslated: an empty target leaf is filled from the source on assembly" + ).toBe("Leave me"); + }); + + it("translates everything when the request carries no filters", async () => { + const id = await create({ title: "All of it", note: "This too" }); + + await enqueue({ id, filters: [] }); + await work(); + + const de = await german(id); + expect(de.title).toBe(tr("de", "All of it")); + expect(de.note).toBe(tr("de", "This too")); + }); + + it("keeps one locale's leaf list out of a second request for another locale", async () => { + const id = await create({ title: "Shared run", note: "Only in French" }); + + await enqueue({ id, target_lng: "de", filters: leaves("title") }); + await enqueue({ id, target_lng: "fr" }); + await work(); + + const de = await german(id); + const fr = (await ctx.payload.findByID({ collection: "docs", id, locale: "fr" })) as Record< + string, + unknown + >; + + expect(de.note, "German asked for the title only, so this was never sent").toBe( + "Only in French" + ); + expect(fr.note, "French asked for everything").toBe(tr("fr", "Only in French")); + }); + + it("gives a later request for the same locale its own fields", async () => { + const id = await create({ title: "Both asked", note: "Second asked for this" }); + + await enqueue({ id, filters: leaves("title") }); + await work(); + await enqueue({ id, filters: leaves("note") }); + await work(); + + const de = await german(id); + expect(de.title, "the first request's leaf").toBe(tr("de", "Both asked")); + expect(de.note, "and the later request's, which the first run did not cover").toBe( + tr("de", "Second asked for this") + ); + }); + + it("applies one leaf list to every document it was sent with", async () => { + const first = await create({ title: "One", note: "Not this" }); + const second = await create({ title: "Two", note: "Nor this" }); + + await enqueue({ id: first, collection_id: [first, second], filters: leaves("title") }); + await work(); + + for (const [id, title] of [ + [first, "One"], + [second, "Two"], + ] as const) { + const de = await german(id); + expect(de.title).toBe(tr("de", title)); + expect(de.note, "the same list narrowed both documents").not.toContain("de:"); + } + }); + + it("fails the run rather than translate everything when nothing answers the rule", async () => { + const id = await create({ title: "Unknown rule" }); + + const response = await enqueue({ id, filters: [{ kind: "rule", name: "nobody-answers" }] }); + expect( + response.status, + "the route queues the name; what answers to it is the run's question" + ).toBe(200); + await work(); + + const de = await german(id); + expect(de.title, "the whole document was not translated instead").toBeUndefined(); + expect(await lastJobError(), "the run failed rather than quietly covering nothing").toContain( + "No criterion answers" + ); + }); +}); diff --git a/apps/dev/src/integration/translator/final-failure.int.test.ts b/apps/dev/src/integration/translator/final-failure.int.test.ts index 8f65886ec..39dbb6f23 100644 --- a/apps/dev/src/integration/translator/final-failure.int.test.ts +++ b/apps/dev/src/integration/translator/final-failure.int.test.ts @@ -51,7 +51,7 @@ describe("onFailed reports a final outcome, not an attempt", () => { target_lng: locales, collection_slug: "docs", collection_id: [String(made.id)], - strategy: "overwrite", + publish_on_translation: false, }, }); @@ -75,7 +75,7 @@ describe("onFailed reports a final outcome, not an attempt", () => { target_lng: ["de"], collection_slug: "docs", collection_id: [String(made.id)], - strategy: "overwrite", + publish_on_translation: false, }, }); diff --git a/apps/dev/src/integration/translator/gave-up-mid-run.int.test.ts b/apps/dev/src/integration/translator/gave-up-mid-run.int.test.ts index c2c3d11e2..e6a1d4749 100644 --- a/apps/dev/src/integration/translator/gave-up-mid-run.int.test.ts +++ b/apps/dev/src/integration/translator/gave-up-mid-run.int.test.ts @@ -53,7 +53,7 @@ describe("a run that spends its attempts across more than one locale", () => { target_lng: ["fr", "de"], collection_slug: "docs", collection_id: [String(made.id)], - strategy: "overwrite", + publish_on_translation: false, }, }); diff --git a/apps/dev/src/integration/translator/hand-edit-in-a-draft.int.test.ts b/apps/dev/src/integration/translator/hand-edit-in-a-draft.int.test.ts index 43974bc49..c3fd8d957 100644 --- a/apps/dev/src/integration/translator/hand-edit-in-a-draft.int.test.ts +++ b/apps/dev/src/integration/translator/hand-edit-in-a-draft.int.test.ts @@ -6,18 +6,16 @@ import { callEndpoint } from "./callEndpoint"; let ctx: TestPayload; -const translate = ( - id: string, - strategy: "overwrite" | "skip_existing", - { publish }: { publish: boolean } -) => +const ruleFilters = (rules?: string[]) => rules?.map((name) => ({ kind: "rule", name })); + +const translate = (id: string, { publish, rules }: { publish: boolean; rules?: string[] }) => callEndpoint(ctx.payload, "post", "/translate/enqueue", { body: { source_lng: "en", target_lng: "de", collection_slug: "docs", collection_id: [id], - strategy, + filters: ruleFilters(rules), publish_on_translation: publish, }, }); @@ -46,7 +44,7 @@ describe("a hand edit saved as a draft, then re-translated with skip_existing", }); const id = String(made.id); - await translate(id, "overwrite", { publish: true }); + await translate(id, { publish: true }); await ctx.payload.update({ collection: "docs", id, @@ -55,7 +53,7 @@ describe("a hand edit saved as a draft, then re-translated with skip_existing", draft: true, }); - await translate(id, "skip_existing", { publish: false }); + await translate(id, { publish: false, rules: ["stale"] }); expect((await germanDraft(id)).title).toBe("de:Source title — PLUS MEINE WORTE"); }); @@ -68,7 +66,7 @@ describe("a hand edit saved as a draft, then re-translated with skip_existing", }); const id = String(made.id); - await translate(id, "overwrite", { publish: false }); + await translate(id, { publish: false }); await ctx.payload.update({ collection: "docs", id, @@ -77,7 +75,7 @@ describe("a hand edit saved as a draft, then re-translated with skip_existing", draft: true, }); - await translate(id, "skip_existing", { publish: false }); + await translate(id, { publish: false, rules: ["stale"] }); expect((await germanDraft(id)).title).toBe("de:Draft title — PLUS MEINE WORTE"); }); @@ -90,7 +88,7 @@ describe("a hand edit saved as a draft, then re-translated with skip_existing", }); const id = String(made.id); - await translate(id, "overwrite", { publish: false }); + await translate(id, { publish: false }); await ctx.payload.update({ collection: "docs", @@ -107,7 +105,7 @@ describe("a hand edit saved as a draft, then re-translated with skip_existing", data: { _status: "published", title: "Stale title", note: "Stale note, rewritten" }, }); - await translate(id, "skip_existing", { publish: false }); + await translate(id, { publish: false, rules: ["stale"] }); const de = await germanDraft(id); expect(de.note, "its source moved").toBe("de:Stale note, rewritten"); diff --git a/apps/dev/src/integration/translator/inline-marks-capability.int.test.ts b/apps/dev/src/integration/translator/inline-marks-capability.int.test.ts index 1cb2fac97..35cacc457 100644 --- a/apps/dev/src/integration/translator/inline-marks-capability.int.test.ts +++ b/apps/dev/src/integration/translator/inline-marks-capability.int.test.ts @@ -73,7 +73,7 @@ describe("a provider silent about marks", () => { target_lng: "de", collection_slug: "nested", collection_id: [id], - strategy: "overwrite", + publish_on_translation: true, }, }); diff --git a/apps/dev/src/integration/translator/inline-marks-corrupt.int.test.ts b/apps/dev/src/integration/translator/inline-marks-corrupt.int.test.ts index b5bcf38f8..e442a3677 100644 --- a/apps/dev/src/integration/translator/inline-marks-corrupt.int.test.ts +++ b/apps/dev/src/integration/translator/inline-marks-corrupt.int.test.ts @@ -74,7 +74,7 @@ describe("a reply whose marks cannot be parsed", () => { target_lng: "de", collection_slug: "nested", collection_id: [id], - strategy: "overwrite", + publish_on_translation: true, }, }); diff --git a/apps/dev/src/integration/translator/inline-marks-nesting.int.test.ts b/apps/dev/src/integration/translator/inline-marks-nesting.int.test.ts index 36df621a4..8d46cf545 100644 --- a/apps/dev/src/integration/translator/inline-marks-nesting.int.test.ts +++ b/apps/dev/src/integration/translator/inline-marks-nesting.int.test.ts @@ -119,7 +119,7 @@ describe("container mode reaches rich text at every nesting depth", () => { target_lng: "de", collection_slug: "nested", collection_id: [id], - strategy: "overwrite", + publish_on_translation: true, }, }); diff --git a/apps/dev/src/integration/translator/inline-marks-structure.int.test.ts b/apps/dev/src/integration/translator/inline-marks-structure.int.test.ts index 394808b72..be673dad0 100644 --- a/apps/dev/src/integration/translator/inline-marks-structure.int.test.ts +++ b/apps/dev/src/integration/translator/inline-marks-structure.int.test.ts @@ -90,7 +90,7 @@ describe("every Lexical container shape, translated independently", () => { target_lng: "de", collection_slug: "nested", collection_id: [id], - strategy: "overwrite", + publish_on_translation: true, }, }); diff --git a/apps/dev/src/integration/translator/inline-marks.int.test.ts b/apps/dev/src/integration/translator/inline-marks.int.test.ts index 1e21b72d2..3b3036805 100644 --- a/apps/dev/src/integration/translator/inline-marks.int.test.ts +++ b/apps/dev/src/integration/translator/inline-marks.int.test.ts @@ -83,7 +83,7 @@ describe("container-granular rich text, saved and read back", () => { target_lng: "de", collection_slug: "docs", collection_id: [id], - strategy: "overwrite", + publish_on_translation: true, }, }); diff --git a/apps/dev/src/integration/translator/job-extend.int.test.ts b/apps/dev/src/integration/translator/job-extend.int.test.ts index 1635f296a..5f6ffa6dc 100644 --- a/apps/dev/src/integration/translator/job-extend.int.test.ts +++ b/apps/dev/src/integration/translator/job-extend.int.test.ts @@ -9,7 +9,7 @@ type Job = { id: string | number; completedAt?: string | null; processing?: boolean; - input?: { collection_id?: string; target_lngs?: string[] }; + input?: { collection_id?: string; targets?: { lng: string }[] }; }; let ctx: TestPayload; @@ -21,7 +21,7 @@ const enqueue = async (id: string, targets: string[] = ["de"]) => { target_lng: targets, collection_slug: "docs", collection_id: [id], - strategy: "overwrite", + publish_on_translation: false, }, }); @@ -70,11 +70,10 @@ describe("a second request extends the live job rather than replacing it", () => const live = await jobs(id); expect(live.length, "the second request queued a job of its own").toBe(1); - expect(live[0].input?.target_lngs, "the locales already owed were dropped").toEqual([ - "de", - "fr", - "es", - ]); + expect( + live[0].input?.targets?.map((t) => t.lng), + "the locales already owed were dropped" + ).toEqual(["de", "fr", "es"]); }); it("translates every locale the extended job accumulated", async () => { @@ -103,7 +102,10 @@ describe("a second request extends the live job rather than replacing it", () => const live = await jobs(id); expect(live.length).toBe(1); - expect(live[0].input?.target_lngs, "the locale was queued a second time").toEqual(["de"]); + expect( + live[0].input?.targets?.map((t) => t.lng), + "the locale was queued a second time" + ).toEqual(["de"]); }); it("leaves a finished job alone and gives the new request its own", async () => { diff --git a/apps/dev/src/integration/translator/legacy-row-upgrade.int.test.ts b/apps/dev/src/integration/translator/legacy-row-upgrade.int.test.ts new file mode 100644 index 000000000..494b1ec9d --- /dev/null +++ b/apps/dev/src/integration/translator/legacy-row-upgrade.int.test.ts @@ -0,0 +1,118 @@ +import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +/* + * The upgrade path, end to end: a row written before 1.0 names `target_lngs` and a `strategy`, and + * carries no `targets`. Appending a locale to such a row writes `targets` beside the fields that + * were already there, so the row then carries both shapes — a state only this path produces. + */ + +type LegacyInput = { + collection_slug?: string; + collection_id?: string; + source_lng?: string; + target_lngs?: string[]; + strategy?: string; + targets?: { lng: string; filters?: unknown[] }[]; +}; +type Job = { id: string | number; input?: LegacyInput }; + +let ctx: TestPayload; + +const createDoc = async () => { + const doc = await ctx.payload.create({ + collection: "docs" as "pages", + locale: "en", + data: { title: "Src", _status: "published" } as never, + }); + return String(doc.id); +}; + +const enqueue = (id: string, locales: string[]) => + callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: locales, + collection_slug: "docs", + collection_id: [id], + publish_on_translation: false, + }, + }); + +const jobFor = async (documentId: string): Promise => { + const { docs } = await ctx.payload.find({ + collection: "payload-jobs" as "pages", + pagination: false, + where: { workflowSlug: { equals: "translate_document_locales" } } as never, + }); + const found = (docs as Job[]).find((job) => job.input?.collection_id === documentId); + if (!found) throw new Error("no job row for that document"); + return found; +}; + +/** Rewrites a live row into the shape a pre-1.0 release wrote. */ +const ageTheRow = async (job: Job) => { + const { targets: _dropped, ...rest } = job.input ?? {}; + await ctx.payload.update({ + collection: "payload-jobs" as "pages", + id: job.id, + data: { + input: { ...rest, target_lngs: ["de"], strategy: "skip_existing" }, + } as never, + }); +}; + +beforeAll(async () => { + ctx = await bootTestPayload({ runner: createPayloadJobsRunner({ autoRun: false }) }); +}); +afterAll(async () => { + await ctx?.cleanup(); +}); + +describe("a run row written before 1.0", () => { + it("takes an appended locale without losing the one it already named", async () => { + const id = await createDoc(); + expect((await enqueue(id, ["de"])).status).toBe(200); + await ageTheRow(await jobFor(id)); + + expect((await enqueue(id, ["fr"])).status).toBe(200); + + const input = (await jobFor(id)).input ?? {}; + expect(input.targets?.map((target) => target.lng)).toEqual(["de", "fr"]); + }); + + it("carries its strategy over as the drift rule, and narrows the appended locale by nothing", async () => { + const id = await createDoc(); + expect((await enqueue(id, ["de"])).status).toBe(200); + await ageTheRow(await jobFor(id)); + expect((await enqueue(id, ["fr"])).status).toBe(200); + + const targets = (await jobFor(id)).input?.targets ?? []; + + expect(targets[0]).toMatchObject({ lng: "de", filters: [{ kind: "rule", name: "stale" }] }); + expect(targets[1]).toMatchObject({ lng: "fr", filters: [] }); + }); + + it("still answers the document-status route for the locales it names", async () => { + const id = await createDoc(); + expect((await enqueue(id, ["de"])).status).toBe(200); + await ageTheRow(await jobFor(id)); + + const res = await callEndpoint( + ctx.payload, + "get", + "/translate/document/:collection_slug/:collection_id", + { + routeParams: { collection_slug: "docs", collection_id: id }, + } + ); + + expect(res.status).toBe(200); + const rows = (res.data as { data: { input: { target_lng: string } }[] }).data; + expect(rows.map((row) => row.input.target_lng)).toEqual(["de"]); + }); +}); diff --git a/apps/dev/src/integration/translator/locale-append.int.test.ts b/apps/dev/src/integration/translator/locale-append.int.test.ts index 33584d130..c4d64d1ee 100644 --- a/apps/dev/src/integration/translator/locale-append.int.test.ts +++ b/apps/dev/src/integration/translator/locale-append.int.test.ts @@ -12,7 +12,7 @@ import { callEndpoint } from "./callEndpoint"; type Job = { id: string | number; completedAt?: string | null; - input?: { target_lngs?: string[] }; + input?: { targets?: { lng: string }[] }; log?: Array<{ state: string; input?: { target_lng?: string } }>; }; @@ -69,7 +69,7 @@ describe("adding a locale to a running job", () => { target_lng: ["de", "fr"], collection_slug: "docs", collection_id: [id], - strategy: "overwrite", + publish_on_translation: false, }, }); @@ -90,7 +90,7 @@ describe("adding a locale to a running job", () => { target_lng: ["es"], collection_slug: "docs", collection_id: [id], - strategy: "overwrite", + publish_on_translation: false, }, }); diff --git a/apps/dev/src/integration/translator/locale-workflow-failure.int.test.ts b/apps/dev/src/integration/translator/locale-workflow-failure.int.test.ts index d62fa32b6..f886f4af3 100644 --- a/apps/dev/src/integration/translator/locale-workflow-failure.int.test.ts +++ b/apps/dev/src/integration/translator/locale-workflow-failure.int.test.ts @@ -32,13 +32,24 @@ describe("when one locale's provider fails", () => { }); const id = String(doc.id); + // The run endpoint refuses a job nobody is recorded as having asked for, because running one + // writes with the host's rules off. Attribute this job so the retry under test is reachable. + const editor = { + ...(await failing.payload.create({ + collection: "users", + data: { email: "retry@test.dev", password: "pw123456" } as never, + })), + collection: "users", + }; + const res = await callEndpoint(failing.payload, "post", "/translate/enqueue", { + user: editor as never, body: { source_lng: "en", target_lng: ["de", "fr", "es"], collection_slug: "docs", collection_id: [id], - strategy: "overwrite", + publish_on_translation: false, }, }); @@ -100,6 +111,7 @@ describe("when one locale's provider fails", () => { // retry has to go through it. const before = failing.translateCount(); const retry = await callEndpoint(failing.payload, "post", "/translate/run/:id", { + user: editor as never, routeParams: { id: String((docs[0] as { id: string | number }).id) }, }); expect(retry.status, "the retry endpoint did not accept the job").toBe(204); diff --git a/apps/dev/src/integration/translator/locale-workflow.int.test.ts b/apps/dev/src/integration/translator/locale-workflow.int.test.ts index 2133de200..bffd36e2f 100644 --- a/apps/dev/src/integration/translator/locale-workflow.int.test.ts +++ b/apps/dev/src/integration/translator/locale-workflow.int.test.ts @@ -35,7 +35,7 @@ const enqueue = async (id: string, targets: string[]) => { target_lng: targets, collection_slug: "docs", collection_id: [id], - strategy: "overwrite", + publish_on_translation: false, }, }); diff --git a/apps/dev/src/integration/translator/multi-target.int.test.ts b/apps/dev/src/integration/translator/multi-target.int.test.ts index 8645e49bb..f7373380e 100644 --- a/apps/dev/src/integration/translator/multi-target.int.test.ts +++ b/apps/dev/src/integration/translator/multi-target.int.test.ts @@ -41,7 +41,7 @@ const enqueue = (ctx: TestPayload, ids: string[], targetLng: string | string[], target_lng: targetLng, collection_slug: "docs", collection_id: ids, - strategy: "overwrite", + publish_on_translation: true, }, }); diff --git a/apps/dev/src/integration/translator/panel-shows-what-is-happening.int.test.ts b/apps/dev/src/integration/translator/panel-shows-what-is-happening.int.test.ts index 0feba5853..369d7ebaa 100644 --- a/apps/dev/src/integration/translator/panel-shows-what-is-happening.int.test.ts +++ b/apps/dev/src/integration/translator/panel-shows-what-is-happening.int.test.ts @@ -31,7 +31,7 @@ const enqueue = async (ids: string[], locales: string[]): Promise => { target_lng: locales, collection_slug: "docs", collection_id: ids, - strategy: "overwrite", + publish_on_translation: false, }, }); @@ -78,8 +78,7 @@ const seedRun = async ( collection_slug: "docs", collection_id: documentId, source_lng: "en", - target_lngs: [targetLng], - strategy: "overwrite", + targets: [{ lng: targetLng }], }, ...(state.failure === undefined ? {} : failureShape(state.failure)), ...(state.completedAt === undefined ? {} : { completedAt: state.completedAt }), diff --git a/apps/dev/src/integration/translator/per-field-receipt.int.test.ts b/apps/dev/src/integration/translator/per-field-receipt.int.test.ts index c0cc71e86..ac77a834c 100644 --- a/apps/dev/src/integration/translator/per-field-receipt.int.test.ts +++ b/apps/dev/src/integration/translator/per-field-receipt.int.test.ts @@ -18,14 +18,16 @@ const receipt = async (documentId: string) => { return docs[0] as unknown as { id: string | number; sourceFingerprint: string } | undefined; }; -const translate = (id: string, strategy: "overwrite" | "skip_existing" = "overwrite") => +const ruleFilters = (rules?: string[]) => rules?.map((name) => ({ kind: "rule", name })); + +const translate = (id: string, rules?: string[]) => callEndpoint(ctx.payload, "post", "/translate/enqueue", { body: { source_lng: "en", target_lng: "de", collection_slug: "docs", collection_id: [id], - strategy, + filters: ruleFilters(rules), publish_on_translation: true, }, }); @@ -81,7 +83,7 @@ describe("the stored receipt", () => { expect(Object.keys(parsed)).toHaveLength(LEAVES + 1); // the 500 labels plus `title` }); - it("reads a receipt written before per-field fingerprints, and upgrades it on the next run", async () => { + it("takes every leaf against a receipt written before per-field fingerprints, and upgrades it", async () => { const id = await create({ title: "Legacy", note: "Legacy note" }); await translate(id); @@ -107,22 +109,14 @@ describe("the stored receipt", () => { data: { _status: "published", title: "Legacy, edited", note: "Legacy note" }, }); - await translate(id, "skip_existing"); + await translate(id, ["stale"]); const de = (await ctx.payload.findByID({ collection: "docs", id, locale: "de" })) as Record< string, unknown >; - expect(de.title, "nothing proved this leaf moved, so today's behaviour holds").toBe( - "KORRIGIERT" - ); + expect(de.title).toBe("de:Legacy, edited"); - expect( - (await receipt(id))?.sourceFingerprint, - "nothing was translated, so nothing new is claimed" - ).toBe(legacyDocumentWideSha256); - - await translate(id, "overwrite"); const upgraded = (await receipt(id))?.sourceFingerprint ?? ""; expect(upgraded.startsWith("{")).toBe(true); expect(Object.keys(JSON.parse(upgraded) as Record).sort()).toEqual([ @@ -172,7 +166,7 @@ describe("the stored receipt", () => { data: { _status: "published", title: "VON HAND" }, }); - await translate(id, "skip_existing"); + await translate(id, ["stale"]); const stored = JSON.parse((await receipt(id))?.sourceFingerprint ?? "") as Record< string, @@ -202,7 +196,7 @@ describe("the stored receipt", () => { it("still lights the indicator when a leaf genuinely appears after the translation", async () => { const id = await create({ title: "Grows later" }); - await translate(id, "overwrite"); + await translate(id); await ctx.payload.update({ collection: "docs", diff --git a/apps/dev/src/integration/translator/prompt-context.int.test.ts b/apps/dev/src/integration/translator/prompt-context.int.test.ts index 9f3675ecd..d44e43a79 100644 --- a/apps/dev/src/integration/translator/prompt-context.int.test.ts +++ b/apps/dev/src/integration/translator/prompt-context.int.test.ts @@ -40,7 +40,7 @@ describe("what the provider is told about a document run", () => { target_lng: "de", collection_slug: "docs", collection_id: [id], - strategy: "overwrite", + publish_on_translation: true, }, }); diff --git a/apps/dev/src/integration/translator/provenance-access.int.test.ts b/apps/dev/src/integration/translator/provenance-access.int.test.ts index 275e8c8f6..1a47e25d9 100644 --- a/apps/dev/src/integration/translator/provenance-access.int.test.ts +++ b/apps/dev/src/integration/translator/provenance-access.int.test.ts @@ -63,7 +63,7 @@ describe("the translation receipts are closed to the outside", () => { ctx.payload.update({ collection: "translator-provenance" as "pages", id: rowId, - data: { dismissedFingerprint: "forged" } as never, + data: { sourceFingerprint: "forged" }, overrideAccess: false, user: SIGNED_IN, }) diff --git a/apps/dev/src/integration/translator/provenance.int.test.ts b/apps/dev/src/integration/translator/provenance.int.test.ts index f168d5f71..7fec0e028 100644 --- a/apps/dev/src/integration/translator/provenance.int.test.ts +++ b/apps/dev/src/integration/translator/provenance.int.test.ts @@ -18,7 +18,6 @@ type ProvRow = { sourceLocale: string; sourceFingerprint: string; translatedAt?: string; - dismissedFingerprint?: string | null; }; const provRows = async (ctx: TestPayload, docId: string): Promise => { @@ -38,7 +37,7 @@ const enqueue = (ctx: TestPayload, id: string, target: string) => target_lng: target, collection_slug: "docs", collection_id: [id], - strategy: "overwrite", + publish_on_translation: true, }, }); @@ -82,8 +81,6 @@ describe("provenance sidecar collection", () => { expect(row.sourceLocale).toBe("en"); expect(row.sourceFingerprint).toBeTruthy(); expect(row.translatedAt).toBeTruthy(); - // A fresh receipt carries no dismissal. - expect(row.dismissedFingerprint ?? null).toBeNull(); } }); diff --git a/apps/dev/src/integration/translator/queued-before-any-ending-on-jobs.int.test.ts b/apps/dev/src/integration/translator/queued-before-any-ending-on-jobs.int.test.ts index 221751173..45d6cd074 100644 --- a/apps/dev/src/integration/translator/queued-before-any-ending-on-jobs.int.test.ts +++ b/apps/dev/src/integration/translator/queued-before-any-ending-on-jobs.int.test.ts @@ -53,7 +53,7 @@ describe("a host hears onQueued before any ending, with the runner that queues o target_lng: ["de", "fr"], collection_slug: "docs", collection_id: [String(made.id)], - strategy: "overwrite", + publish_on_translation: false, }, }); diff --git a/apps/dev/src/integration/translator/queued-before-any-ending.int.test.ts b/apps/dev/src/integration/translator/queued-before-any-ending.int.test.ts index 8cbe21ca3..65ab27fc9 100644 --- a/apps/dev/src/integration/translator/queued-before-any-ending.int.test.ts +++ b/apps/dev/src/integration/translator/queued-before-any-ending.int.test.ts @@ -45,7 +45,7 @@ describe("a host hears onQueued before any ending, with the runner that translat target_lng: ["de", "fr"], collection_slug: "docs", collection_id: [String(made.id)], - strategy: "overwrite", + publish_on_translation: false, }, }); diff --git a/apps/dev/src/integration/translator/read-access-queue.int.test.ts b/apps/dev/src/integration/translator/read-access-queue.int.test.ts index ee5d67938..786595a85 100644 --- a/apps/dev/src/integration/translator/read-access-queue.int.test.ts +++ b/apps/dev/src/integration/translator/read-access-queue.int.test.ts @@ -85,7 +85,7 @@ describe("a queued translation reads as whoever asked for it", () => { target_lng: ["de"], collection_slug: "docs", collection_id: [id], - strategy: "overwrite", + publish_on_translation: false, }, }); diff --git a/apps/dev/src/integration/translator/retranslate-after-giving-up.int.test.ts b/apps/dev/src/integration/translator/retranslate-after-giving-up.int.test.ts index 65242493c..86bf0817d 100644 --- a/apps/dev/src/integration/translator/retranslate-after-giving-up.int.test.ts +++ b/apps/dev/src/integration/translator/retranslate-after-giving-up.int.test.ts @@ -20,7 +20,7 @@ const enqueue = async (id: string, locales: string[]): Promise => target_lng: locales, collection_slug: "docs", collection_id: [id], - strategy: "overwrite", + publish_on_translation: false, }, }); diff --git a/apps/dev/src/integration/translator/source-semantics.int.test.ts b/apps/dev/src/integration/translator/source-semantics.int.test.ts index 2709a4ee0..af0e8b791 100644 --- a/apps/dev/src/integration/translator/source-semantics.int.test.ts +++ b/apps/dev/src/integration/translator/source-semantics.int.test.ts @@ -70,7 +70,7 @@ const translate = async (id: string, sourceLng: Locale, targetLng: Locale) => { target_lng: targetLng, collection_slug: "docs", collection_id: [id], - strategy: "overwrite", + publish_on_translation: false, }, }); diff --git a/apps/dev/src/integration/translator/stale-detection.int.test.ts b/apps/dev/src/integration/translator/stale-detection.int.test.ts index d5f5ccd76..d439048c2 100644 --- a/apps/dev/src/integration/translator/stale-detection.int.test.ts +++ b/apps/dev/src/integration/translator/stale-detection.int.test.ts @@ -18,7 +18,7 @@ const enqueue = (ctx: TestPayload, id: string, opts: { publish: boolean; slug?: target_lng: "de", collection_slug: opts.slug ?? "docs", collection_id: [id], - strategy: "overwrite", + publish_on_translation: opts.publish, }, }); diff --git a/apps/dev/src/integration/translator/staleness-refresh.int.test.ts b/apps/dev/src/integration/translator/staleness-refresh.int.test.ts index 6a8438715..7b5ee2dfb 100644 --- a/apps/dev/src/integration/translator/staleness-refresh.int.test.ts +++ b/apps/dev/src/integration/translator/staleness-refresh.int.test.ts @@ -28,14 +28,16 @@ const editSource = (id: string, data: Record) => data: { _status: "published", ...data }, }); -const translate = (id: string, strategy: "overwrite" | "skip_existing") => +const ruleFilters = (rules?: string[]) => rules?.map((name) => ({ kind: "rule", name })); + +const translate = (id: string, rules?: string[]) => callEndpoint(ctx.payload, "post", "/translate/enqueue", { body: { source_lng: "en", target_lng: "de", collection_slug: "docs", collection_id: [id], - strategy, + filters: ruleFilters(rules), publish_on_translation: true, }, }); @@ -61,9 +63,9 @@ describe("a translation refreshes the leaf whose source moved, and only that one await ctx?.cleanup(); }); - it("refreshes a stale leaf that skip_existing used to skip (#118)", async () => { + it("refreshes the leaf whose source moved, and only that one (#118)", async () => { const id = await create({ title: "Original title", note: "Original note" }); - await translate(id, "overwrite"); + await translate(id); await ctx.payload.update({ collection: "docs", @@ -73,7 +75,7 @@ describe("a translation refreshes the leaf whose source moved, and only that one }); await editSource(id, { title: "Original title", note: "Note, rewritten" }); - await translate(id, "skip_existing"); + await translate(id, ["stale"]); const de = await german(id); expect(de.note, "its source moved, so it is refreshed — this is what #118 asks for").toBe( @@ -84,12 +86,12 @@ describe("a translation refreshes the leaf whose source moved, and only that one it("sends only the changed leaf to the provider", async () => { const id = await create({ title: "Only one moves", note: "Steady" }); - await translate(id, "overwrite"); + await translate(id); await editSource(id, { title: "Only one moves, edited", note: "Steady" }); const before = ctx.translateCount(); - await translate(id, "skip_existing"); + await translate(id, ["stale"]); expect(ctx.translateCount() - before, "one provider call for the one run").toBe(1); const de = await german(id); @@ -97,7 +99,7 @@ describe("a translation refreshes the leaf whose source moved, and only that one expect(de.note).toBe(tr("de", "Steady")); }); - it("leaves a filled-in target alone when NO receipt exists at all", async () => { + it("translates everything, hand-written target and all, when NO receipt exists at all", async () => { const id = await create({ title: "No receipt", note: "No receipt note" }); await ctx.payload.update({ collection: "docs", @@ -106,13 +108,11 @@ describe("a translation refreshes the leaf whose source moved, and only that one data: { _status: "published", title: "HAND-WRITTEN" }, }); - await translate(id, "skip_existing"); + await translate(id, ["stale"]); const de = await german(id); - expect(de.title, "no receipt says nothing moved, so skip_existing keeps its promise").toBe( - "HAND-WRITTEN" - ); - expect(de.note, "empty, so it is filled in as it always was").toBe(tr("de", "No receipt note")); + expect(de.title).toBe(tr("de", "No receipt")); + expect(de.note).toBe(tr("de", "No receipt note")); }); it("refreshes a hand-written target once its own source moves", async () => { @@ -124,14 +124,14 @@ describe("a translation refreshes the leaf whose source moved, and only that one data: { _status: "published", title: "VON HAND" }, }); - await translate(id, "skip_existing"); + await translate(id, ["stale"]); expect( (await german(id)).title, - "first run: no receipt yet, so the filled target is kept" - ).toBe("VON HAND"); + "first run: no receipt yet, so nothing is known to stand still" + ).toBe(tr("de", "Watermark")); await editSource(id, { title: "Watermark, edited", note: "Watermark note" }); - await translate(id, "skip_existing"); + await translate(id, ["stale"]); expect((await german(id)).title).toBe(tr("de", "Watermark, edited")); }); @@ -141,7 +141,7 @@ describe("a translation refreshes the leaf whose source moved, and only that one title: "Array head", items: [{ label: "First" }, { label: "Second" }], }); - await translate(id, "overwrite"); + await translate(id); const de = await german(id); const germanItems = de.items as { id: string; label: string }[]; @@ -162,7 +162,7 @@ describe("a translation refreshes the leaf whose source moved, and only that one items: [{ label: "Inserted" }, ...keepingRowIds(en.items)], }); - await translate(id, "skip_existing"); + await translate(id, ["stale"]); const after = (await german(id)) as { items: { label: string }[] }; const labels = after.items.map((item) => item.label); @@ -177,7 +177,7 @@ describe("a translation refreshes the leaf whose source moved, and only that one title: "Array staleness", items: [{ label: "Alpha" }, { label: "Beta" }], }); - await translate(id, "overwrite"); + await translate(id); const en = (await english(id)) as { items: { id: string; label: string }[] }; await editSource(id, { @@ -194,7 +194,7 @@ describe("a translation refreshes the leaf whose source moved, and only that one "still the old translation, or the case below passes for reasons unrelated to the receipt" ).toEqual([tr("de", "Alpha"), tr("de", "Beta")]); - await translate(id, "skip_existing"); + await translate(id, ["stale"]); const after = (await german(id)) as { items: { label: string }[] }; expect(after.items.map((item) => item.label)).toEqual([ @@ -205,7 +205,7 @@ describe("a translation refreshes the leaf whose source moved, and only that one it("translates a leaf the receipt has no entry for", async () => { const id = await create({ title: "Partial receipt", note: "" }); - await translate(id, "overwrite"); + await translate(id); await ctx.payload.update({ collection: "docs", @@ -215,7 +215,7 @@ describe("a translation refreshes the leaf whose source moved, and only that one }); await editSource(id, { title: "Partial receipt", note: "A note exists now" }); - await translate(id, "skip_existing"); + await translate(id, ["stale"]); const de = await german(id); expect(de.note, "the receipt never saw this leaf, so it is owed a translation").toBe( @@ -225,14 +225,14 @@ describe("a translation refreshes the leaf whose source moved, and only that one it("translates a leaf again after its source was emptied and later refilled", async () => { const id = await create({ title: "Refilled", note: "The first note" }); - await translate(id, "overwrite"); + await translate(id); expect((await german(id)).note).toBe(tr("de", "The first note")); await editSource(id, { title: "Refilled", note: "" }); - await translate(id, "overwrite"); + await translate(id); await editSource(id, { title: "Refilled", note: "The note is back" }); - await translate(id, "skip_existing"); + await translate(id, ["stale"]); expect((await german(id)).note).toBe(tr("de", "The note is back")); }); @@ -242,7 +242,7 @@ describe("a translation refreshes the leaf whose source moved, and only that one // silently. it("overwrites a hand-rewritten target once its own source moves — the trade this change makes", async () => { const id = await create({ title: "Owned by a human", note: "Steady note" }); - await translate(id, "overwrite"); + await translate(id); await ctx.payload.update({ collection: "docs", @@ -252,7 +252,7 @@ describe("a translation refreshes the leaf whose source moved, and only that one }); await editSource(id, { title: "Owned by a human, edited", note: "Steady note" }); - await translate(id, "skip_existing"); + await translate(id, ["stale"]); const de = await german(id); expect(de.title, "the receipt cannot tell a human's text from ours, so the refresh wins").toBe( diff --git a/apps/dev/src/integration/translator/status-reads-are-bounded.int.test.ts b/apps/dev/src/integration/translator/status-reads-are-bounded.int.test.ts index 6deb0c49a..98909a92f 100644 --- a/apps/dev/src/integration/translator/status-reads-are-bounded.int.test.ts +++ b/apps/dev/src/integration/translator/status-reads-are-bounded.int.test.ts @@ -54,7 +54,7 @@ const enqueue = async (ids: string[], locales: string[]): Promise => { target_lng: locales, collection_slug: "docs", collection_id: ids, - strategy: "overwrite", + publish_on_translation: false, }, }); @@ -77,8 +77,7 @@ const seedLegacyRow = async ( const input = { collection: { relationTo: collectionSlug, value: documentId }, source_lng: "en", - target_lngs: [targetLng], - strategy: "overwrite", + targets: [{ lng: targetLng }], }; const row = await ctx.payload.create({ collection: JOBS, @@ -103,8 +102,7 @@ const seedForeignRow = async (collectionSlug: string): Promise => { collection_slug: collectionSlug, collection_id: "999", source_lng: "en", - target_lngs: ["de"], - strategy: "overwrite", + targets: [{ lng: "de" }], }, }, } as never); diff --git a/apps/dev/src/integration/translator/strategies.int.test.ts b/apps/dev/src/integration/translator/strategies.int.test.ts deleted file mode 100644 index 3d4215e26..000000000 --- a/apps/dev/src/integration/translator/strategies.int.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; - -import { bootTestPayload } from "./bootTestPayload"; -import type { TestPayload } from "./bootTestPayload"; -import { callEndpoint } from "./callEndpoint"; - -// R4 — translation strategies, with DISTINCT assertions per branch: -// overwrite → replaces an existing target value with the new translation. -// skip_existing → keeps an already-filled target value, but still fills an EMPTY sibling. - -const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s); - -const enqueue = (ctx: TestPayload, id: string, strategy: "overwrite" | "skip_existing") => - callEndpoint(ctx.payload, "post", "/translate/enqueue", { - body: { - source_lng: "en", - target_lng: "de", - collection_slug: "docs", - collection_id: [id], - strategy, - publish_on_translation: true, - }, - }); - -describe("translation strategies", () => { - let ctx: TestPayload; - - beforeAll(async () => { - ctx = await bootTestPayload(); - }); - afterAll(async () => { - await ctx?.cleanup(); - }); - - it("overwrite replaces an existing target value", async () => { - const created = await ctx.payload.create({ - collection: "docs", - locale: "en", - data: { _status: "published", title: "En title" }, - }); - const id = String(created.id); - await ctx.payload.update({ - collection: "docs", - id, - locale: "de", - data: { _status: "published", title: "Manual DE" }, - }); - - await enqueue(ctx, id, "overwrite"); - - const de = await ctx.payload.findByID({ collection: "docs", id, locale: "de" }); - expect(de.title).toBe(tr("de", "En title")); // replaced - expect(de.title).not.toBe("Manual DE"); - }); - - it("skip_existing keeps a filled target but fills an empty sibling", async () => { - const created = await ctx.payload.create({ - collection: "docs", - locale: "en", - data: { _status: "published", title: "En title", meta: { subtitle: "En sub" } }, - }); - const id = String(created.id); - // de.title already filled; de.meta.subtitle deliberately left empty. - await ctx.payload.update({ - collection: "docs", - id, - locale: "de", - data: { _status: "published", title: "Keep DE" }, - }); - - await enqueue(ctx, id, "skip_existing"); - - const de = await ctx.payload.findByID({ collection: "docs", id, locale: "de" }); - expect(de.title).toBe("Keep DE"); // existing kept - expect((de.meta as { subtitle?: string }).subtitle).toBe(tr("de", "En sub")); // empty filled - }); -}); diff --git a/apps/dev/src/integration/translator/strategy-publish-matrix.int.test.ts b/apps/dev/src/integration/translator/strategy-publish-matrix.int.test.ts deleted file mode 100644 index 9b87e4703..000000000 --- a/apps/dev/src/integration/translator/strategy-publish-matrix.int.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import type { CollectionConfig, Payload } from "payload"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; - -import { bootTestPayload } from "./bootTestPayload"; -import { callEndpoint } from "./callEndpoint"; - -type Slug = "docs" | "versioned"; -type Locale = "en" | "de" | "fr"; - -const SOURCE = "Hello world"; -const MACHINE = `de:${SOURCE}`; -const REVIEWED = "REVIEWED BY A HUMAN"; - -let payload: Payload; -let cleanup: () => Promise; - -beforeAll(async () => { - const collections: CollectionConfig[] = [ - { slug: "users", auth: true, fields: [] }, - { - slug: "docs", - versions: { drafts: true }, - fields: [{ name: "title", type: "text", localized: true }], - }, - { - slug: "versioned", - versions: true, - fields: [{ name: "title", type: "text", localized: true }], - }, - ]; - ({ payload, cleanup } = await bootTestPayload({ collections })); -}); - -afterAll(async () => { - await cleanup(); -}); - -const findDoc = (collection: Slug, id: string, locale: Locale, draft: boolean) => - payload.findByID({ - collection: collection as "docs", - id, - locale: locale as "en", - draft, - fallbackLocale: false, - }) as Promise>; - -const updateDoc = ( - collection: Slug, - id: string, - locale: Locale, - data: Record, - opts: { draft?: boolean } = {} -) => - payload.update({ collection: collection as "docs", id, locale: locale as "en", ...opts, data }); - -const createDoc = async (collection: Slug, data: Record): Promise => { - const doc = await payload.create({ collection: collection as "docs", locale: "en", data }); - return String(doc.id); -}; - -const read = (id: string, draft: boolean) => findDoc("docs", id, "de", draft); - -async function seedReviewedTranslation(layer: "published" | "draft"): Promise { - const id = await createDoc("docs", { title: SOURCE, _status: "published" }); - await updateDoc("docs", id, "de", { title: REVIEWED }, layer === "draft" ? { draft: true } : {}); - return id; -} - -const translate = async ( - id: string, - strategy: "overwrite" | "skip_existing", - publish: boolean, - collection: Slug = "docs" -): Promise => { - const res = await callEndpoint(payload, "post", "/translate/enqueue", { - body: { - source_lng: "en", - target_lng: "de", - collection_slug: collection, - collection_id: [id], - strategy, - publish_on_translation: publish, - }, - }); - expect(res.status).toBe(200); -}; - -describe("strategy x publish-on-translation", () => { - describe("the existing translation is published", () => { - it("overwrite without publishing: replaces it in the draft, leaves the live value alone", async () => { - const id = await seedReviewedTranslation("published"); - await translate(id, "overwrite", false); - - expect((await read(id, false)).title).toBe(REVIEWED); - expect((await read(id, true)).title).toBe(MACHINE); - }); - - it("overwrite with publishing: replaces it in both layers", async () => { - const id = await seedReviewedTranslation("published"); - await translate(id, "overwrite", true); - - expect((await read(id, false)).title).toBe(MACHINE); - expect((await read(id, true)).title).toBe(MACHINE); - }); - - it("skip_existing without publishing: leaves it alone", async () => { - const id = await seedReviewedTranslation("published"); - await translate(id, "skip_existing", false); - - expect((await read(id, false)).title).toBe(REVIEWED); - expect((await read(id, true)).title).toBe(REVIEWED); - }); - - it("skip_existing with publishing: leaves it alone", async () => { - const id = await seedReviewedTranslation("published"); - await translate(id, "skip_existing", true); - - expect((await read(id, false)).title).toBe(REVIEWED); - expect((await read(id, true)).title).toBe(REVIEWED); - }); - }); - - describe("the existing translation is a draft awaiting review", () => { - it("overwrite without publishing: replaces it, and nothing goes live", async () => { - const id = await seedReviewedTranslation("draft"); - await translate(id, "overwrite", false); - - expect((await read(id, false)).title).toBeUndefined(); - expect((await read(id, true)).title).toBe(MACHINE); - }); - - it("overwrite with publishing: replaces it and publishes the replacement", async () => { - const id = await seedReviewedTranslation("draft"); - await translate(id, "overwrite", true); - - expect((await read(id, false)).title).toBe(MACHINE); - expect((await read(id, true)).title).toBe(MACHINE); - }); - - it("skip_existing without publishing: keeps the reviewer's text, publishes nothing", async () => { - const id = await seedReviewedTranslation("draft"); - await translate(id, "skip_existing", false); - - expect((await read(id, false)).title).toBeUndefined(); - expect((await read(id, true)).title).toBe(REVIEWED); - }); - - it("skip_existing with publishing: publishes the reviewer's text rather than re-translating it", async () => { - const id = await seedReviewedTranslation("draft"); - await translate(id, "skip_existing", true); - - expect((await read(id, false)).title).toBe(REVIEWED); - expect((await read(id, true)).title).toBe(REVIEWED); - }); - }); - - describe("an emptied source", () => { - it("still publishes the current draft, because that is what the flag asks for", async () => { - const id = await createDoc("docs", { title: SOURCE, _status: "published" }); - // Clear the English source before the German draft exists: a plain `payload.update` on a - // drafts collection is a publishing write, so the reverse order takes the draft live in - // setup and the case passes vacuously. - await updateDoc("docs", id, "de", { title: "PUBLISHED DE" }); - await updateDoc("docs", id, "en", { title: "" }); - await updateDoc("docs", id, "de", { title: "UNREVIEWED DRAFT DE" }, { draft: true }); - - await translate(id, "overwrite", true); - - expect((await read(id, false)).title).toBe("UNREVIEWED DRAFT DE"); - }); - }); - - describe("there is nothing to translate", () => { - it("publishes anyway when everything is already translated", async () => { - const id = await createDoc("docs", { title: SOURCE, _status: "published" }); - await updateDoc("docs", id, "de", { title: REVIEWED }); - await updateDoc("docs", id, "en", { _status: "draft" }); - expect((await findDoc("docs", id, "en", false))._status).toBe("draft"); - - await translate(id, "skip_existing", true); - - expect((await findDoc("docs", id, "en", false))._status).toBe("published"); - expect((await read(id, false)).title).toBe(REVIEWED); - }); - - it("publishes nothing when it is not asked to publish", async () => { - const id = await createDoc("docs", { title: SOURCE, _status: "draft" }); - await updateDoc("docs", id, "de", { title: REVIEWED }, { draft: true }); - - await translate(id, "skip_existing", false); - - expect((await findDoc("docs", id, "en", false))._status).toBe("draft"); - expect((await read(id, true)).title).toBe(REVIEWED); - }); - - it("publishes anyway when the source has no translatable content", async () => { - const id = await createDoc("docs", { title: "", _status: "draft" }); - - await translate(id, "overwrite", true); - - expect((await findDoc("docs", id, "en", false))._status).toBe("published"); - }); - }); - - describe("a collection with versions but no drafts", () => { - it("skip_existing writes nothing when the target is already translated", async () => { - const id = await createDoc("versioned", { title: SOURCE }); - await updateDoc("versioned", id, "de", { title: REVIEWED }); - const before = await findDoc("versioned", id, "de", false); - - await translate(id, "skip_existing", false, "versioned"); - - const after = await findDoc("versioned", id, "de", false); - - expect(after.title).toBe(REVIEWED); - expect(after.updatedAt, "no write happened").toBe(before.updatedAt); - }); - - it("overwrite replaces the translation in the only row there is", async () => { - const id = await createDoc("versioned", { title: SOURCE }); - await updateDoc("versioned", id, "de", { title: REVIEWED }); - - await translate(id, "overwrite", false, "versioned"); - - expect((await findDoc("versioned", id, "de", false)).title).toBe(MACHINE); - }); - - // `publishSpecificLocale` on a versions-without-drafts collection drops every other locale from - // the live row — which is what the `fr` assertion is guarding. - it("the publish flag changes nothing, and leaves other locales alone", async () => { - const id = await createDoc("versioned", { title: SOURCE }); - await updateDoc("versioned", id, "fr", { title: "FR UNTOUCHED" }); - - await translate(id, "overwrite", true, "versioned"); - - expect((await findDoc("versioned", id, "de", false)).title).toBe(MACHINE); - expect((await findDoc("versioned", id, "fr", false)).title).toBe("FR UNTOUCHED"); - }); - }); -}); diff --git a/apps/dev/src/integration/translator/task-runner-contract.int.test.ts b/apps/dev/src/integration/translator/task-runner-contract.int.test.ts index 296d17a0f..291f0c5b1 100644 --- a/apps/dev/src/integration/translator/task-runner-contract.int.test.ts +++ b/apps/dev/src/integration/translator/task-runner-contract.int.test.ts @@ -1,7 +1,6 @@ import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { assertTaskRunnerContract } from "@focus-reactive/payload-plugin-translator/testing"; import { afterAll, beforeAll, vi } from "vitest"; - -import { assertTaskRunnerContract } from "../../../../../packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.invariants"; import { bootTestPayload } from "./bootTestPayload"; import type { TestPayload } from "./bootTestPayload"; diff --git a/apps/dev/src/lib/translator/lifecycleLogging.ts b/apps/dev/src/lib/translator/lifecycleLogging.ts index c18144331..69c50d00b 100644 --- a/apps/dev/src/lib/translator/lifecycleLogging.ts +++ b/apps/dev/src/lib/translator/lifecycleLogging.ts @@ -6,7 +6,7 @@ import type { TranslationLifecycleCallbacks } from "@focus-reactive/payload-plug export const loggingLifecycle: TranslationLifecycleCallbacks = { onQueued: (task) => console.info( - `[lifecycle] queued ${task.collection}#${task.id} ${task.sourceLng}→${task.targetLng} (${task.strategy})` + `[lifecycle] queued ${task.collection}#${task.id} ${task.sourceLng}→${task.targetLng}` ), onCompleted: (task) => console.info( diff --git a/apps/dev/src/payload-types.ts b/apps/dev/src/payload-types.ts index 9a0d5a19f..142b243f7 100644 --- a/apps/dev/src/payload-types.ts +++ b/apps/dev/src/payload-types.ts @@ -716,7 +716,7 @@ export interface Comment { id?: string | null; }[] | null; - author: number | User; + author?: (number | null) | User; isResolved?: boolean | null; resolvedBy?: (number | null) | User; resolvedAt?: string | null; @@ -747,7 +747,6 @@ export interface TranslatorProvenance { sourceLocale: string; sourceFingerprint: string; translatedAt: string; - dismissedFingerprint?: string | null; updatedAt: string; createdAt: string; } @@ -1298,7 +1297,6 @@ export interface TranslatorProvenanceSelect { sourceLocale?: T; sourceFingerprint?: T; translatedAt?: T; - dismissedFingerprint?: T; updatedAt?: T; createdAt?: T; } @@ -1461,7 +1459,15 @@ export interface TaskTranslateDocument { } | null); source_lng: string; target_lng: string; - strategy: string; + filters?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; publish_on_translation?: boolean | null; }; output?: unknown; @@ -1516,9 +1522,8 @@ export interface WorkflowTranslateDocumentLocales { value: number | Playground; } | null); source_lng: string; - strategy: string; publish_on_translation?: boolean | null; - target_lngs: + targets: | { [k: string]: unknown; } diff --git a/packages/payload-plugin-translator/README.md b/packages/payload-plugin-translator/README.md index 80d37e106..71ac31ba5 100644 --- a/packages/payload-plugin-translator/README.md +++ b/packages/payload-plugin-translator/README.md @@ -96,12 +96,12 @@ Open a `posts` document. A Translate control now sits beside the document contro ## What you can do -- **Translate one document** — the control on the edit view queues a job for the target locale you pick, choosing a strategy and whether to publish the locale once it is filled in. +- **Translate one document** — the control on the edit view queues a job for the target locale you pick, and whether to publish the locale once it is filled in. - **Translate a whole collection** — a dashboard above the list view runs the selected documents, or all of them, and reports progress per locale. - **Translate into several locales at once** — `targetSelection: 'multi'` turns the target field on both the document and the collection surfaces into a multi-select, and every selected document is translated into every locale you picked. The default is one locale per run. - **Translate a single field** — a control on the field itself, answering in place without queueing anything. Needs both `fieldTranslation()` in `modules` and `withFieldTranslation` on the field. - **Auto-translate on save** — wrap a collection in `withAutoTranslate(collection, { targets })` and editing the source locale queues its own translations, with no one pressing anything. On a collection with drafts enabled it waits until the document is published; without drafts it fires on every save. Rapid edits are coalesced into one run. -- **Keep a field out of translation** — `withFieldTranslation(field, { exclude: true })` is the one way to say a field is never sent to the provider. `skip_existing` is about emptiness and drift, not authorship: it fills an empty target, and — with `provenance` on, for a locale the plugin has translated at least once — refreshes a target whose source has moved since that run, whoever wrote the text that is there. +- **Keep a field out of translation** — `withFieldTranslation(field, { exclude: true })` is the one way to say a field is never sent to the provider. Naming fields in a run's filters is about *this* run, not about authorship: a leaf nobody named is simply not sent this time, and nothing records that a person wrote the text that is there. - **Steer how it translates** — `systemPrompt` replaces the instruction sent with every request, so tone, register, a glossary, or brand names that must stay untranslated are yours to set. - **Bring any translation service** — an OpenAI-compatible adapter ships in the box; anything else is a single function you write, which receives the text and returns the reply. You do not parse it or check it back — that is the plugin's job. - **Run it in the background or inline** — Payload Jobs by default, so an editor is not left waiting and a run can be cancelled or retried. `createSyncRunner()` translates inline instead, for tests and scripts. @@ -230,7 +230,7 @@ The control needs a saved document, so it stays hidden while you are creating on > [!WARNING] > **A localized `blocks` or `array` container has no per-field translation.** Wrap the leaves, not the container. When the container itself is localized, each locale holds its own order and content, so there is no single field to translate across them: the control answers with a notice telling you to translate the whole document instead, which handles this case. -`{ exclude: true }` means *never send this field to the provider* — not *leave it untouched*. An excluded field keeps its target value when it has one, is filled from the source locale when the target is empty (so a required field does not fail validation on save), and is never sent for translation. Exclusion wins over the `overwrite` strategy. +`{ exclude: true }` means *never send this field to the provider* — not *leave it untouched*. An excluded field keeps its target value when it has one, is filled from the source locale when the target is empty (so a required field does not fail validation on save), and is never sent for translation. Exclusion wins over a filter: naming an excluded field translates nothing. ### Auto-translate on source change @@ -251,7 +251,6 @@ translatorPlugin({ | Key | Type | Default | Description | | --- | --- | --- | --- | | `targets` | `string[]` | — | **Required.** Locales to translate into. The source locale is always excluded. | -| `strategy` | `'overwrite' \| 'skip_existing'` | `'overwrite'` | Replace every target field, or fill the empty ones — and, with `provenance` on, refresh those whose source moved. | | `debounceMs` | `number` | `0` | Delay before the queued job runs, coalescing rapid edits. | | `sourceLocale` | `string` | `localization.defaultLocale` | Source locale override for this collection. | @@ -276,7 +275,7 @@ The source is the source locale's **own current content** — the newer draft wh Two consequences worth knowing before relying on it: - **Publishing publishes the current draft, whatever is in it.** That includes pending edits nobody made for the translation's sake, and non-localized fields, which Payload stores once per document and cannot scope to a locale. Worth remembering before running translate-and-publish over a long list: every unpublished draft among them goes live. -- **`skip_existing` refreshes on drift, and does not ask who wrote the target.** With `provenance` on and a receipt already written for that locale, it leaves a non-empty target alone while its own source text is unchanged, and re-translates it once that source moves — including text a person typed or corrected. A field the record does not name — one added later, or emptied and since refilled — counts as moved, because a run records the source as it stood and an empty field is not in it. Without `provenance`, or before the plugin has translated the locale once, it has nothing to compare against and fills only empty targets. It has no notion of *reviewed*; `withFieldTranslation(field, { exclude: true })` is what keeps a field out of a run altogether. +- **The `stale` criterion refreshes on drift, and does not ask who wrote the target.** With `provenance` on and a receipt already written for that locale, a run naming the `stale` rule keeps the leaves whose source text has moved since that run and drops the rest — so a target whose source stands still is left alone, and one whose source moved is re-translated, including text a person typed or corrected. A leaf the record does not name — added later, or emptied and since refilled — counts as moved, because a run records the source as it stood and an empty field is not in it. With no receipt for that locale, or none written leaf by leaf, nothing is known to stand still and every leaf is taken. Nothing has a notion of *reviewed*; `withFieldTranslation(field, { exclude: true })` is what keeps a field out of a run altogether. ## Translation providers @@ -392,6 +391,9 @@ const fake = createTranslationProvider({ > [!IMPORTANT] > Cron autorun does not fire on serverless hosts such as Vercel, so jobs queue and wait. Pass `createPayloadJobsRunner({ autoRun: false })` and drive the queue from your own cron or worker via `POST {basePath}/run/:id`. +> [!IMPORTANT] +> Set `jobs: { enableConcurrencyControl: true }` in your **Payload** config. Payload's update reads the whole stored document, merges one locale's values into it and writes every locale back, so two runs over one document lose each other's work — even when they write different locales. The plugin keeps a document's locales in one run wherever it can, but a request asking for fields the live run was not asked for gets its own run, and that is when the two can overlap. + `createSyncRunner` keeps the status of what it ran in memory — the last 100 tasks, for an hour, adjustable with `maxSize` and `ttlMs`. A restart forgets them. Nothing is lost but the reporting: the translations themselves are already written. ### Seeing finished jobs @@ -432,7 +434,7 @@ translatorPlugin({ }) ``` -Each `task` carries `{ collection, id, sourceLng, targetLng, strategy, handle }`. `handle` names the run translating that locale — the value the enqueue answer gave, and the one `DELETE /translate/cancel` takes back. _Since v0.16.0._ +Each `task` carries `{ collection, id, sourceLng, targetLng, handle }`. `handle` names the run translating that locale — the value the enqueue answer gave, and the one `DELETE /translate/cancel` takes back. _Since v0.16.0._ Each callback fires at most once per target locale, never per attempt: `onQueued` when the runner takes the work, then one of `onCompleted`, `onFailed` or `onCancelled`. **`onFailed` means the locale will not be translated** — retries are the runner's own business and are not reported, so there is no need to disable them. _Since v0.16.0; `onFailed` previously fired on every failed attempt._ @@ -462,7 +464,11 @@ Three endpoints are meant for you. They mount under your Payload API route plus | `POST` | `/run/:id` | Run a queued job now. Required wherever cron autorun does not fire, such as Vercel. | | `POST` | `/field` | Translate one field and get the result back. Mounted only when `modules` includes `fieldTranslation()`. | -`POST /enqueue` takes `source_lng`, `target_lng` (one locale or a list), `collection_slug` and `collection_id[]`, plus optional `select_all`, `strategy` (`'overwrite' | 'skip_existing'`, default `'overwrite'`) and `publish_on_translation` (default `false`). Field names are snake_case. +`POST /enqueue` takes `source_lng`, `target_lng` (one locale or a list), `collection_slug` and `collection_id[]`, plus optional `select_all`, `publish_on_translation` (default `false`) and `filters`. Field names are snake_case. + +`filters` says which leaves the run translates. A filter is either the addresses themselves — `{ kind: 'leaves', addresses: ['title'] }` — or the name of a rule that produces them — `{ kind: 'rule', name: 'stale' }`. The run translates the intersection of every filter's answer, so an empty list means every translatable leaf and two filters mean the leaves both accept. + +`filters` reach the stored job row as they were written, and are resolved against the document as it stands when the run happens. An address no longer in the document matches nothing, and an address naming an element by position (`content.#0.heading` — the fallback for an element carrying no `id`) resolves to whatever is in that position then. A `rule` is answered in the worker; with `provenance` on, `stale` keeps only the leaves whose source has moved since this locale was translated. If nothing answers to the rule's name, the run fails rather than translate the whole document. > [!NOTE] > The plugin mounts further routes under the same `basePath` to serve its own admin panels — status, cancel and staleness. They are not part of the published contract and may change in any release; drive the plugin through the three above. @@ -488,7 +494,7 @@ Three endpoints are meant for you. They mount under your Payload API route plus | `OpenAIClientShape`, `OpenAISamplingParams`, `OpenAIStructuredOutput` | types | OpenAI adapter. | | `TaskRunnerProvider`, `PayloadJobsRunnerOptions`, `TaskFilter` | types | Task runners. | | `TargetSelectionMode` | type | Single or multi target-language selection. | -| `AutoTranslateConfig`, `AutoTranslateStrategy`, `FieldTranslationConfig` | types | Collection and field configuration. | +| `AutoTranslateConfig`, `FieldTranslationConfig` | types | Collection and field configuration. | | `TranslationTask`, `TranslationLifecycleCallbacks` | types | Lifecycle callbacks. | | `TranslationProvenanceRecord` | type | One stored provenance record. | diff --git a/packages/payload-plugin-translator/docs/DEPRECATIONS.md b/packages/payload-plugin-translator/docs/DEPRECATIONS.md index 265a23e02..f1abf28de 100644 --- a/packages/payload-plugin-translator/docs/DEPRECATIONS.md +++ b/packages/payload-plugin-translator/docs/DEPRECATIONS.md @@ -440,3 +440,261 @@ keeps flagging until the source has text again. - **Code refs:** - `src/server/modules/task-runner/TaskRunnerProvider.interface.ts` - `src/server/modules/task-runner/payload-jobs-runner/model/attemptsBefore.ts` + +### the-strategy-leaves-the-run + +- **What:** the translation strategy, everywhere it appeared — the `strategy` field of `POST /enqueue`, + the stored run row, the host-facing lifecycle task, the status response, the `TaskHandlerInput` a + third-party runner implements, `AutoTranslateConfig.strategy`, the exported `AutoTranslateStrategy` + type, the `TranslationStrategy` port and both its implementations, and the "Update mode" select in + both admin forms. +- **Status:** removed +- **Changed:** 2026-10-23 / #208 +- **Replacement:** a run carries, per target locale, a list of **filters**. A filter is either the + leaf addresses themselves or the name of a rule that produces them, and the run translates the + intersection of every filter's answer. + + | Was | Becomes | + | --- | --- | + | `strategy: "overwrite"` (the default) | `filters: []` — a run with no filters translates every translatable leaf | + | `strategy: "skip_existing"` | `filters: [{ kind: "rule", name: "stale" }]`, which keeps the leaves whose source has moved | + | a selection of fields | `filters: [{ kind: "leaves", addresses: ["title", "layout.b1:text.content"] }]` | + +- **Why:** the strategy answered "what do we translate" with a rule, because nobody had asked. Two of + its clauses guessed at state the caller never stated — whether the target was empty, and whether the + source had moved — and `skip_existing` meant something different again when `provenance` was off. + A run now carries the answer rather than a rule for inferring it. +- **Who this breaks:** every host. A plugin config setting `AutoTranslateConfig.strategy`, a caller + posting `strategy` to `/enqueue`, a lifecycle callback reading `task.strategy`, and a third-party + `TaskRunnerProvider` typed against `TaskHandlerInput` all change. +- **Neither removal is silent.** `POST /enqueue` refuses a body still carrying `strategy` with a 400 + naming the replacement, and the plugin warns at boot for each collection whose auto-translate + config still carries one. A removed option that had been protecting something must not become a + no-op that quietly overwrites it. +- **Auto-translate loses the choice, for now.** It names no filters, so an automatic run covers the + whole document. A host that set `"skip_existing"` was getting only the drifted fields and now gets + all of them, at the provider's price and over reviewed text. External control over what an + automatic run covers is the next piece of work on that module. +- **Code refs:** + - `src/server/features/enqueue-translation/model.ts` (`filters` and the refusals) + - `src/core/domain/selection/TranslationFilter.ts` + - `src/server/modules/translation-run/Criterion.interface.ts` + - `src/core/domain/selection/narrowSelection.ts` + - `src/server/modules/provenance/Provenance.criterion.ts` + +### the-strategy-leaves-the-stored-run + +- **What:** reading `target_lngs` and `strategy` off a run row written before 1.0. +- **Status:** read-only shim, written nowhere +- **Changed:** 2026-10-23 / #208 +- **Replacement:** `targets: [{ lng, filters }]`. A legacy row is read as one target per listed + locale, and a row that named `skip_existing` is read as carrying one `rule` filter named `stale`. +- **That is the nearest meaning, not the same one.** `skip_existing` promised two things and `stale` + keeps one. A queued row therefore behaves differently in three cases, all of them confined to the + rows already in flight when this ships: + + | The queued row | What it did | What it does now | + | --- | --- | --- | + | with `provenance` on and a per-leaf receipt | filled empty targets, refreshed what drifted | refreshes what drifted; an empty target whose source stands still is left empty | + | with `provenance` on and no per-leaf receipt for that locale | left a filled target alone | translates every leaf, reviewed text included | + | with `provenance` **off** | filled empty targets only | **fails**: nothing answers `stale`, so the run throws and spends its retries | + + The last one is deliberate. Translating the whole document instead would overwrite text the row + was queued to protect, and a run that fails loudly can be queued again. +- **Removal condition, not a version:** drop the legacy branch of `store/runTargets.ts` once no row + written before 1.0 can still be in flight. That is the queue's drain time — hours — not a release + cycle, so it does not need to wait for the next major. + +## 2026-10-24 — hiding the out-of-date mark writes the receipt + +### the-dismissed-fingerprint-leaves-the-receipt + +- **What:** the `dismissedFingerprint` column of the provenance sidecar, the field of the same name + on `ProvenanceReceipt`, and `ProvenanceStore.dismiss`. +- **Status:** removed +- **Changed:** 2026-10-24 +- **Replacement:** none — hiding the mark now writes `sourceFingerprint` through + `ProvenanceStore.rebaseSourceFingerprint`, so a receipt names one source: the one this translation + is considered current against. +- **Why:** a receipt held two fingerprints and three readers asked whether a locale was out of date. + Only the admin panel consulted both, so hiding the mark quieted the lamp and nothing else: the + staleness response still named the field as moved, and a run narrowing by `stale` still + re-translated it over the text the editor had chosen to keep. +- **Every mark an editor hid before this ships comes back once.** Those rows carry a non-null + `dismissedFingerprint` and nothing reads it any more, so each of those locales reads as out of date + again and a run narrowing by `stale` selects its fields again. A host that wants to keep them + copies `dismissedFingerprint` into `sourceFingerprint` wherever the first is not null, once, before + upgrading; a host that does not care lets the editors press the button again. +- **An existing database keeps the column.** Nothing reads or writes it; the plugin no longer + declares it. A consumer on Postgres or SQLite with migrations drops it in a migration of their own, + at whatever moment suits them. +- **On schema push the drop is an interactive prompt.** It appears whenever the table holds **any** + row, even though every value in the column may be null, and it blocks when standard input is not a + terminal — a process manager or CI will appear to hang rather than fail. Declining is safe: the + column is nullable and the plugin ignores it. +- **Who this breaks:** nobody outside the package in TypeScript terms — `ProvenanceStore`, + `ProvenanceReceipt` and `ProvenanceKey` are internal, and `TranslationProvenanceRecord`, the only + provenance type the package exports, never carried a fingerprint. +- **What a person will notice.** Hiding the mark used to quiet the panel alone. It now also keeps + those fields out of the next run that narrows by `stale`. **A field no run ever translated is + re-based too** — the button speaks for the whole document and locale — so such a field is treated + as current until its source moves again. +- **Code refs:** + - `src/core/domain/provenance/ProvenanceStore.interface.ts` + - `src/core/domain/provenance/staleness.ts` (`isRecordStale`) + - `src/server/modules/provenance/Provenance.service.ts` (`dismiss`) + - `src/server/modules/provenance/Provenance.collection.ts` + +## 2026-10-25 — the receipt holds one shape + +### the-document-wide-digest-stops-being-read + +- **What:** the `document` member of `SourceFingerprint` — the single sha256 the plugin wrote into + the sidecar's `sourceFingerprint` column until per-leaf fingerprints shipped on 2026-10-05. The + union is gone with it; a receipt now holds a map of leaf address to hash, or nothing. +- **Status:** removed +- **Changed:** 2026-10-25 +- **Replacement:** none. A stored value that is not a per-leaf map now reads as *no receipt* — + exactly what an empty column has always read as. +- **Why:** nothing had written that shape for twenty days, and keeping its reader cost a second hash + of the whole source document on every staleness read, since the whole-document hash existed only + to compare against it. +- **What a host sees.** A target locale whose receipt was written before 0.15.0, and whose source + has not moved since, flips from up to date to **out of date**. It clears when that locale is + translated **in full**, or when an editor hides the mark. A narrowed run does not clear it: the row + carries no earlier claims to keep, so every leaf the run did not send is recorded as unanswered and + keeps reading as moved. A + locale whose source *had* moved already read as out of date and is unaffected, as is every locale + translated by 0.15.0 or later. The direction is a false alarm, never a false all-clear: nothing + reads as current that is not. +- **Nothing to migrate.** The column keeps whatever it holds; the plugin overwrites it on the next + write to that row. A host that wants the marks gone without re-translating can press the hide + button on each affected locale. +- **This narrows the workaround in #the-dismissed-fingerprint-leaves-the-receipt.** That entry tells + a host to copy `dismissedFingerprint` into `sourceFingerprint` wherever the first is not null, to + keep marks their editors had hidden. Copy only values that are JSON maps: a mark hidden before + 0.15.0 stored a bare sha256 there, and copying one now writes a value that reads as no receipt, so + that mark comes back anyway. Those locales need the hide button pressed again. + +## 2026-10-25 — one permission decision point + +**Status:** live · **Removal target:** n/a — nothing is named for removal, but one name is newly +**required**: `TaskRunner.assignmentsOf`, below. + +The plugin stopped deciding who has access. It now asks twice, and never answers itself: the host's +`AccessGuard` is asked whether this **kind** of request is allowed at all, and Payload's own +`access.update` rule on the collection is asked about **each document**. Nine routes, the queue, the +admin widgets and the translation chain are all behind one or both of those asks. + +What follows is every place a host can notice it. + +### The guard is asked about an action, and only `true` allows + +`AccessGuard.check` receives `{ req, action }` where it used to receive `{ req }`. `action` is a +`TranslatorAction` — one of `enqueue`, `run`, `cancel`, `cancel-by-collection`, `document-status`, +`collection-status`, `document-staleness`, `dismiss-staleness`, `translate-field`. The name is the +action, not the route, so moving an endpoint does not change it. + +- **A guard returning a truthy non-boolean now refuses where it allowed.** The old code tested + `if (!hasAccess)`, so a guard answering with a user object, a non-empty string or `1` let the + request through. Only `true` allows now. +- **A guard that throws now refuses.** The throw used to escape into the error envelope, which + answered `500` — a failure a host could mistake for a bug in the plugin. It is now a `403`, and + the error is logged. One exception, so that a guard may still redirect from an admin widget: an + error carrying a `digest` property — what `redirect()` and `notFound()` throw — is re-thrown + untouched. +- **The guard is asked more than once for one thing a person does.** Each admin widget asks at + render to decide whether to offer the action; the endpoint asks again when the button is pressed. + A guard that counts, bills or rate-limits on being asked was always wrong and is now visibly so. +- **A deep import stops resolving.** `@focus-reactive/payload-plugin-translator/client/shared/types/AccessGuard` + was undocumented but reachable through the `./client/*` export. It is gone; `AccessGuard`, + `AccessGuardRequest` and `TranslatorAction` come from the package root. +- **Implementing `AccessGuard` does not break.** A `check` that destructures only `req` still + satisfies the interface, and so does one whose parameter type names only `req`. + +### Enqueue drops the documents the caller may not change + +`POST {basePath}/enqueue` asks the collection's `access.update` rule about every id in +`collection_id`, as the caller, and queues only the ones it allows. Ids it refuses are reported back +under `refused`, which is present **only when it is non-empty** — an answer with no `refused` key +means nothing was dropped. A request whose ids are **all** refused still answers `200` with +`success: true`; what says so is `queued: 0` and every id listed under `refused`. The refusal is in +the body, not the status. + +`select_all` is unchanged: it already listed only what the caller may read, and that filter stays +the one that decides. + +`collection_id` now rejects an empty array and an empty-string id, which used to be accepted and +then either queued nothing or queued a job naming no document. + +### A job is resolved to its document before anyone may touch it + +`POST {basePath}/run/:id`, `DELETE {basePath}/cancel` and +`DELETE {basePath}/cancel-by-collection/:collection_slug` used to act on a handle without ever +asking what document was behind it. + +- **`DELETE /cancel` can now answer `200`.** It still answers `204` when every handle was cancelled. + When some were refused it answers `200` with `{ refused: [...] }` naming them. A client that treats + any 2xx as success needs no change; one that asserts `204` does. +- **`POST /run/:id` answers `403` for a job whose row recorded no requester.** Letting the queue + drain such a job is unchanged — it was always going to run. Asking for it to run **now** is a + second person's request, and the row names nobody to check it against. +- **A handle the runner cannot place** is a `404` on run, and is left alone by cancel rather than + cancelled unasked. +- **Cancel reports a handle it could not place as neither cancelled nor refused.** It is dropped, + and a request whose handles were *all* unplaceable answers `204` — "nothing left to cancel" — + rather than naming them. Pre-existing behaviour, now reachable by one more route: a runner whose + `assignmentsOf` answers `[]` for everything satisfies the contract and makes every cancel a silent + success. Known, and left as it is: saying so would need a third category beside cancelled and + refused, which is an endpoint-contract change rather than part of this one. Omitting is for a + handle the runner does not have; a lookup it cannot perform **at all** is a failure and should be + thrown, not answered as `[]`. +- **A custom `TaskRunner` must implement `assignmentsOf`.** It is a required member, so an existing + runner stops compiling until it is added. That is the point: a runner that cannot say which + document a handle names cannot be asked about it, and an optional member would have moved the + failure from the compiler to a `403` in production, where nothing names the cause. + `cancel-by-collection` does not need it — it already knows the collection it was called for. + The method takes `readonly JobHandle[]` and answers a `JobAssignment` per handle it can place, + omitting the rest; neither type is published, and neither needs naming to implement the method. + +### The queue takes only work the rules were asked about + +`TaskRunner.enqueue`, `cancel` and `run` no longer accept plain tasks and handles. Their parameters +carry a type-level brand that only the plugin's own handlers can produce, so work that reached the +queue without an ask does not compile. + +- **A host that implements `TaskRunner` is unaffected** — a method declared with the wider parameter + type still satisfies the narrowed one. +- **A host that calls a runner directly**, rather than going through the endpoints, no longer + compiles. The brands are deliberately not exported: an exported minter would be a way to put + unasked work on the queue, which is the thing being prevented. Go through the endpoints. + +### An actor we cannot name stops being read as no actor + +A request whose user carries no `collection` — which only a custom auth strategy or a hand-made +local-API user produces, since Payload's own JWT strategy always stamps it — used to be read as *no +user*, and a write with no user runs with Payload's access checks off. + +- The endpoints answer `403` for such a request. +- Auto-translate skips the document and logs a warning, rather than queueing an unchecked run. It + skips rather than throws because the hook runs inside the editor's save transaction, where a throw + would roll the editor's save back. +- **A request with no user at all is unchanged** and still trusted. That is a deliberate, pinned + decision: in-process callers — a seed script, a local-API call, a webhook handler — have always + been trusted, and making them fail closed would strand them. + +### A chain module can no longer write the document being translated + +A member of the translation chain used to receive the whole `payload` and could write the very +document the run was translating, past every ask above. It now receives a guarded view: `create`, +`update`, `delete`, `duplicate` and `restoreVersion` **on the run's own collection** throw. Every +other collection, and every read including of the document under translation, is untouched. + +- **This is a runtime refusal, not a compile-time one.** The view's type is still `Payload`. The + run's collection is a value, not a literal, so there is no type that admits every collection but + that one. +- **It does not cover `payload.db`**, nor a method reached through the prototype rather than off the + object. A module determined to write anyway can still do so; the guard stops the accident, not the + intent. +- **A host whose chain module writes the translated collection starts failing.** The error + propagates rather than being swallowed, so the run fails loudly instead of half-writing. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-18-the-rewiring.md b/packages/payload-plugin-translator/docs/plans/2026-10-18-the-rewiring.md index e26f32e94..2a7bff030 100644 --- a/packages/payload-plugin-translator/docs/plans/2026-10-18-the-rewiring.md +++ b/packages/payload-plugin-translator/docs/plans/2026-10-18-the-rewiring.md @@ -766,6 +766,11 @@ Two of these are complete outcomes rather than half-finished work: ### Verification between steps Steps 2, 5, 6 and 8 each change the public surface. Each publishes a prerelease under the `next` -dist-tag and is exercised in `apps/cms` — which consumes the plugin as a workspace dependency and -owns real Postgres migrations — before the next step starts. A step whose prerelease has not been run -against a real database is not finished, because the stored job input changes in two of the four. +dist-tag and is exercised before the next step starts. + +**Corrected 2026-10-23 (step 5).** This paragraph named `apps/cms` as where that happens, "because the +stored job input changes in two of the four". `apps/cms` wires `createSyncRunner()`, which holds its +tasks in memory and writes no job row, so the stored input is never exercised there — and its +migrations carry no task slug of this plugin's. The stored shape is exercised by the integration +suites in `apps/dev`, which run the real queue runner. `apps/cms` remains the check that the plugin +still configures and builds inside a full app. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-23-fields-not-a-strategy.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-23-fields-not-a-strategy.task.md new file mode 100644 index 000000000..994d0f533 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-23-fields-not-a-strategy.task.md @@ -0,0 +1,356 @@ +# A run carries fields, not a strategy + +**Status:** awaiting approval · **Risk:** high · **Issue:** #208, the server half of #48 +**Design:** `2026-10-18-the-rewiring.md` §4 (S1–S6); D21 superseded, see D2 below. + +## Requirements / Task restatement + +A translation run carries a *strategy* — `overwrite`, or `skip_existing` (fill what is empty, refresh +what drifted). The strategy exists because nobody ever asked the editor which fields they meant, so a +rule had to guess, and two of its clauses guess about state the caller never stated: "is the target +empty" and "did the source move". One of them, `skip_existing`, means something different again when +provenance is off. + +Replace the guess with an explicit answer: **a run carries the list of leaves to translate.** +`TranslationStrategy` disappears as a port, with both implementations, from the pipeline, the enqueue +input, the stored row, the host-facing task, the status response and both admin forms. + +Server only. The field-picker tree is built elsewhere; nothing here touches `src/client/` except what +dies with the strategy. + +## What already exists — platform · installed · project · verdict + +Nothing is invented; every piece reuses something the package already has. + +| Need | What already does it | +|---|---| +| enumerate every translatable leaf of a document | `projectTranslatableContent(doc, schema)` — `core/domain/content-projection/contentProjector.ts:42`, already pinned against the pipeline's collector by `driftGuard.test.ts` | +| answer "which leaves drifted" | `changedLeaves(recorded, currentFields)` + `recordsEachLeaf` — `core/domain/provenance/staleness.ts:55` | +| the one address grammar | `makeIdPath` / `elementSegment` — `core/domain/content-projection/idPath.ts:78,91` | +| refuse bad input at the request | `EnqueueInputSchema` + `ServerResponse.validationError` — the enqueue route already rejects four ways | +| one declaration for the stored row | `jobInput.schema.ts` — generates both the TS types and the registered Payload fields | +| normalise a target list | `Locales.resolveTargets` / `Locales.dedupe` | + +Nothing new is built outside the two units named under **New surface**. + +## Decisions + +**D1 — the pipeline takes a list of leaf addresses; the strategy port is deleted.** +`FieldChunkCollector`'s per-leaf rule collapses to: translatable · in the list · source non-empty. +`targetValue` leaves the decision entirely — nothing consults the target locale to decide what to +translate. `targetData` stays, because `DataReconciler` still needs it to assemble the document. +*Rejected — keep the port with one implementation that reads the list.* An interface with one +implementation and no second caller is a field, and the port's whole purpose was to hold the branch +this removes. + +**D2 — the row carries named criteria, not a run kind. Supersedes D21.** +D21 stores `ordinary | out-of-date only`. That names one source of the list inside a type, so a second +source ("leaves empty in the target", "leaves matching a filter") cannot be added without changing the +type and every reader of it. A list of names can. +*Rejected — D21 as written.* Recorded here rather than argued again later. + +**D3 — a criterion is a source of a list, not a filter over one.** It answers "here are the addresses +I vouch for"; the run intersects. *Rejected — hand the criterion the current list to filter.* Then +order matters and every criterion must be told about everything before it. As intersection, order +cannot matter — a property of the shape rather than a rule anyone has to honour. It also makes a +person's ticks and a criterion the same kind of thing: both are lists. + +**D4 — "everything" is materialised into a real list before any intersection.** +With no selection on the row, the handler calls `projectTranslatableContent(sourceData, schema)`. So +inside the run the list is always concrete. +*Rejected — treat "absent" as a special value meaning everything.* Then the intersector has two input +shapes and every criterion must handle both. + +**D5 — the criteria are applied by the handler, not by a module on the seam.** Provenance keeps one +job, the one it has always had: it *reports*. Before #198 it produced a drift map and +`SkipExistingStrategy` decided; it has never narrowed anything, and this step does not give it that +job. +*Rejected — a narrowing module on the chain.* A module's failure is swallowed by design +(`swallowOrThrow`), and for a narrowing step swallowing turns "only what drifted" into "the whole +document, over reviewed text", silently. A criterion that cannot answer fails the translation instead. +*Rejected — a registry of criterion modules keyed by name.* One criterion exists; that is structure +for a second caller nobody has. + +**D6 — the chain stays assembled at boot.** The plan this task adopted said per-locale chain assembly +was the largest piece of the work. **It is not owed.** That followed from criteria being modules; with +D5 they are not. The chain is host modules plus provenance's recorder, the same for every run, and what +varies per locale is a field the handler reads. One clause of the inherited design, dropped on the +"is it due here" walk. + +**D7 — an old row's `skip_existing` is read as the `stale` criterion.** Rows written before this +change are in flight when it deploys and carry a strategy the code no longer has. Translating the +intent preserves what the person asked for. +*Rejected — ignore the old field and translate everything.* Cheaper, and it spends the host's money +re-translating over reviewed text for every row already queued. +*Removal condition, not a version:* `DEPRECATIONS.md` says to drop the read once no row written before +1.0 can still be in flight — a shim whose life is the queue's drain time, not a public type's. + +**D8 — "translated nothing" is logged, not reported.** Three causes are now distinguishable: nothing +drifted, the selection's addresses no longer exist, nothing the person ticked drifted. The log names +which. *Rejected — carry it to the host and the admin.* That crosses the runner port and the lifecycle +event type, which step 6 (#200) reshapes; doing it now means doing it twice. + +### Placement + +| Piece | Lands in | Why there | +|---|---|---| +| the intersector | `core/domain/selection/` (new) | pure, no Payload, beside the other domain rules it composes with | +| the `stale` answer | `server/modules/provenance/Provenance.service.ts` | the comparison reads provenance's own store; the service is already "the single owner of fingerprint policy" | +| applying criteria | `server/features/translate-document/handler.ts` | the run is where "what do we translate" is decided | +| the row's shape | `payload-jobs-runner/store/jobInput.schema.ts` | the one declaration both the types and the Payload fields come from | +| validation of a selection | `server/features/enqueue-translation/model.ts` | refusals belong at the request, not in the worker | + +### New surface — the shape check + +Triggers fired: a field is added to a published type (`TaskInput`, `TranslationTask`, the enqueue +input); the new fields are optional; an exported function gains a parameter (`translateContent`). + +**The row's per-locale shape**, the one that matters: + +| | `targets: [{ lng, selection?, criteria? }]` (chosen) | `target_lngs: string[]` + parallel maps keyed by locale | +|---|---|---| +| cost of the next field of this kind | 1 place — the entry type | 2 — a new map and its reader | +| states admitted / produced | **n / n** — one entry per locale by construction | admits a map key with no locale, and a locale with no entry; neither is ever produced | +| second caller already in the repo | `localesAsTheyStand` and `extendJob` both walk the list | same | +| breaks for an implementor outside this repo | the stored shape a third-party runner replays | identical | + +One shape survives on the honesty row. Two new units, each with two callers named: + +- **the intersector** — callers: the handler (every run), and its own suite. Second *behavioural* + caller is the `stale` path, which composes with a selection. +- **the `stale` answer on the provenance service** — callers: the handler, and the staleness route, + which needs the same per-leaf answer to report drifted addresses. + +### Written contract + +Two units owe callers what a signature cannot state, so Phase 3 runs these through `/sp-red-test`: + +- **the intersector**: the result is a subset of every input; order of inputs does not change it; an + empty input list means an empty result, not "no restriction"; an address no input vouches for is + absent rather than an error. +- **a criterion's answer**: the complete set of addresses it vouches for, independent of what the run + already holds — never a filtered copy of the input. + +### Escalate to `/sp-architect`? + +No. The design arrived from `2026-10-18-the-rewiring.md` §4 and this task builds one of its steps. + +## Acceptance Criteria + +Run from `packages/payload-plugin-translator` unless stated. + +| AC | Criterion | How it is checked | Passes when | +|---|---|---|---| +| AC1 | A run with a selection translates those leaves and leaves every sibling's target text untouched | `bunx vitest run src/core/translation-pipeline` | the new case runs green | +| AC2 | A run with no selection translates every translatable leaf | same file | green — today's default, unchanged | +| AC3 | The intersector keeps its contract: subset, order-free, empty means empty | `bunx vitest run src/core/domain/selection` | green, written red-first against a stub | +| AC4 | A selection together with more than one `collection_id` is refused | `bunx vitest run src/server/features/enqueue-translation` | 400, the message names the restriction | +| AC5 | A selection carrying a positional address (`content.#0.heading`) is refused | same file | 400, the message names the address | +| AC6 | An unknown criterion name is refused at the request | same file | 400, the message lists the known names | +| AC7 | A run naming `stale` translates the drifted leaves and nothing else | `bunx vitest run src/server/features/translate-document` | green | +| AC8 | A selection **and** `stale` translates only the ticked leaves that drifted | same file | green | +| AC9 | A criterion that cannot answer fails the translation rather than translating everything | same file | the run rejects; no provider call | +| AC10 | A run that translates some leaves leaves the receipt's claims for the others unchanged | `bunx vitest run src/server/modules/provenance` | green; the untranslated leaf still reads stale afterwards | +| AC11 | The staleness route reports the drifted addresses, and none for a whole-document receipt | same command | green | +| AC12 | An old row (`target_lngs` + `strategy: "skip_existing"`) reads back as one target naming `stale` | `bunx vitest run src/server/modules/task-runner` | green | +| AC13 | Two requests over one document differing only in their lists share a single run | `bun run test:integration` from `apps/dev` | green; one job row | +| AC14 | Auto-translate still enqueues on a source change, and its run covers the whole document | same command | green | +| AC15 | Nothing else moves | `bunx vitest run` · `bun run check-types` · `bunx oxlint .` · `bunx turbo run build --filter='./packages/payload-plugin-translator'` from the root · `bun run test:integration` from `apps/dev` | ≥183 files / ≥2445 checks, 0 type errors, ≤44 warnings and 0 errors, build green, 63 integration files green | +| AC16 | No `strategy` left in the package outside the deprecation record | `grep -rln "strateg" src --include="*.ts" --include="*.tsx" \| grep -v "__tests__" \| grep -v "\.test\." \| grep -vi "field-actions"` | empty | + +## Pre-flight — each check run against the untouched tree + +| AC | Result on the base | Classification | +|---|---|---| +| AC1, AC2, AC7–AC9 | the cases cannot be written in their final form: `translateContent` has no `selection` parameter and the handler has no criteria, so a test naming either is a type error | **change** — fails for the right reason | +| AC3 | `src/core/domain/selection` does not exist; vitest exits non-zero with no matching files | **change** | +| AC4–AC6 | `EnqueueInputSchema` has no `selection` and no `criteria`; a request carrying either passes validation today, since zod strips unknown keys | **change** | +| AC10 | the current recorder re-bases an unsent leaf onto the current hash (`Provenance.service.ts:37-44`), so the assertion fails today | **change** | +| AC11 | the staleness route returns `{target_lng, source_lng, is_stale, translated_at}` and no addresses | **change** | +| AC12 | there is no `targets` shape to read into | **change** | +| AC13 | `pickHost` compares `strategy`, so two requests naming different strategies cannot share a run | **change** | +| AC14 | passing | **invariant** | +| AC15 | 183 files / 2445 checks, 0 type errors, 44 warnings, build green, 63 integration files | **invariant** | +| AC16 | **33** non-test files under `src` carry the word | **change** | + +## Risk notes + +**High.** Three reasons, each changing what later phases do: + +1. **Published types change** — `TaskInput`, `TaskHandlerInput`, `TranslationTask`, the enqueue input + and the status response. A third-party runner implements the port. +2. **The stored row's shape changes.** No SQL migration is needed (everything lives in Payload's + single `input jsonb` column), but rows written by the previous version are in flight at deploy and + must still read — D7. +3. **A silent widening is the failure mode.** Every defect in this area makes the run translate *more* + than it was asked to, over text a person reviewed, at the provider's price. Quiet is the dangerous + direction, which is why D5 refuses a swallowed failure and AC9 pins it. + +Two things to watch while building: + +- the address a selection names must be minted by the same grammar as the collector's and the + fingerprint's. `driftGuard.test.ts` pins two of the three producers; a selection is the third. +- `extendJob` appends to `target_lngs` with a `db.updateOne`; appending whole entries instead of + strings must keep its two-attempt retry and its "the row completed mid-append" fallback. + +## Human choices + +Settled by the owner in conversation, 2026-10-23: + +- **Strategies go completely**, including `AutoTranslateConfig.strategy` and the exported + `AutoTranslateStrategy`. Auto-translate is not reworked here; its runs cover the whole document. + "б - убираем тогда стратегии полностью." +- **The criterion is a source of a list, and the narrower only intersects.** "Сократитель … сам по + себе должен принимать только список полей которые мы переводим." +- **The field is named `criteria`.** +- **An old row's intent is translated** rather than dropped, marked as a shim with a drain-time + removal condition. +- **"Translated nothing" is logged only** in this step; surfacing it belongs to step 6. +- **The stored shape is exercised in `apps/dev`,** not `apps/cms` — `apps/cms` wires the simple runner + and never writes a job row. §9 of the rewiring plan is corrected to say so. + +At the Phase 2 gate: + +- **D6 confirmed — the chain stays assembled at boot.** Per-locale assembly was carried into this + task from the plan as "the largest piece"; it followed from criteria being modules, which D5 + refuses. No tenant today needs to be in one locale's chain and out of another's. +- **The staleness route reports drifted addresses in this step** rather than being left to the + session building the tree, so the two do not edit the same file. + +## Open, not solved here + +- **The auto-translate rework** — external control over whether a save triggers a run and over what a + run covers. Its own task. Notes from reading the module: the trigger criterion must stay synchronous + because it runs inside the editor's save transaction, and `AutoTranslatePolicyResolver(slug, doc)` + already exists as the declared swap point. +- **Surfacing "translated nothing" to the host and the admin** — step 6 (#200), where the runner port + and the lifecycle event are reshaped anyway. +- The field-picker tree and everything else in `src/client/`. +- A criterion that **adds** leaves. The shape admits narrowing only. +- A parameter channel for criteria. Arrives with the first criterion that has parameters. +- A leaf whose target was cleared by hand is written with the untranslated **source** text + (`DataReconciler.ts:84`). Pre-existing. +- Rich text cannot be selected below the field: the collector emits one leaf per `richText` field. + +## Regression sweep + +Mandatory at this risk level, so what was searched and what came of it: + +| Surface | Searched | Call sites read | Found | +|---|---|---|---| +| the runner port (`TaskRunnerProvider`, `TaskHandlerInput`) | `grep -rln "TaskRunnerProvider\|TaskHandlerInput"` over `packages` and `apps` | 21 files, all in this repository | none — every one compiles | +| the host-facing lifecycle task | `grep -rn "\.strategy"` over `packages` and `apps` | 2 | **one defect**: `apps/dev/src/lib/translator/lifecycleLogging.ts` read `task.strategy`. It compiled because `apps/dev`'s `check-types` covers only `src/integration/`; nothing else in that app is type-checked | +| the enqueue route | `grep -rln "/enqueue"` | 10 | none | +| Payload's generated types | `grep` over both apps' `payload-types.ts` | 2 files | **one defect**: `apps/dev/src/payload-types.ts` still declared `strategy` and `target_lngs`. Regenerated; `apps/cms` never carried them, because it wires the simple runner and registers no job types | +| unchecked host code in `apps/cms` | `grep -rn "\.strategy\|target_lngs\|skip_existing"` over `src` | — | none | + +*The first finding is the one worth keeping in mind:* a type-check that covers part of an app will +pass over a host consumer reading a field that no longer exists. + +## Acceptance criteria — walked with their declared checks + +| AC | Result | Evidence | +|---|---|---| +| AC1 selection translates those leaves, siblings untouched | met | `bunx vitest run src/core/translation-pipeline` — 199 checks green, including "keeps the target text of a leaf the selection left out" | +| AC2 no selection translates every translatable leaf | met | same command, "sends every translatable leaf when the request named none" | +| AC3 the intersection keeps its contract | met | `bunx vitest run src/core/domain/selection` — 9 checks, written red-first against a stub; every one proven to redden under a mutation of its own clause | +| AC4 a selection with more than one document is refused | met | `bunx vitest run src/server/features/enqueue-translation` — 31 checks | +| AC5 a positional address is refused | met | same command, and `namesAPosition` has its own 6 checks | +| AC6 an unknown criterion name is refused | met | same command; the message lists the known names | +| AC7 a run naming `stale` sends the drifted leaves only | met | `bunx vitest run src/server/features/translate-document` — 90 checks | +| AC8 a selection and `stale` together | met | same file, "sends only what both the request and the criterion name" | +| AC9 a criterion that cannot answer fails the run | met | same file, two cases: the criterion throws, and nothing answers the name. No provider call in either | +| AC10 the receipt keeps the claims for leaves a run did not send | met | `bunx vitest run src/server/modules/provenance` — 96 checks with `src/server/features/staleness` | +| AC11 the staleness route reports the drifted addresses | met | same command, and reports none for a whole-document digest | +| AC12 an old row reads back | met | `bunx vitest run src/server/modules/task-runner` — 298 checks, including a check tying the shim's criterion literal to provenance's own constant | +| AC13 two requests over one document share a run | met | `bun run test:integration` — `field-selection.int.test.ts`, both the append path (another locale) and the covered path (the same locale) | +| AC14 auto-translate enqueues, and covers the whole document | met | `auto-translate.int.test.ts`, 4 checks | +| AC15 nothing else moves | met | 186 files / 2461 checks · 0 type errors · 43 lint warnings, 0 errors (one fewer than the 44 baseline) · build green · integration 56 files, 224 passed / 8 skipped | +| AC16 no `strategy` outside the deprecation record | met, with the nuance below | three files carry the word, all of them the read-only shim D7 approved: `jobInput.schema.ts` (`LegacyRunSchema`), `runTargets.ts` (`legacyRunTargets`) and the destructure in `localesAsTheyStand.ts`. The criterion said "no hit outside a comment naming the removal"; these are code, not comment, and they are the shim itself | + +## Review log + +### Comment audit over the whole diff — 2026-10-23 + +67 blocks collected, 65 judged (two were Markdown and out of scope), verdicts reconciled. Density was +fine for inline comments (0.15 per ten lines of added code) and heavy for docblocks: one fact — "a +selection and criteria are per locale; omitting a selection means every leaf" — was written out in +seven places. + +Four of the findings were **false statements**, not verbosity, and each is a defect this run +introduced: + +| Found | What was wrong | Done | +|---|---|---| +| `field-selection.int.test.ts` | its docblock claimed the queue runner carried the selection through a stored row. `bootTestPayload()` with no runner uses the **simple** runner, which writes no row — so the file's whole subject was untested | switched to `createPayloadJobsRunner({ autoRun: false })` and driven with `jobs.run` | +| `idPath.ts` | inserting `namesAPosition` left `makeIdPath`'s docblock stranded above it | moved back | +| `runTargets.ts` | "the module that answers to it is optional, so the name is spelled rather than imported" — importing a string constant needs nothing at run time. The real constraint is that `store/` must not depend on `provenance/` | reason corrected | +| `Provenance.criterion.ts` | "the plugin passes the name around without reading it" — the store spells the same literal | corrected, and a test now ties the two spellings together rather than prose | + +The rest: 18 deleted, 22 rewritten, 17 answered by changing code — the largest of those replacing six +copies of one docblock with a shared `RunAsk`, and six hand-written copies of an enumeration with +`everyTranslatableLeaf`. + +### Three fresh-eyes passes — 2026-10-23 + +Run in parallel over the finished diff: one on the new mechanism, one on stored data and consumers +written before the change, one on what the deletion took with it. Between them they found one defect +I had introduced **while fixing another one**, and three more. + +**The merge rule was wrong, and I removed it.** A previous pass had added `widenedBy`, merging a +second request into a locale a live run already listed, so the second ask would not be dropped. The +merge took the union of the selections and the intersection of the criteria — **independently**, and +that is not the union of two asks. A run listing `{criteria: ["stale"]}` merged with a request naming +`{selection: ["x"]}` produced an entry naming neither, which translates **the whole document**: past +both asks, over reviewed text. The shape cannot express the union of two `(list, rules)` pairs at all. + +Replaced with the simpler thing: `alreadyCovered` now asks whether the listed ask **covers** the new +one — starts from at least as much, narrows by at most as much — and a locale it does not cover goes +to its own run. That deletes `widenedBy`, and with it three further findings that only existed +because of it: a widening lost to a concurrent writer, a widening applied after the worker had +already read the row, and a locale reported covered by a narrower run under `enableConcurrencyControl`. + +*What it costs:* a request asking for fields the live run was not asked for now gets its own run over +that document, which can race the first. That hazard is older than this change — S5 measured it — and +the README now says to switch `enableConcurrencyControl` on. + +| Other finding | Done | +|---|---| +| a host still posting `strategy` to `/enqueue` got a 200 and a run over the whole document | refused with a 400 naming the replacement | +| a host config still carrying `AutoTranslateConfig.strategy` was ignored in silence | warns at boot, per collection, with a test | +| `sourceChangedByLeaf` was computed on every run and read by nobody — the receipt was read twice | the field and its plumbing deleted; the criterion uses the shared `driftedAddresses` | +| `DEPRECATIONS.md` claimed a queued `skip_existing` row "keeps meaning what it meant" | corrected to a table of the three cases, including that with provenance **off** such a row now fails rather than overwriting — deliberate, and stated | +| `README.md` still described `skip_existing` | rewritten around the `stale` criterion | +| the converted blocks test could no longer catch a pairing bug, and no integration case ran a selection over blocks | one added: a selection naming one block's leaf by id, with its sibling's target text left standing | + +**One hygiene finding stands, with its proof.** `sp-diff-checks` reports an assertion removed from +`chainOrder.test.ts` with none added back. It guarded the chain's fallback for `sourceChangedByLeaf`; +that field now has zero references anywhere under `src`, so the behaviour it guarded no longer +exists. + +**Not fixed, recorded instead:** three cases deleted with the two strategy integration suites have no +integration replacement — publishing when nothing drifted, and writing nothing when a versioned +collection is already translated. The publish-after-nothing path is covered by a unit test +(`handler.test.ts`). A coverage gap, not a defect. + +## Superseded in part + +The two members this contract gave a target locale — `selection` and `criteria` — were merged into +one list of filters before any of it was committed. See +`2026-10-23-one-list-of-filters.task.md`; every decision above stands, only the shape moved. + +**AC4, AC5 and AC6 were reversed on 2026-10-25, before any of this was committed.** The enqueue +route no longer refuses a selection sent with several documents, an address that names an element by +position, or a rule name nothing answers to. `namesAPosition` was the predicate AC5 ran on and had no other caller, so it went too, with its six checks. All three were request-time checks on a property of a +string, and the grammar that mints those strings disagreed with two of them: `elementSegment` +produces a positional address deliberately, with its own checks. Whether anything answers to a rule +name is now settled only where the run is narrowed (`translate-document/handler.ts`, `answerTo`), +against the criteria as they stand when it runs rather than when it was queued — the throw was +always there, and the route merely asked the same question earlier. `knownCriteria` went with it, +out of `ConfigTimeContext`, `PluginConfigBuilder`, `createTranslationRoutes` and `EnqueueConfig`. + +What the removal gives up, recorded rather than implied: a selection holding a positional address +is resolved against the document as it then stands, so a run whose elements were reordered in the +meantime translates a different element and reports success. Reachable only through an array or +blocks row with no `id`, which Payload assigns by default. The cure, if it is ever owed, belongs to +whatever mints the address or to the field picker — not to the request handler. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-23-one-list-of-filters.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-23-one-list-of-filters.task.md new file mode 100644 index 000000000..13a836f13 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-23-one-list-of-filters.task.md @@ -0,0 +1,260 @@ +# One list of filters, not two fields + +**Status:** awaiting approval · **Risk:** high · **Follows:** `2026-10-23-fields-not-a-strategy.task.md`, same branch, before any commit + +## Requirements / Task restatement + +The previous pass gave each target locale of a run two optional members: `selection`, a list of leaf +addresses, and `criteria`, names of rules that narrow it. Both answer one question — which leaves +does this run translate — and the handler has to say so twice: `selection ?? everything`, then +intersect each criterion's answer. + +Make it one list. A filter is either the addresses themselves or the name of a rule that produces +them; the run translates the intersection of every filter's answer, and an empty list means every +translatable leaf. + +```ts +{ kind: "leaves", addresses: ["title", "layout.b1:text.content"] } +{ kind: "rule", name: "stale" } +``` + +**Behaviour does not change.** All four cases answer exactly as they do now: + +| Today | After | Result | +|---|---|---| +| `selection: [a, b]` | `[{ leaves: [a, b] }]` | `[a, b]` | +| `criteria: ["stale"]` | `[{ rule: "stale" }]` | the drifted leaves | +| both | both filters | `[a, b]` ∩ drifted | +| neither | `[]` | every translatable leaf | + +The intersection of no filters is everything, so the special case "a selection sets the starting +list" disappears rather than being re-implemented. + +## What already exists — platform · installed · project · verdict + +| Need | What already does it | +|---|---| +| a discriminated union parsed out of a JSON column | `SourceFingerprint` (`core/domain/provenance/SourceFingerprint.ts:28`) — zod, a `kind` tag, a parser that degrades rather than throws. The precedent to follow | +| intersect lists of addresses | `narrowSelection` (`core/domain/selection/`), unchanged | +| the starting list | `everyTranslatableLeaf` (`core/domain/content-projection/contentProjector.ts`), unchanged | +| refuse at the request | the enqueue route already refuses four ways | + +Nothing new is built beyond the shape itself and the per-kind comparison in `covers`. + +## Decisions + +**D1 — one list, intersected; the two fields go.** +*Rejected: keep `selection` and `criteria`.* They are two mechanisms for one question, and the +asymmetry is written into every reader: the schema, `leavesToSend`, `covers`, and three refusals. +The intersection already expresses what `selection` did — a filter that vouches for exactly those +addresses — so the special case is removable rather than inherent. + +**D2 — two kinds: `leaves` and `rule`.** +*Rejected: `kind: "stale"`, a union enumerating the rule names.* That puts provenance's word into a +type the plugin declares, which is the dependency the previous pass spent D5 avoiding. With a `rule` +kind the name stays opaque data, checked against what is registered rather than against a type. +*Rejected: `kind: "fields"`, as first sketched.* `SourceFingerprint` already uses `kind: "fields"` in +this codebase, for a different thing — how a receipt records the source. Two unions, one tag value, +two meanings is a collision a reader pays for. `leaves` is the word the rest of the code uses for +what an address names. + +**D3 — filters resolve in the handler; the pipeline is untouched.** +`translateContent` keeps taking `selection: readonly string[]`, and `core/` does not change at all. +*Rejected: pass the filters down.* S1 of the rewiring plan says the pipeline takes one input, and a +filter that must ask provenance cannot run inside a pure core function. + +**D4 — one ask covers another when every filter it carries is implied by one of theirs.** +For a `leaves` filter that means their addresses are a subset of ours; for a `rule` filter, the same +name. An ask carrying no filters covers everything; an ask carrying filters covers no empty ask. +*Rejected: compare the two lists for equality.* It would refuse `{leaves:[a,b]}` covering +`{leaves:[a]}`, which is a run that genuinely already serves the request. +*This is the same rule as today*, written once instead of once per member. + +**D5 — the pre-1.0 shim produces a filter.** `skip_existing` becomes `[{ kind: "rule", name: "stale" }]` +and anything else becomes `[]`. Unchanged in meaning from the previous pass; only the shape moves. + +### Placement + +| Piece | Lands in | Why there | +|---|---|---| +| the filter union and its parse | `payload-jobs-runner/store/jobInput.schema.ts` | the one declaration the stored types and the registered Payload fields are both generated from | +| `covers` | `payload-jobs-runner/model/alreadyCovered.ts` | where it already is | +| resolving a filter to addresses | `translate-document/handler.ts` (`leavesToSend`) | the run is where "what do we translate" is decided (D5 of the previous pass) | +| the wire shape and its refusals | `enqueue-translation/{model.ts,handler.ts}` | refusals belong at the request | + +### New surface — the shape check + +Triggers fired: a field changes on a published type (`TaskInput`, `TaskHandlerInput`, the enqueue +input, the stored row); the new member is optional; an exported function changes shape. + +| | `filters: Filter[]` (chosen) | `selection?` + `criteria?` (today) | +|---|---|---| +| cost of the next kind of narrowing | **1** — a member of the union and its resolver | **5** — a third optional field, plus a branch in the schema, `leavesToSend`, `covers` and the refusals | +| states admitted / produced | every state the union admits is produced | admits `{selection: [], criteria: []}` and three more combinations of two optionals for one question; the schema refuses some, the type allows all | +| second caller already in the repo | `leavesToSend` resolves, `covers` compares, the enqueue route validates | the same three | +| breaks for an implementor outside this repo | `TaskHandlerInput` and the stored row — **but neither has shipped**, which is the reason to do this now rather than after | + +One shape survives on the first two rows. + +### Written contract + +Two units owe callers what a signature cannot state, so Phase 3 runs them red-first: + +- **`covers`** — what "covers" means (the listed ask translates at least what the new one asks for), + that it is deliberately conservative (it may answer false for an ask it does in fact cover, which + costs a second run rather than lost work), and that no filters covers everything while any filters + covers no empty ask. +- **the filter list** — an empty list means every translatable leaf, not none; order does not matter, + because the result is an intersection. + +### Escalate to `/sp-architect`? + +No. One shape, five readers, all in this package, and the design came from the owner at the review. + +## Acceptance Criteria + +Run from `packages/payload-plugin-translator` unless stated. + +| AC | Criterion | How it is checked | Passes when | +|---|---|---|---| +| AC1 | A request naming addresses translates those leaves | `bunx vitest run src/server/features/translate-document` | green | +| AC2 | A request naming a rule translates what the rule vouches for | same | green | +| AC3 | A request naming both translates the intersection | same | green | +| AC4 | A request naming no filters translates every translatable leaf | same | green | +| AC5 | A rule nothing answers to fails the run rather than widening it | same | the run rejects; no provider call | +| AC6 | A `leaves` filter sent with more than one document is refused | `bunx vitest run src/server/features/enqueue-translation` | 400 naming the restriction | +| AC7 | A `leaves` filter carrying a positional address is refused | same | 400 naming the address | +| AC8 | A `rule` filter nothing answers to is refused at the request | same | 400 listing the known names | +| AC9 | An empty filter list is accepted and means everything; an empty `addresses` is refused | same | 200 and 400 respectively | +| AC10 | One ask covers another exactly when it translates at least as much | `bunx vitest run src/server/modules/task-runner` | green, red-first against a stub | +| AC11 | A pre-1.0 `skip_existing` row reads as one `rule` filter named by provenance | same | green, and the literal is tied to provenance's own constant | +| AC12 | The registered Payload field list matches the schema | same | the regenerated baseline is what the generator produces | +| AC13 | The four cases behave end to end as they did before | `bun run test:integration` from `apps/dev`, after a rebuild | green | +| AC14 | Nothing else moves | `bunx vitest run` · `bun run check-types` · `bunx oxlint .` · `bunx turbo run build --filter='./packages/payload-plugin-translator'` from the root · `bun run test:integration` | ≥187 files / ≥2462 checks, 0 type errors, ≤43 warnings and 0 errors, build green, 56 integration files green | +| AC15 | `selection` and `criteria` are gone as separate members of an ask | `grep -rn "selection?: readonly\|criteria?: readonly" src --include="*.ts"` | no hit on an ask. Two stay and are not asks: the pipeline's own `selection` parameter (D3 keeps it), read back in `handler.test.ts` and `handler.criteria.test.ts` to see what was sent. (`wireTranslateRunner`'s `criteria?: Criteria` is the registry of answerers, a different thing, and stays) | + +## Pre-flight — each check run against the current branch + +| AC | Result now | Classification | +|---|---|---| +| AC1–AC5 | the cases exist and pass against `selection`/`criteria`; rewritten against `filters` they are a type error, because the member does not exist | **change** | +| AC6–AC9 | the refusals exist against `selection`/`criteria`; AC9's "empty filter list is accepted" cannot be expressed | **change** | +| AC10 | `covers` exists and passes with its two-member comparison; a case naming a filter list is a type error | **change** | +| AC11 | the shim produces `{ criteria: ["stale"] }`, not a filter | **change** | +| AC12 | the baseline records `selection` and `criteria` as two json fields | **change** | +| AC13 | passing — the four cases are the behaviour that must survive | **invariant** | +| AC14 | 187 files / 2462 checks, 0 type errors, 43 warnings, build green, 56 integration files | **invariant** | +| AC15 | two hits, both on `RunAsk` (`task-runner/types.ts:27-28`) | **change** | + +## Risk notes + +**High**, for the same two reasons as the pass before it, plus one of its own: + +1. **The stored row changes shape again.** Nothing has shipped, so no row anywhere carries either + shape — which is exactly why this is cheap now and a second breaking change later. +2. **The published port changes again.** `TaskHandlerInput` is what a third-party runner implements. + Same answer: unshipped. +3. **`covers` is where a defect hid twice today** — first as a dropped ask, then as a merge that + widened past both asks. It gets the red-first treatment and a mutation per check. + +One thing to watch: the refusals move from "a selection was sent" to "a `leaves` filter was sent", +and a request may now carry several filters of the same kind. Two `leaves` filters intersect, which +is legal and means something; two `rule` filters with the same name are a duplicate and mean nothing +new. + +## Human choices + +Settled by the owner at the review of the previous pass, 2026-10-23: + +- **One list, not two fields.** "selection — это тоже признак, как и stale… я бы это включил в более + общий неймспейс." +- **A tagged data shape, not a class instance** — the column holds JSON; a class does not survive it. +- **Two kinds, not an enumeration of rule names** — a name in the type would put provenance's word in + a type the plugin declares. +- **Now, before the commit** — the stored shape is about to ship in a major and a third-party runner + replays it; changing it twice costs two breaking entries. + +Raised by me at this gate: + +- **`leaves` rather than `fields`** for the tag value, because `SourceFingerprint` already uses + `kind: "fields"` in this codebase for a different thing. + +## Open, not solved here + +- Everything the previous contract left open stays open: the auto-translate rework, surfacing + "translated nothing" to the host, the field-picker tree, a filter that widens rather than narrows. +- **The field-picker tree in the parallel session posts the old shape.** It must be told before it + settles: `selection: ["title"]` becomes `filters: [{ kind: "leaves", addresses: ["title"] }]`. + +## Review log + +### 2026-10-23 — four reviews, three defects fixed + +Risk is high, so three angles ran in parallel beside the comment audit. + +| Angle | Verdict | +|---|---| +| behaviour preservation | the four cases hold; the intersection, the empty list and the order-independence are all as stated. One defect: the wrong reason reported when a run sends nothing | +| the stored row and the pre-1.0 shim | the round trip keeps each locale's filters, and the legacy branch is unchanged. One defect: `targets` entries were never parsed | +| the request refusals | the positional refusal cannot be walked around; the known rule names cannot drift. One defect: two removed members were stripped in silence | +| comments | 37 blocks judged. Four stated something false; two were stranded or broken; one contract was written four times over | + +**Three defects, each fixed with a check that was red first.** + +1. **`selection` and `criteria` were stripped, not refused.** `EnqueueInputSchema` is a plain + `z.object`, which drops a key it does not declare — so a request asking for *less* was answered + with a run over the whole document. That is the hazard the `strategy` refusal already exists for, + and `strategy` had no check of its own either. All three are now refused by one `refused()` + helper, under one check that names the three. + *Not done: `.strict()` on the object.* It would refuse every unknown key and lose the message + that names the replacement, and it would break hosts sending a field this schema never read. + +2. **A stored `targets` entry was never parsed.** `runTargets` returned `input.targets` behind a + cast, so `RunTargetSchema`'s `.default([])` never ran. An entry without `filters` reached + `covers` as `undefined` and threw, and reached the worker as "no filters" — the whole document. + `StoredInputSchema` now says `targets` holds unknown entries, which is what a `json` column + makes true, and `runTargets` parses each one. **An entry that fails to parse is dropped, not + defaulted to `[]`**: an unreadable entry says nothing about what its locale was meant to + translate, and reading it as "no filters" would widen the run. + +3. **`sentNothingBecause` blamed the wrong thing.** It decided from the named addresses and the + final list, which cannot tell a rule that kept nothing from a leaf list naming addresses the + document does not have. A rule-only run was reported as a success when leaves had in fact + drifted and been skipped; a leaf-only run was told nothing was out of date when no rule had run. + The reason now comes from `leavesToSend`, the only place that sees each step, as one of three + named outcomes. Its docblock, which asserted the false premise, is gone with it. + +**Comment audit — 37 blocks, 14 kept.** Four false statements corrected: the throw in +`leavesToSend` is also reached by a pre-1.0 row with provenance off; `everyTranslatableLeaf` does +not answer for an empty leaf; `runTargets`' shim prose contradicted `DEPRECATIONS.md`, which calls +`stale` the nearest meaning rather than the same one; `Provenance.criterion` still called the name +a criterion a row stores. `localesAsTheyStand`'s docblock had been stranded from its function by an +inserted helper, and `FieldChunkCollector`'s had lost the reason for its `structuredClone`. Two +names were doing the work a comment had been covering: `acceptedBy` → `impliedBySomeOf`, +`servedElsewhere` → `needsItsOwnRun` (it names the locales served *nowhere* yet). + +**Declined, with reasons.** + +- *Move `"stale"` into core so `store/` and `Provenance.criterion` share one constant.* Refused by + the owner, 2026-10-24: `stale` is provenance's own word for what provenance measures, and core is + not where that vocabulary belongs. The duplication is the price of keeping the term inside the + module that owns it; the literal stays spelled in both places, tied by a test. +- *`storedTargets` groups by locale, so two tasks for one document and locale in a single + `enqueue()` call keep only the later one's filters.* The route cannot produce it; pre-existing + grouping, outside this pass. +- *A legacy `skip_existing` row fails on a host without provenance.* Deliberate, and already the + third row of the table in `DEPRECATIONS.md#the-strategy-leaves-the-stored-run`. +- *The package is not `oxfmt`-clean.* Files nobody touched are flagged too, so formatting is not a + gate here; only the one file this change left mis-indented was formatted. + +### Checks + +| | | +|---|---| +| `bunx vitest run` | 188 files / 2494 checks, green | +| `bun run check-types` (package and `apps/dev`) | 0 errors | +| `bunx oxlint .` | 43 warnings, 0 errors — the baseline | +| `bunx turbo run build` | green | +| `bun run test:integration` | 56 files / 225 checks green, 8 skipped | +| `sp-lint-delta` | nothing introduced | +| AC15 | the only hits are the pipeline's own `selection` parameter, which D3 keeps | diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-23-what-the-admin-needs-from-the-server.md b/packages/payload-plugin-translator/docs/plans/2026-10-23-what-the-admin-needs-from-the-server.md new file mode 100644 index 000000000..04fab4151 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-23-what-the-admin-needs-from-the-server.md @@ -0,0 +1,279 @@ +# What the 1.0 admin needs from the server + +**Status:** settled in design, not built · **Risk:** medium · **Ships with:** the major + +The 1.0 admin was designed from the editor's task rather than from the current screens. This records +only the **server-side** half of what that design assumes, so it is not re-argued with whoever picks +it up. The screens themselves are designed separately. + +## What this does *not* cover, because it is already settled on this branch + +Read these first; everything below docks onto them and none of it contradicts them. + +| Already decided | Where | +|---|---| +| a run carries a list of **filters**, not a strategy — `{ kind: "leaves", addresses }` or `{ kind: "rule", name }`, intersected, empty list meaning every translatable leaf | `2026-10-23-fields-not-a-strategy.task.md`, `2026-10-23-one-list-of-filters.task.md` | +| the per-locale stored shape `targets: [{ lng, filters }]` | same, the shape check | +| criteria are applied by the handler; provenance **reports** and never narrows (D5) | `fields-not-a-strategy`, D5 | +| the chain stays assembled at boot; no per-locale assembly (D6) | same, D6 | +| a `stale` answer on `Provenance.service`, whose named second caller is *"the staleness route, which needs the same per-leaf answer to report drifted addresses"* | same, New surface | +| one leaf per `richText` field — rich text cannot be selected below the field | same, Open | + +The field picker's output is a `{ kind: "leaves", addresses }` filter. Nothing new is needed to carry +a person's ticks to a run. + +**Nothing below changes what is stored.** Every value is derived at read time from the receipt the +plugin already writes. + +## 1. The staleness answer must carry the reason, not only the set + +`fields-not-a-strategy` already justifies the per-leaf `stale` answer by the staleness route needing +it. The delta is its shape: the run wants a **set of addresses**, the admin wants a **reason per +leaf**, and they should be one computation. + +```ts +type LeafVerdict = "current" | "drifted" | "unanswered" | "unrecorded"; +``` + +| Stored in the receipt | Source now | Verdict | +|---|---|---| +| hash A | hash A | `current` | +| hash A | hash B | `drifted` | +| `!unanswered` | anything | `unanswered` | +| address absent | a hash | `unrecorded` | + +The run collapses anything that is not `current` into "translate"; the admin reads the reason. Two +functions over the same receipt would put one rule in two places — and the rule changed once already +today ("an address the receipt does not name means translate"), which would then have had to be +changed twice. + +**Why the reason matters to a person.** `unanswered` means a leaf was sent and came back incomplete: +source text is sitting in the target, mixed with translation. Today nothing says so, and the only way +to find out is to read the translated page. `drifted` is ordinary life; `unanswered` is a failure. +The admin marks them differently. + +**It is already computed and thrown away.** `getStaleness` reads the document's records, hashes the +source once and reuses it across locales, then calls `isRecordStale`, whose `sameFields` walks every +leaf and collapses the walk to one boolean. Returning the walk costs nothing new. + +## 2. A route for the picker's tree + +Explicitly left to the client half by `fields-not-a-strategy`, but the tree itself is server data: it +describes the **saved document**, not the schema, because leaf addresses are id-based and a person +needs "this block, not that one". + +```ts +type DocumentTranslationState = { + /** The document's translatable shape — one tree, shared by every locale. */ + tree: readonly TreeNode[]; + /** Fields excluded from translation; labels only. */ + excluded: readonly string[]; + locales: readonly LocaleState[]; +}; + +type TreeNode = + | { kind: "group"; label: string; children: readonly TreeNode[] } + | { kind: "leaf"; label: string; address: string; required: boolean }; + +type LocaleState = { + locale: string; + content: ContentState; + /** A run in flight. Orthogonal to content: a first translation can be in flight too. */ + run: RunState | null; + /** Addresses without which Payload refuses the write. See §3. */ + mustInclude: readonly string[]; +}; + +type ContentState = + | { kind: "empty" } + | { kind: "untracked" } + | { kind: "tracked"; verdicts: Readonly>; + translatedAt: string; dismissed: boolean }; +``` + +Three things the shape is deliberate about: + +**`untracked` must not be folded into `empty`.** A locale with content and no receipt is one someone +translated by hand. Treat it as "never translated" and the admin offers "first translation, all of +it" and wipes their work — the failure removed from `skip_existing` earlier on this branch, walking +back in through the admin. Named in the type, it cannot be forgotten. + +**`group` and `leaf` are a union, not an optional `address`.** An optional field never says when it +is absent; a union forces both cases, and a third kind of node becomes a compile error rather than a +silent fall-through. + +**The run is its own field.** A locale can be translated and running again, or empty and running for +the first time. Two independent facts. + +Labels: a field has one; a **block instance** does not, and "Block 3" is useless where "Call to +action — «Buy»" is not. Payload solves this in its own admin with `RowLabel`. Real work, better +recognised here than discovered in implementation. + +Knowing a locale is empty is free: read it with `fallbackLocale: false` — the read `handler.ts:88` +already makes — and a locale with no value on any translatable leaf is empty. What must **not** be +used is the presence of a receipt, for the reason above. + +## 3. `mustInclude` — required fields that are empty in the target + +A partial run whose write leaves a required localized field empty is refused by Payload. This is not +a property of a first translation: add a required field to a collection and every existing locale is +now missing it, so the same refusal awaits a partial run there. + +So the admin locks those checkboxes, and the server supplies the list — it can see the target's +values and the client cannot. + +**Two locks with different lifetimes, and conflating them is the trap:** + +| Lock | Applies to | Lifetime | +|---|---|---| +| required and empty in the target | those leaves, on any write of that locale | **permanent** — a property of Payload | +| everything else, on a first translation | that run | until a deliberately excluded leaf is recorded as `UNANSWERED` | + +Written as one rule — "first translation locks everything" — the permanent half disappears with the +temporary one, and the failure surfaces later, for whoever adds a required field to an existing +collection. + +*Note against D1.* `fields-not-a-strategy` D1 says nothing consults the target locale to decide what +to **translate**. `mustInclude` does not: it is about what the **write** needs to be accepted. The two +do not conflict, but the distinction is worth keeping in words, because both are "a list of addresses +derived from the target". + +## 4. Abilities reach the browser through the provider, not a route + +A control whose ability is absent is **not rendered** — not disabled, not greyed (D20). Abilities are +computed at boot from the routes that actually registered, so they cannot drift from reality. + +They need no endpoint. The channel exists and carries exactly one field today: `CacheProviderExport` +has `serverProps: { basePath }`, Payload renders it server-side, `TranslateKitConfigProvider` puts it +in context, `useTranslateKitConfig()` reads it. An endpoint would be a round trip for a constant — +abilities cannot change without a redeploy. + +```ts +type TranslateKitConfig = { + basePath: string; + abilities: Abilities; + /** Facts that depend on the collection rather than the installation, by slug. */ + collections: Readonly>; +}; + +type Abilities = { + translate: boolean; // start a run and watch it + cancel: boolean; // stop one in flight + runNow: boolean; // execute a queued job now + staleness: boolean; // see drift, and dismiss the notice + translateField: boolean; // translate one field in place, with no run +}; + +type CollectionFacts = { + /** Locales this collection is translated into on save. Empty means auto-translate is off. */ + autoTranslateTargets: readonly string[]; +}; +``` + +**Flat, never keyed by module.** One locale row in the admin needs three modules at once, so keying +by module would make every component learn module names — the mistake D20 rejected one level down: an +ability must not name its implementor, or the implementor cannot be replaced. A host module's own +abilities are a separate, later problem (step 9) and arrive additively as a clearly-marked bag the +plugin's own components never read. + +Smaller choices: `translate` covers reading status, because both come from one registration and two +fields that cannot disagree are one field; `cancel` and `runNow` stay apart because step 6 (#200) +declares them separately and the sync runner answers `not_found` to run-now; every flag is always +present, since sending only the true ones reintroduces "false" versus "unknown"; auto-translate is a +list of locales rather than a flag, because a flag and a list can disagree and an empty list cannot. + +**Open:** ability and permission both mean absent (D20), but whether that server component can +evaluate the access guard per user is unverified. If it cannot, some controls appear only after a +request, and "the panel's shape is known before first paint" stops being true. + +## 5. Translation state for a page of documents + +The collection list gains a translation-status column — a virtual field with a custom `Cell`, +injected only when the host lists the component. The route behind it does not exist: +`getCollectionStatus` returns **runs** in flight and recent failures (`{id, status, collection_id, +target_lng}`), which are queue jobs, not translation state. + +**It takes a list of ids, not one.** A cell knows only its own row, so twenty-five cells would make +twenty-five requests. They must share one react-query key — collection plus the list's current query +— and each find itself in the shared answer. + +**Keep it cheap: no staleness in the list.** Per locale translated and when is one query over the +sidecar collection. *Whether it drifted* needs the source of every document on the page read and +hashed — twenty-five reads and twenty-five hashings per page turn. The column says "translated into +de, es · 3 days ago" or "not translated", which is already more than Payload's list can show. + +**Two consequences to accept rather than discover:** + +- **The column cannot be filtered on.** A virtual field has nothing in the database, so + `admin.disableListFilter: true` — an absent filter beats one that silently finds nothing. "Show me + the untranslated ones" is the first thing a person will ask on seeing the column, and 1.0 cannot + answer it. Putting the state on the document to make it filterable would be a second source of + truth for something derived. +- **The field reaches the API and the host's generated types**, always empty, because it only renders + in a cell. Payload has no "add a column without a field" slot. + +## 6. Editing a target field clears that leaf's drift + +A new `afterChange` hook on managed collections, firing when the write is **not** in the source +locale: compare `doc` with `previousDoc` per localized leaf (`computeFieldFingerprints` already does +the hashing) and record the source's current hash for every leaf that changed. + +**Why.** The receipt says "this German was made from this English". A person editing a German field +asserts exactly that, whatever they used to produce the text. The staleness marker exists to say "the +English moved, look at this" — they looked and acted. + +**It closes, from the right side, the hole this branch opened.** Protection for hand-written copy was +removed because the plugin cannot guess authorship. It returns here without guessing: the plugin does +not ask who wrote the text, it observes that the field was **edited**. A fact, not an inference. + +**Verified safe on the axis that could have made it silent corruption:** the admin's document view +reads with `fallbackLocale: false` in all four places it fetches, so an untranslated field is +genuinely empty in the form and a save never writes source text into a target locale by itself. + +Four things not to forget: + +1. **A typo fix clears real drift.** Someone corrects a comma in German while the English was + rewritten; the leaf goes current and the plugin never returns. Accepted — the field was in front + of them — but accepted deliberately. +2. **Bulk edit has the same meaning and a wider radius.** `EditMany` writes one field across many + documents in the active locale. +3. **The hook must skip the plugin's own writes**, which already carry + `translatorSkipAutoTranslate` — otherwise it races the receipt a run has just written. +4. **Which source to hash against**: the locale the leaf's record names, or the configured default + when there is no record. + +*Against D5.* `fields-not-a-strategy` D5 says provenance reports and does not narrow. This hook does +not narrow either — it **records**, which has always been provenance's other job. But it does give +the module a second write path, and that is worth naming rather than discovering. + +*A more honest variant, for later.* "Current" claims the German was *made from* this English, which +for a hand edit is a statement about intent, not mechanism. Stored instead as a per-leaf +**acknowledgement**, the admin could say "you edited this yourself" and the knowledge that the source +did move would survive. It costs a second per-leaf map and one more state on screen. + +## 7. The dismissal has to be reconciled with per-leaf answers + +`isRecordStale` consults `dismissedFingerprint`, so a dismissed locale reads as current even when its +leaves have moved. While the answer was one bit this was invisible. With per-leaf verdicts the admin +would say "this locale is fine" beside "3 fields have drifted" — the plugin arguing with itself. + +The shape in §2 keeps the verdicts truthful and carries `dismissed` as a separate fact, so the admin +says "3 fields have drifted; you asked not to be reminded". Hiding the drift instead would make the +dismissal a lie about the document rather than a note about the reminder. + +## 8. `ComponentContext` needs `addConfigModifier` + +A list column is a browser thing that requires a config change: the virtual field must be injected +into the collection. Today `ComponentContext` cannot modify config — only `ModuleContext` can. + +Either it gains the method, or a column is declared a module, which it is not. **Preferred: give it +the method**, and D7's boundary reads "a module contributes routes and collections; a component +contributes what is seen in the admin, together with the config needed to place it there". + +## Open + +- Whether `staleness` is one ability or two. Seeing drift and dismissing the notice are different + rights that happen to share a source today; one field for now, recorded as a known seam. +- Permission versus ability, per §4. +- Whether `mustInclude` is computed server-side (preferred — it needs the target's values) or the + client reads the target document itself and derives it. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-24-dismissal-writes-the-receipt.md b/packages/payload-plugin-translator/docs/plans/2026-10-24-dismissal-writes-the-receipt.md new file mode 100644 index 000000000..ef05113e1 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-24-dismissal-writes-the-receipt.md @@ -0,0 +1,106 @@ +# Dismissing the out-of-date mark writes the receipt + +**Status:** designed, not scheduled · **Owner decision:** 2026-10-24, option 1 of two +**Do after:** the findings open against `Provenance.service.ts` (see *Not this task*) + +## The defect + +A receipt holds two fingerprints of the source: + +| Field | Written by | Means | +|---|---|---| +| `sourceFingerprint` | `record` | the source this translation was made from | +| `dismissedFingerprint` | `dismiss` | the source as it stood when an editor hid the out-of-date mark | + +Three readers ask "is this locale out of date", and only one of them consults both: + +| Reader | Reads | Where | +|---|---|---| +| the admin panel's mark | both | `isRecordStale` — `core/domain/provenance/staleness.ts:38-43` | +| `changed_leaves` in the staleness response | `sourceFingerprint` only | `Provenance.service.ts:216` | +| the `stale` rule a run narrows by | `sourceFingerprint` only | `Provenance.criterion.ts:37-40` | + +So after an editor hides the mark, the lamp goes out and nothing else changes: the response still +names the field in `changed_leaves`, and a run narrowing by `stale` still re-translates it over the +text the editor chose to keep. + +`changed_leaves` was added on 2026-10-23 so the field picker can pre-tick what drifted, which is what +makes this reachable from the admin rather than only from the API. + +### Why the suite is green over it + +Both halves are covered; the pair is covered nowhere. + +- Every `changed_leaves` and `is_stale` check runs on the `record()` fixture, whose + `dismissedFingerprint` is `null` (`__tests__/Provenance.service.test.ts:55`). No dismissal exists + in any of them. +- Every `dismiss` check stops at the write — "`store.dismiss` was called with this fingerprint" + (`:264-283`), "no-op when the locale has no record". None reads back. +- No suite, unit or integration, dismisses and then asks for staleness. + +## The decision + +**Hiding the mark writes `sourceFingerprint`. `dismissedFingerprint` is deleted.** + +The receipt then states one thing — *this translation is considered current against this source* — +and the three readers cannot disagree, because there is nothing left to forget to consult. + +*Rejected: keep both fields and give the module one function that consults them.* It fixes the two +readers that are wrong today and leaves the trap set for the next reader, which is exactly how this +arrived. The second field earns nothing: outside `isRecordStale` nothing reads it, and nothing needs +to tell "translated from this source" apart from "a person accepted it against this source". + +No schema change. The column may stay in place unread and be dropped in a later pass. + +## Open — the owner's call, needed before implementation + +**What does hiding the mark mean for a leaf that was never translated?** + +A per-leaf receipt can claim `UNANSWERED` for a leaf — sent and never returned, or never sent with +no earlier claim. Writing the whole current map over it is a decision either way: + +| | Consequence | +|---|---| +| include those leaves | the mark goes out, and a leaf nobody translated is claimed current — no later run picks it up | +| exclude those leaves | the leaf keeps its `UNANSWERED` claim, so `sameFields` keeps failing and the mark cannot be turned off while one exists | + +The second needs somewhere else to report "never translated", separately from "out of date", or the +button appears broken. This is not a detail of the chosen option — the other option faces the same +question. + +## What changes + +| Piece | File | +|---|---| +| write the receipt instead of the dismissal | `src/server/modules/provenance/Provenance.service.ts` — `dismiss` | +| drop the second fingerprint from the comparison | `src/core/domain/provenance/staleness.ts` — `isRecordStale` | +| drop it from the port and the stored row | `src/core/domain/provenance/ProvenanceStore.interface.ts`, `src/server/modules/provenance/Provenance.store.ts` — `dismiss`, `toStoredData` | +| the field the collection declares | `src/server/modules/provenance/Provenance.collection.ts` | +| the receipt a fresh translation writes | `Provenance.service.ts` — `record`'s `dismissedFingerprint: null`, which retires the dismissal today and has nothing to retire after | + +## Verification + +Three checks, red before the change: + +1. hide the mark, then ask for staleness — `is_stale` is `false` **and** `changed_leaves` is empty; +2. hide the mark, then run a translation narrowing by `stale` — the field is not sent; +3. hide the mark, move the source again — both say out of date once more. + +A fourth follows from the open question above and is written once it is answered. + +Then the usual: `bunx vitest run`, `bun run check-types`, `bunx oxlint .` from the package, +`bunx turbo run build --filter='./packages/payload-plugin-translator'` from the root, and +`bun run test:integration` from `apps/dev` after a rebuild. + +## Not this task + +Open against `Provenance.service.ts` from the three audits of 2026-10-24, to be settled first: + +- one failure policy for the class instead of four — `dismiss` uses no `swallowOrThrow` and logs + nothing, so a store failure leaves it raw; +- one answer to "which leaves moved" for both `getStaleness` and the `stale` rule, instead of two + implementations; +- the service mints the HTTP response shape, which the client re-declares by hand. + +The second of those is where this task lands once it is done: with one answer, the baseline is +chosen in one place. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-24-dismissal-writes-the-receipt.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-24-dismissal-writes-the-receipt.task.md new file mode 100644 index 000000000..92198fe89 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-24-dismissal-writes-the-receipt.task.md @@ -0,0 +1,249 @@ +# Dismissing the out-of-date mark writes the receipt + +**Status:** awaiting approval · **Risk:** high · **Design:** `2026-10-24-dismissal-writes-the-receipt.md` +**Branch:** `feat/translator-fields-not-a-strategy`, alongside the uncommitted filters pass + +## Requirements / Task restatement + +A receipt holds two fingerprints of the source: `sourceFingerprint`, what the translation was made +from, and `dismissedFingerprint`, what the source looked like when an editor hid the out-of-date +mark. Three readers ask whether a locale is out of date and only one consults both, so hiding the +mark turns the lamp off and changes nothing else: the drifted-leaf list still names the field and a run +narrowing by `stale` still re-translates it over the text the editor chose to keep. + +Hiding the mark will instead write `sourceFingerprint`, and `dismissedFingerprint` goes — from the +receipt, from the port, from the sidecar collection. One fingerprint, one meaning: *this translation +is considered current against this source*. + +The design note states the defect in full, why the suite is green over it, and why the alternative +lost. **Its `## Not this task` section is spent** — every finding it defers was closed on 2026-10-24. + +## What already exists — platform · installed · project · verdict + +| Need | What already does it | +|---|---| +| write a receipt | `ProvenanceStore.upsert`, which takes exactly the shape a dismissal now produces | +| read the one the dismissal must preserve | `store.find`, which `dismiss` already calls, returning `sourceLocale` and `translatedAt` | +| decide what a leaf claims | `claimToRecord`, unchanged — a dismissal does not go through `record` | +| hash the source as it stands | `sourceAsItStands`, added this morning, already used by both `getStaleness` and `dismiss` | + +Nothing new is built. The port loses a method and a field; no file is created. + +## Decisions + +**D1 — a dismissal is a targeted write: `ProvenanceStore.rebaseSourceFingerprint(key, fingerprint)` +replaces the deleted `dismiss`, and the port keeps five methods.** + +*Taken at the Phase 2 gate and reversed in Phase 5, after review.* The gate chose `upsert`, reasoning +that a dismissal **is** writing a receipt and the method already existed. **That was wrong, and the +way it was wrong is the point:** `upsert` replaces the whole row from a snapshot taken before the +source was read and hashed. A translation finishing in that window is overwritten — its +`translatedAt` goes backwards, and every leaf it left `UNANSWERED` is re-based to the current hash, +so no later run picks those leaves up. The deleted `dismiss` was a single-column update and could +not do that. Reusing `upsert` therefore made concurrency *worse* than before the task, which no part +of the task asked for. + +A targeted write restores what was there: one column, matched on the key, update-only — so a +dismissal also cannot resurrect a receipt whose document was deleted mid-call. The name says what it +does, which the old `dismiss` no longer would. + +*Still rejected: a transaction around read-hash-write.* It would be stronger, but the race predates +this task and closing it is its own decision with its own cost; this task owes not making it worse. + +**D2 — the receipt drops `dismissedFingerprint`, and `isRecordStale` loses its second branch.** +The owner's choice on 2026-10-24, over keeping both fields behind one shared reader. The reason is in +the design note: the bug exists because there are two fields and three readers, and the rejected +option leaves that trap set for the next reader. + +**D3 — a leaf the receipt claimed unanswered is claimed current by a dismissal.** +Settled by the owner at this gate. A dismissal is an explicit statement by a person about this whole +document-locale, unlike the automatic re-base that `claimToRecord` refuses. *Rejected: leave such a +leaf unanswered.* `sameFields` compares the maps whole, so an unanswered claim never matches a hash — +the mark would stay lit after the button and the button would look broken, and nothing today can +report "never translated" apart from "out of date". +**Cost, recorded rather than discovered:** a leaf nobody translated is silently treated as done until +its source moves again. + +**D4 — the sidecar field is removed now.** +Settled by the owner at this gate, over leaving it declared and unwritten — which is the same trap +this task exists to remove. Consumers' databases keep the column until their own migration drops it; +`docs/DEPRECATIONS.md` says so. `apps/dev` must have `dev.db` reset, or Payload's push will stop on +the column-drop prompt. + +**D5 — `isRecordStale` stays a named function** although its body becomes one call. +It is the only export carrying "out of date unless the receipt describes the source as it stands", +and `describesCurrentSource` is private to `staleness.ts`. No abstraction is created; an existing one +shrinks. + +### Placement + +| Piece | Lands in | Why there | +|---|---|---| +| the receipt's shape and the port | `core/domain/provenance/ProvenanceStore.interface.ts` | where both already live | +| the staleness rule | `core/domain/provenance/staleness.ts` | `isRecordStale` is already the one place that decides | +| writing the receipt on a dismissal | `server/modules/provenance/Provenance.service.ts` | `dismiss` is already there and already reads the record | +| the stored row | `server/modules/provenance/Provenance.store.ts`, `Provenance.collection.ts` | all Payload coupling for provenance is in these two | + +### New surface — the shape check + +Triggers fired: a field is removed from a type; an exported method is removed from a port. + +**Both are internal.** `package.json` publishes `.` and `./client/*` only; `src/index.ts` exports +`TranslationProvenanceRecord`, which never carried the fingerprints. `ProvenanceStore`, +`ProvenanceReceipt` and `ProvenanceKey` reach no consumer. + +| | one fingerprint (chosen) | two fingerprints (today) | +|---|---|---| +| states the receipt admits | 3 | 9 | +| states where the readers can disagree | **0** — unrepresentable | 4 | +| cost of the next reader of "is this current" | 1 place | 2, and forgetting one is silent | +| breaks for an implementor outside this repo | nothing — the port is not published | — | + +### Written contract + +`dismiss` owes callers what it does to a leaf nobody translated, which no signature states. That is +D3, and Phase 3 writes it red-first. + +### Escalate to `/sp-architect`? + +No. One module, four files, and the design came from the owner. + +## Acceptance Criteria + +Run from `packages/payload-plugin-translator` unless stated. + +| AC | Criterion | How it is checked | Passes when | +|---|---|---|---| +| AC1 | After a dismissal the locale reads as not stale **and** names no changed leaves | `bunx vitest run src/server/modules/provenance` | green | +| AC2 | After a dismissal the `stale` rule selects nothing for that locale | same | green | +| AC3 | After a dismissal, a further source edit makes both say out of date again | same | green | +| AC4 | A dismissal does not move `translatedAt` | same | green | +| AC5 | A leaf the receipt claimed unanswered is claimed current by a dismissal (D3) | same | green | +| AC6 | Nothing reads or writes a dismissed fingerprint | `grep -rn "dismissedFingerprint\|dismissed_fingerprint" src --include="*.ts"` | no hit | +| AC7 | The sidecar declares six fields, not seven | `bunx vitest run src/server/modules/provenance/__tests__/Provenance.collection.test.ts` | green, and the check names the absence | +| AC8 | A row written before this change still reads back without throwing | same suite as AC1 | green | +| AC9 | The dismiss route still answers, and the panel goes quiet end to end | `bun run test:integration` from `apps/dev`, after a rebuild | green | +| AC10 | Nothing else moves | `bunx vitest run` · `bun run check-types` · `bunx oxlint .` · `bunx turbo run build --filter='./packages/payload-plugin-translator'` from the root | ≥188 files / ≥2494 checks, 0 type errors, ≤43 warnings and 0 errors, build green | +| AC11 | The removed column is written down for consumers | read `docs/DEPRECATIONS.md` | an entry naming the column, what happens to marks already hidden, and what a host on schema push will be asked | +| AC12 | A dismissal writes the fingerprint and nothing else, and creates nothing | `bunx vitest run src/server/modules/provenance` | green | +| AC13 | A receipt predating per-leaf fingerprints is upgraded by a dismissal | same | green | +| AC14 | A source this caller cannot read writes nothing | same | green | + +## Pre-flight — each check run against the current branch + +| AC | Result now | Classification | +|---|---|---| +| AC1, AC2, AC5 | the cases do not exist; written against the untouched code each fails — a dismissal writes the other field | **change** | +| AC3, AC4 | **invariants, not changes.** Written against the untouched code both pass: a dismissal already left `translatedAt` alone, and the mark already returned when the source moved past the dismissal. Recorded here as guards on the fix, which is what they turned out to be | **invariant** | +| AC12–AC14 | added in Phase 5 with D1's reversal; the cases do not exist | **change** | +| AC6 | 37 hits across `src` | **change** | +| AC7 | `Provenance.collection.test.ts:41` asserts the field **is** declared; seven `name:` entries in the collection | **change** | +| AC8 | passing — the store already degrades an unreadable fingerprint to `null` | **invariant** | +| AC9 | passing — 56 files / 225 checks, 8 skipped | **invariant** | +| AC10 | passing — 188 files / 2494 checks, 0 type errors, 43 warnings, build green | **invariant** | +| AC11 | no entry exists | **change** | + +## Risk notes + +**High**, for three reasons. + +1. **The stored row loses a column.** Consumers' databases keep it; nothing in the plugin reads it. + Payload's schema push will offer to drop it, which in `apps/dev` is the prompt that hangs behind + `concurrently` — `dev.db` must be reset rather than answered. +2. **A behaviour a person sees changes.** Before: hiding the mark quiets the panel only. After: it + also keeps the field out of the next run. That is the point of the task, and it is the thing to + state plainly in the release notes. +3. **D3 silences a leaf nobody translated.** Accepted deliberately; the alternative was a button that + cannot turn the mark off. Worth a line in `DEPRECATIONS.md` beside the column. + +One thing to watch: `provenance-access.int.test.ts:66` forges `dismissedFingerprint` through a cast +to `never`. That case goes with the field, and the cast goes with it — it must not be reproduced on +another column. + +## Human choices + +Settled by the owner, 2026-10-24: + +- **Hiding the mark writes the receipt; the second fingerprint is deleted.** Chosen over one shared + reader over two fields, because the trap is having two fields at all. +- **A leaf nobody translated is claimed current by a dismissal.** Chosen over excluding it, which + would leave the mark permanently lit. +- **The column goes now**, not at the next major. +- **`dismiss` stays loud** — not best-effort, unlike its siblings. Settled earlier the same day and + untouched here. + +## Review log + +### 2026-10-24 — four reviews; one of them reversed a decision this contract had already made + +High risk, so three angles ran beside the comment audit. + +| Angle | Verdict | +|---|---| +| the dismissal's behaviour | **two defects, one of them introduced by this task.** See D1 | +| the stored shape and the boot path | no defect in the code; **two defects in the deprecation entry**, and one hazard the plugin cannot prevent | +| the tests | no behaviour left unguarded, but **one tautology** and four untested cases | +| comments | 8 blocks: 3 deleted, 2 rewritten, 2 answered by a rename, 1 kept | + +**Introduced by this task, caught in review, fixed.** Writing the receipt with `upsert` replaced the +whole row from a snapshot taken before the source was read, so a translation finishing in that window +was overwritten. D1 above records the reversal. A second, narrower case went with it: `upsert` +creates when no row exists, so a dismissal racing the delete-cascade could resurrect a receipt for a +deleted document. The targeted write cannot. + +**The deprecation entry was wrong in a way the code was not.** It said no data the plugin needs is in +the column. Untrue for any host whose editors used the button: those rows hold the only record of +what was accepted, nothing reads it now, and every such mark comes back once. The entry now says so +and tells a host how to keep them. It also said a host on schema push "is offered the drop", leaving +out that the offer is an interactive data-loss prompt, that it fires whenever the table holds any +row even when the column is entirely null, and that it blocks where standard input is not a +terminal. + +**One check proved nothing.** The route's dismissal case seeded a receipt whose fingerprint already +equalled the source's, so a dismissal that wrote the record back unchanged would have passed. It now +seeds a drifted receipt. + +**Four cases nobody had written**, three of them now checked: a receipt predating per-leaf +fingerprints (AC13), a source this caller cannot read (AC14), and the adapter writing one column +only (AC12). The fourth — a leaf that disappears from the document and later returns — is left, +because it belongs to the open defect below rather than to this change. + +### Open, found here, not this task + +**`isRecordStale` and `driftedAddresses` still disagree when a leaf is removed from the source.** +(Named `leavesMovedSince` when this was written; that wrapper was removed on 2026-10-25 and its +`recordsEachLeaf` guard moved to the two call sites. The disagreement is unchanged.) +`sameFields` compares key counts, so a receipt naming `{a, b}` over a source holding `{a}` is stale; +`changedLeaves` walks the current leaves only, so it names nothing. The panel then says out of date +while the response names no field, and a run narrowing by `stale` translates nothing, so the mark +cannot clear except by a dismissal. + +This predates the task — it is the same disagreement in a different pair of readers, and the task's +own criteria do not cover it. Closing it means deciding whether a leaf the source no longer has +counts as moved, which is a behaviour decision and the owner's. Recorded rather than fixed. + +### Disagreed with a verdict, visibly + +The comment audit asked for `apps/dev/scripts/verify-provenance.ts`'s four numbered section banners +to be replaced by a `step(name, work)` helper. Kept the banners: the script is a linear checklist a +person reads top to bottom, which is the one shape where a numbered heading earns its line, and the +rewrite widens a diff in a dev script for no behavioural gain. The dangling label the same verdict +found — `ok("other fields round-trip")`, whose "other" referred to a check this task deleted — is +fixed. + +### Checks + +| | | +|---|---| +| `bunx vitest run` | 189 files / 2500 checks, green | +| `bun run check-types` (package and `apps/dev`) | 0 errors | +| `bunx oxlint .` | 43 warnings, 0 errors — the baseline | +| `bunx turbo run build` | green | +| `bun run test:integration` | 56 files / 225 checks green, 8 skipped | +| `sp-lint-delta` | `mode=two-run`, nothing introduced | +| `sp-diff-checks` | two test-integrity rows remain, both accounted for below | + +The hygiene gate's third row cleared when the adapter's three checks were added. The two that remain: +`provenance.int.test.ts` lost one assertion about a column that no longer exists, and +`chainOrder.test.ts` belongs to an earlier pass on this branch, where it guarded a field deleted +wholesale. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-24-name-what-the-signatures-hold.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-24-name-what-the-signatures-hold.task.md new file mode 100644 index 000000000..f46fe0d66 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-24-name-what-the-signatures-hold.task.md @@ -0,0 +1,254 @@ +# Name what the signatures hold + +**Status:** approved at the gate, 2026-10-24 · **Risk:** high +**Branch:** `feat/translator-fields-not-a-strategy`, alongside the uncommitted work +**Upstream:** the twelve audit runs of 2026-10-24 (notes in the session scratchpad). Those notes +predate the id scheme, so the plan gate does not apply to them; their counts are adopted as read. + +## Requirements / Task restatement + +Four audits of the translator converged on one complaint from three directions, and the owner +raised it himself against `NarrowSelection`: **a signature of `readonly string[]` does not say what +the strings are.** Three different meanings travel the plugin as bare strings — a leaf address, a +locale code, a rule name — and in several places a comment exists only to say which. + +Two smaller findings ride along: a handful of barrel exports nobody imports, and one stored column +whose type is wider than any writer. + +## What already exists — platform · installed · project · verdict + +| Need | What already does it | +|---|---| +| a branded address type | `IdPath` (`core/domain/content-projection/idPath.ts:22`), minted by `makeIdPath` and `joinRendered` | +| a named locale code | `LocaleCode` (`payload-jobs-runner/store/types.ts`), added earlier today and not yet spread | +| a home for locale utilities | `core/domain/locales/`, a namespace barrel whose own docblock names the two features that today reach into `modules/auto-translate/` instead | +| the barrel rule | `packages/payload-plugin-translator/CLAUDE.md`, "What goes into any other `index.ts`" — mechanical, no judgement needed | + +**Nothing new is built** beyond two type aliases. No dependency, no file, no seam. + +## The decisive constraint, found in Phase 1 + +**There is no way to mint an `IdPath` from a string that arrived from outside.** `makeIdPath` takes +structural segments; `joinRendered` takes already-rendered ones; `namesAPosition` only *inspects* a +string. A `leaves` filter's addresses arrive over HTTP as plain strings and are checked for shape, +never parsed back into the grammar — `idPath.ts`'s own docblock says so: "re-entering the grammar +from such an address costs a cast, and a cast is not checked." + +So branding the chain end to end needs one of two things the owner's rules push against: a cast at +the request boundary, or a new parser. That is what makes the alias the option that fits **today**, +and it is the reason this goes to the gate rather than being chosen here. + +## Decisions + +**D1 — `LeafAddress` and `RuleName` as aliases in `core/domain/selection/`, `LocaleCode` spread from +where it already is. Settled by the owner at the gate.** +*Rejected: a type the compiler checks, built on `IdPath`.* It would catch a list of locale codes +passed where addresses are wanted, which an alias cannot. It is not reachable from here: an address +arriving in a request is a plain string, and nothing can turn one into an `IdPath` — `makeIdPath` +takes parsed segments, `joinRendered` takes escaped ones, `namesAPosition` only inspects. Reaching it +needs either an assertion at the request boundary, which the owner forbids, or a parser that does not +exist yet. **Recorded as the next step, with the parser first.** +*Rejected: do nothing and keep the explaining comments.* Three audits independently filed the same +finding, and two of the comments they want deleted exist only to patch it. + +**D2 — each alias lives where its meaning is already declared, not both in one place.** +`LeafAddress` goes beside `IdPath` in `core/domain/content-projection/idPath.ts`: that file already +owns the address grammar, and `everyTranslatableLeaf` one folder over already produces address +lists. `RuleName` goes in `core/domain/selection/TranslationFilter.ts`, which is where a rule name +enters the system and the only place it is parsed. +*Rejected: both in `core/domain/selection/`* — the first shape this contract proposed. It would make +`content-projection` and `provenance` import the address type from `selection/`, although neither +has anything to do with filters, and `provenance/staleness.ts` already imports from +`content-projection`. Corrected on contact with the code, before any edit. + +**D3 — `extractLocaleCodes` and `LocalizationLike` move to `core/domain/locales/`.** +*Rejected: leave them and delete the comment that excuses the placement.* Two features that have +nothing to do with automatic translation import from `modules/auto-translate/` today; the comment is +holding a misplacement in position. The destination already exists and already names these callers. +Call sites become `Locales.knownCodes(...)`, matching `Locales.dedupe(...)` beside them. + +**D4 — the five unused barrel exports are deleted, not kept for a future caller.** `CLAUDE.md`: +"adding a name later is not a breaking change; removing one is. The cheap direction is to start +narrow." None is reachable from `src/index.ts`, so nothing outside the package can name them. + +**D5 — nothing changes. The column keeps `required: true` and the type keeps `| null`.** +*Tried and reverted in Phase 5, after review.* The first answer was to relax the column, reasoning +that the type admits `null` and the column refused it. That was wrong in a way worth recording: the +relaxation only reaches a database built by schema push. A consumer whose database came from the +committed migration still has `source_fingerprint text NOT NULL`, and after the change Payload would +*accept* a null row and the insert would then fail against the constraint — a raw driver error in +place of a validation one. Shipping it would have needed a migration this task never planned. +The asymmetry is real but harmless: the readers produce `null` (`parseSourceFingerprint` on an empty +column), the writers never do (`record` and `rebaseSourceFingerprint` both write a map). One type +serves both directions, which is the actual shape question, and it is not this task's. + +### Placement + +| Piece | Lands in | Why there | +|---|---|---| +| `LeafAddress`, `RuleName` | `core/domain/selection/TranslationFilter.ts` | where both meanings are already declared and parsed | +| `LocaleCode` | stays in `payload-jobs-runner/store/types.ts`, re-exported where used | it is already there; moving it is a second decision nobody needs | +| `knownCodes`, `LocalizationLike` | `core/domain/locales/resolveLocales.ts` | the barrel's own docblock names the callers | + +### New surface — the shape check + +Triggers fired: **an exported function gains a parameter** — no; **a field is added to a published +type** — no. None fired. Two type aliases are added and no shape changes: `type LeafAddress = string` +is assignable both ways, so no call site and no test changes. + +| | aliases (chosen) | brand (`IdPath`) end to end | +|---|---|---| +| cost of the next meaning | 1 — one more alias | 1 alias + a parser for the boundary | +| states admitted / produced | unchanged — an alias is the same type | would fall: a string could not be passed where an address is wanted | +| second caller already present | 13 signatures | the same 13 | +| breaks outside this repo | nothing — none of these is published | nothing | + +The brand wins on the second row and loses on the first, and the first is the one that is blocked +today. **Chosen: aliases now, brand recorded as the next step.** + +### Written contract + +None of the three aliases owes callers anything a signature cannot state — that is the whole point +of adding them. No `/sp-red-test` path. + +### Escalate to `/sp-architect`? + +No. Two aliases, one file move inside the package, four deletions. + +## Acceptance Criteria + +Run from `packages/payload-plugin-translator` unless stated. + +| AC | Criterion | How it is checked | Passes when | +|---|---|---|---| +| AC1 | No signature outside tests and the client carries a bare list of leaf addresses | `grep -rn "readonly string\[\]\|: string\[\]" src --include="*.ts" \| grep -v "__tests__\|\.test\.\|client/" \| grep -icE "selection\|addresses\|leaves\|vouched\|changed\|drifted\|moved"` | 0 | +| AC2 | The two look-alike helpers no longer declare the same return type | read `TranslationFilter.ts`; `grep -n "NamedBy" src/core/domain/selection/TranslationFilter.ts` | one returns `LeafAddress[]`, the other `RuleName[]` | +| AC3 | No feature imports a locale utility from the auto-translate module | `grep -rn "modules/auto-translate" src/server/features --include="*.ts"` | no hit | +| AC4 | The locale utility answers from its new home | `bunx vitest run src/core/domain/locales src/server/features/enqueue-translation src/server/features/translate-field` | green | +| AC5 | No barrel exports a name nobody imports | for each of the five, a grep for importers outside the defining file | 0 importers, 0 exports | +| AC6 | The sidecar column and the receipt type are left exactly as they were — see D5 | `git diff -- src/server/modules/provenance/Provenance.collection.ts src/core/domain/provenance/ProvenanceStore.interface.ts` for the `sourceFingerprint` lines | neither line is in the diff | +| AC8 | One shared double replaces the hand-rolled `ProvenanceStore` stubs | `grep -rln "deleteByDocument:" src --include="*.ts" \| grep -cE "test\|__tests__"` | 2 — the fixture, and the one stateful store that is not a stub | +| AC9 | Every verdict of the four comment audits has an outcome | the Phase 5 report lists each as applied or declined-with-reason | applied + declined = 199 | +| AC10 | The prose the audits called surplus is gone | count comment lines over the four audited file sets | below 913, with the per-stage split reported | +| AC7 | Nothing else moves | `bunx vitest run` · `bun run check-types` · `bunx oxlint .` · `bunx turbo run build --filter='./packages/payload-plugin-translator'` from the root · `bun run test:integration` from `apps/dev` after the build | ≥190 files / ≥2503 checks, 0 type errors, ≤43 warnings and 0 errors, build green, 56 integration files green | + +## Pre-flight — each check run against the current branch + +| AC | Result now | Classification | +|---|---|---| +| AC1 | **13 hits** | **change** | +| AC2 | both helpers declare `(filters) => string[]` | **change** | +| AC3 | 2 hits: `enqueue-translation/handler.ts:9`, `translate-field/handler.ts:6` | **change** | +| AC4 | the suites pass against the current home | **invariant** | +| AC5 | 5 exports, 0 importers each | **change** | +| AC6 | the column says `required: true`, the type says `SourceFingerprint \| null` | **withdrawn** | +| AC7 | 190 files / 2503 checks, 0 type errors, 43 warnings, build green, 56 integration files | **invariant** | +| AC8 | 10 hand-rolled doubles, no shared fixture | **change** — nine convert; `dismissalWritesTheReceipt.test.ts` keeps its own, because it is a store that *remembers* what was written and reads it back, which is the thing those checks are about. A spy with default answers cannot stand in for it | +| AC9 | 199 verdicts, 0 outcomes recorded | **change** | +| AC10 | 913 comment lines over 3014 code lines across the four sets (247/214/213/239) | **change** | + +## Risk notes + +1. **AC1 touches 13 signatures across `core/` and `server/`.** An alias is assignable in both + directions, so the compiler cannot tell a missed site from a converted one — which is why AC1 is + a count rather than a build result. +2. **The locale utility move changes an import path two features depend on**, and the destination is + a namespace barrel, so the call style changes with it. +3. **Deleting a barrel export is irreversible for a consumer who found it anyway.** None is reachable + from `src/index.ts`; that was checked rather than assumed. +4. The column change alters the sidecar's declared schema. It makes a column *less* strict, so a + schema push has nothing to destroy — unlike the removal shipped earlier today. + +## Scope — the whole list, by the owner's decision + +Taken: all six open findings. The owner's words at the gate: "Брать за раз — ВСЁ. Я не хочу +возвращаться к этому потом." So the two items this contract had proposed to defer are in: +the shared test double (AC8) and the four audits' comment verdicts (AC9, AC10). + +Still out, and still only recorded: `/field` and `/enqueue` narrow a run by two unrelated +mechanisms — open in `docs/plans/2026-10-18-the-rewiring.md`. + +## Human choices + +Settled by the owner, 2026-10-24: + +- **Names only, not a checked type.** "Давай дадим пока что только имена таким аргументам. И на этом + всё." The alternative — a type the compiler enforces — is blocked behind a parser that does not + exist; building it is the next step, not this one. +- **The whole list in one pass**, rather than the four-item scope this contract first proposed. + +**One criterion was rewritten because the chosen design cannot meet the one first written.** AC2 +originally demanded that swapping the two helpers become a type error. A plain second name for +`string` cannot deliver that — the compiler treats the alias and `string` as the same type, which is +exactly the trade the owner accepted at the gate. The criterion now asks what the design does +deliver: the two helpers stop declaring the same return type, so a reader sees the difference and a +later move to a checked type has somewhere to attach. **The compiler still permits the swap**, and +that is recorded here rather than hidden behind a weaker check that would have read as a pass. + +## Risk notes — revised after the gate + +The scope grew at the gate, and so did the risk. Beyond the four already recorded above: + +5. **The comment work touches about thirty files** that are already part of a large uncommitted + branch the owner is reading. The diff it produces is wide and almost entirely prose. Mitigation: + it goes last, after every behavioural change is green, so the two are separable by file. +6. **Replacing ten hand-rolled test doubles with one fixture** means ten test files stop declaring + what they expect of the port. If the fixture's defaults differ from a local stub's, a test can + pass for a new reason. Mitigation: the fixture takes per-method overrides, and each converted file + is run before moving to the next. + +## Review log + +### 2026-10-24 — three angles; one of them caught a regression this task introduced + +| Angle | Verdict | +|---|---| +| the type aliases | no wrong label anywhere, no import cycle, the rename complete. **Five sites left half-labelled** | +| the move, the deletions, the column, the fixture | the move and the fixture clean. **The column relaxation was a regression** | +| what the deleted prose held | **one real fact lost**, one minor; nothing stranded, nothing newly false | + +**Introduced here, caught in review, reverted.** Relaxing the sidecar column is recorded in D5 +above. The short version: it only takes effect under schema push, and in a migration-managed +database it would have turned a validation refusal into a constraint violation at insert time. + +**The labelling was half-done, which is worse than not done.** An alias is assignable both ways, so +a half-labelled path reads as deliberate: a reader seeing `LeafAddress[]` beside `readonly string[]` +assumes the second is something else. Finished at `answerTo`, `emptyBecause.everything`, `Criteria`'s +key, `FieldFingerprints`' key and `FieldChunkCollector`'s selection set. + +**A lost fact, restored as prose rather than as a test.** The chain assembly in `plugin.ts` had +carried the reason provenance must run first — the staleness route re-hashes the stored source, so a +fingerprint taken after another module replaced `sourceData` would never match. The owner's standing +rule prefers a test to a sentence, and the review suggested one; there is no seam in +`translatorPlugin` to observe the assembled chain, and inventing one to hold a comment would cost +more than it saves. The sentence is back and the reason is recorded here. `alreadyCovered` likewise +got back the clause saying a failed attempt still counts as covered. + +**Two things this task did not do, named rather than implied.** +- **A locale code is still a bare `string`** everywhere outside `payload-jobs-runner/store/types.ts`, + where `LocaleCode` was added earlier the same day. It is the third meaning the audits named and no + criterion here covers it. +- **The ~199 comment verdicts were applied in part, not in whole.** Comment lines over the four + audited file sets went 913 → ~800. What was applied: every verdict that removed a false, stale, + misplaced or duplicated statement, and the deletions where a block restated the symbol below it. + What was not: most of the shortenings of blocks that are merely long. Each would be a judgement + call on prose nobody has challenged, and the diff is already wide. + +### Checks + +| | | +|---|---| +| `bunx vitest run` | 190 files / 2503 checks, green | +| `bun run check-types` (package and `apps/dev`) | 0 errors | +| `bunx oxlint .` | 43 warnings, 0 errors — the baseline | +| `bunx turbo run build` | green | +| `bun run test:integration` | 56 files / 225 checks, 8 skipped | +| `sp-lint-delta` | `mode=two-run`, nothing introduced | +| `sp-diff-checks` | two test-integrity rows, both from earlier passes on this branch | + +### A slip worth recording + +The `filteredData` → `reconciledData` rename appeared to run and had not: `sed -i '' 's/\bfoo\b/.../'` +is a silent no-op on macOS, whose `sed` does not know `\b`. The regression sweep caught it because it +counted survivors rather than trusting the command's exit status. The rename was redone in Python and +verified by count. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-25-asking-payload-everywhere.design.md b/packages/payload-plugin-translator/docs/plans/2026-10-25-asking-payload-everywhere.design.md new file mode 100644 index 000000000..417507764 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-25-asking-payload-everywhere.design.md @@ -0,0 +1,340 @@ +## Architecture Design — one permission decision point + +### The ask (ground truth) + +Give the plugin one place that answers "may this actor perform this action on this target", so that +permission checking stops being wired into the translation feature and every entry point asks the +same way. + +Constraints that shaped it: `AccessGuard` is published and **required since 0.14.0**, and it receives +no document, no locale and no action — so it can only answer "is this a trusted caller" · the queue +worker has no `Headers`, only a requester stored on the job row · the inline path runs inside the +editor's `afterChange`, where Payload calls `killTransaction` from the catch of every operation and +`mustPropagate` treats any foreign error as having already rolled the transaction back · the package +is on `1.0.0-next.5`. + +Requirements: +- R1 (in) One contract answers the policy question, carrying actor + action + target. No entry point asks it its own way. +- R2 (in) Every entry point asks: the HTTP routes, the queue worker separately from enqueue, auto-translate, the field route. +- R3 (in) Feature modules receive a port or a wrapper, never a mechanism. +- R4 (in) Adding a new action is a change in one place. +- R5 (in) All six measured gaps are closed. +- R6 (in) The design states what becomes of `AccessGuard` and how a host migrates. +- R7 (in) The existing Payload-rule ask still works: "ask before acting, never catch `Forbidden`" weakens on no path, and the compile-time guarantee that a write cannot be sent without the rules having been asked about exactly that payload survives. +- R8 (out) The translation pipeline, the stored job row, the client half. + +**Vectors:** structure + security + contract (leading: security) — security because the whole ask is +"who may do what"; contract because `AccessGuard` is implemented outside this repo and both sides +cannot change in one commit, and because a host callback that throws inside the editor's transaction +is the sharpest failure here. + +### Decision points + +- D1 [load-bearing] The shape of the question and the type that carries it (axis: authz-placement, abstractions) +- D2 [load-bearing] Where it is asked, and what enforces that it is (axis: authz-placement) +- D3 [load-bearing] What becomes of the published `AccessGuard` (axis: versioning-compat, contract-shape) +- D4 [load-bearing] Where the port lives and where the asker lives (axis: placement, dependencies) +- D5 [load-bearing] How the non-HTTP entry points ask (axis: authz-placement) +- D6 [minor] What an actor-less request means (axis: authz-placement) +- D7 [minor] When the host's guard is slow, throws, or answers with the wrong shape (axis: failure-backpressure, trust-boundary) +- D8 [minor] The action enumeration (axis: abstractions, versioning-compat) + +### How it lands on the system + +**The finding that decides everything: the plugin does not need a policy of its own.** There are +three questions, not one. + +1. *"May this person use the translator at all?"* — only a host can answer. **The port already + exists**: `AccessGuard`, asked at `withAccessCheck` on all nine routes. It lacks only the action. +2. *"May this person act on **this document**?"* — the host already wrote it, once, in the + collection's `update` rule. Five of the six gaps are this question, unasked. + `translate-document/translationPermission.ts` already asks it correctly and serves exactly one path. +3. *"May this person act on **this job**?"* — the only residue, answerable from state the plugin + already stores (`requester_id` / `requester_collection` on the job row). A **rule**, not a port. + +**The six gaps, measured.** `POST /run/:id` runs a job by id with no ownership or document check · +`POST /cancel` and `POST /cancel-by-collection` the same · `POST /enqueue` with an explicit +`collection_id` checks neither read nor update · `POST /field` reads under the requester's read rule +and pays the provider without asking about update rights · the module seam hands a host module the +live `Payload` plus the run's target. + +**A seventh, found during adjudication:** `POST /translate/stale/dismiss` checks read access and then +**writes** — `rebaseSourceFingerprint` re-bases the receipt, so a read-only caller can mark +translations accepted across the project. + +**An escalation inside gap 1, verified in the code:** a job executes under its **stored** requester, +not the caller's. So a caller may trigger a write attributed to someone else, and — the sharp case — +may trigger a job whose requester is `null`, which runs with Payload's access checks off entirely. +No document-level question catches that. + +**Reused rather than rebuilt:** `AccessGuard` and `withAccessCheck` · `mayWrite` / `rebuildRequester` +(moved, unchanged) · the branded `Requester` and `identityOf` · `ApprovedSource` / `ApprovedWrite` as +the proven enforcement idiom · `visibleIds` (moved) · `swallowOrThrow`. + +### The design + +**Abstractions** + +- `AccessGuard` — unchanged type, in `src/types/AccessGuard.ts`. Its argument gains `action`. +- `TranslatorAction` (new, `src/types/TranslatorAction.ts`) — a closed, published union of the nine + route actions. Outcomes (`queued|delivered|failed|cancelled`) never appear in it. +- `mayWrite` / `rebuildRequester` / `RebuiltRequester` — **moved** to `src/server/shared/access/permission.ts`, unchanged. +- `mayInitiate` (new) — `mayWrite` with empty `data`: "could this person save this document at all". + Never authorises a write; the write keeps its own ask with the real payload. Six callers. +- `refusedTargets` (new) — the bulk form over a list of ids, returning the refused ones. Two callers. +- `AuthorisedWork` / `AuthorisedHandle` (new) — symbol-branded values in the existing idiom, demanded + by `TaskRunner.enqueue` / `run` / `cancel`. The auto-translate hook mints `authorisedByTheSave()`, a + named value whose docblock carries the reason, so its deliberate non-ask is visible code rather than + an absence. +- `JobAssignment` + optional `TaskRunner.assignmentsOf(handles)` (new) — `{handle, collectionSlug, + collectionId, startedBy}`, one per job. Deliberately not a widened `EnqueueAssignment`: that type is + per-locale and is handed to host lifecycle callbacks, which must not start carrying requester ids. +- `Identity` (reshaped `identityOf`) — three-valued: `named` · `nobody` · `unnameable`. +- `ModulePayload` (new) — the narrowed type of the seam's `payload`, refusing the run's own collection. + +**Dependency direction.** `src/types/` (leaf) ← `server/shared/access/` ← `server/shared/payload/` ← +`features/*` and `modules/*`. All edges downward, no cycle. The move of `visibleIds` out of +`features/_lib/` into `shared/payload/` is what keeps `shared/` from importing `features/`. + +**Placement** + +| responsibility | file | +|---|---| +| "may this caller use the translator" | `src/types/AccessGuard.ts` *(unchanged)* | +| the action vocabulary | `src/types/TranslatorAction.ts` *(new)* | +| asking the port | `src/server/shared/http/withAccessCheck.ts` *(gains the action, strict `=== true`, a throw is a refusal)* | +| asking the host's record rule | `src/server/shared/access/{permission,mayInitiate,refusedTargets}.ts` | +| proof an ask happened | `src/server/shared/access/authorised.ts` *(new)* | +| who the actor is | `src/server/shared/payload/identityOf.ts` *(reshaped)* | +| read-visibility of a list | `src/server/shared/payload/collections.ts` *(moved)* | +| job → document + `startedBy` | `modules/task-runner/TaskRunner.interface.ts` + both runners | +| the module seam's write path | `modules/translation-run/ModulePayload.shapes.ts` *(new)* | +| the write's own ask | `features/translate-document/targetDocument.ts` *(untouched)* | + +**Data flow & ownership.** The actor has one owner, the branded `Requester`, with the two mint sites +that already exist. The rule about a document is owned by the host, in `access.update`, asked through +one function; the plugin stores no copy and caches nothing. The rule about a job is owned by the +plugin: *a job may be cancelled by anyone who may initiate on its document, and run by them too, +unless it is unattributed.* + +**Security.** The decision is made on the server, per record, on every path: coarse at the edge before +body parsing, record-level inside each handler once the target is parsed, and at the queue sink via +the brand. A refused document and an absent one answer identically, so neither documents nor job +handles can be probed for existence. Revocation is caught because both asks are evaluated live and the +worker re-reads the user per job. + +**Contract.** The published surface gains one property on the guard's argument and one optional method +on `TaskRunner`; nothing is removed or narrowed, so the change is additive. The guard gets no timeout: +it is an in-process callback of the same class as Payload's own access functions, and under this +design it is never called inside a transaction, so a hanging guard costs one request. + +### Requirement coverage + +| R | Design element | +|---|---| +| R1 | `AccessGuard` for the coarse question, now asked as `check({req, action})`; `mayInitiate` / `refusedTargets` for the record question. One contract per question — a single contract is impossible because the read side of the host's rule is folded into Payload's query and cannot be fronted | +| R2 | all nine routes (coarse) + the six document-scoped handlers (record) · the worker's existing ask at `readSourceIfAllowed`, re-evaluated at run time · the hook's decided non-ask, made explicit by a named mint · the field route before the provider call | +| R3 | chain members receive `ModulePayload`; route factories receive the guard by injection; handlers import the shared asker, which is the plugin's own rule rather than a swappable policy | +| R4 | one literal in `src/types/TranslatorAction.ts` plus the new route's factory | +| R5 | gaps 1–3 `assignmentsOf` + `mayInitiate` + the unattributed-job rule · gap 4 `refusedTargets` · gap 5 `mayInitiate` before the provider · gap 6 `ModulePayload` | +| R6 | `AccessGuard` is kept, its meaning narrowed in documentation, its argument extended. No host migration | +| R7 | `translationPermission.ts` is moved, not changed; the two brands and their distinct symbols are untouched; the probe is a separate question and never mints `ApprovedWrite` | +| R8 | the pipeline, the job row and the client half are untouched | + +### Vector gates + +**security — the wrong-caller gate.** *No session:* the coarse guard decides, then every +document-scoped handler refuses an actor-less HTTP request. *A valid user who does not own the +record:* the host's `update` rule is asked per document; job routes resolve to the document and ask +the same rule. *Another tenant guessing an id:* the read side returns nothing, so the answer is 404, +identical to absent; job handles answer 404 for both "no such job" and "not yours". *Revoked a minute +ago:* both asks are live, and the worker re-reads the user before re-evaluating. Residue, named: the +coarse guard is not re-asked in the worker, so a host that revokes translator access while leaving +collection rights lets queued jobs finish. + +**contract — the other-side-misbehaves gate.** *Slow:* no timeout, deliberately; the blast radius is +one HTTP request because the guard is never on the worker path and never inside a transaction. +*Down or throwing:* a throw is a refusal plus a log, not a 500. *A shape it did not promise:* only a +literal `true` allows; today `if (!hasAccess)` would accept the string `"false"`. `assignmentsOf`'s +answer is parsed at the seam before it authorises anything. *Delivered twice:* the guard is already +called twice per user action, and that idempotency obligation becomes documented rather than assumed. + +### Decisions (ADRs) + +#### D1. No new port: one coarse question to the host, one record question to Payload + +- **Context:** the host has already written per-record rules in `access.update`. Five of the six gaps + are that question, unasked. The read side of the host's rule is folded into Payload's query and + cannot be fronted by a central policy. +- **Options considered:** + - a new `TranslationPolicy.decide({actor, action, target})` beside the guard (`least-privilege`) + - an `AccessPolicy.may(question, ctx)` replacing the guard (`centralised-policy`) + - no new port: one coarse question and one record question (`data-minimisation`) +- **Decision:** the third. The first is disqualified under **ST5** — the constraint cited for the port + is "a host may want to be stricter", which is the unverifiable future caller **ST1** excludes — and + its only in-repo implementation would delegate back to Payload. Under **SC2** a host-implemented + policy *creates* a second copy of a rule the host already wrote, and two copies of a permission rule + drift. Clears **ST1** (every new name has two in-repo callers), **SC2**, **CT1**. +- **Consequences:** the plugin commits to Payload's `docAccessOperation` as its record authority; a + host wanting a translator-specific record rule must express it in `access.update` or in the guard. + Unverified: whether any host wants such a rule — none is known, and a port can be added later + additively, which is the cheap direction. + +#### D2. Coarse at the edge, record inside the handler, and a brand at the queue sink + +- **Context:** `withAccessCheck` runs before body parsing, so it cannot see the target. A shared + function alone has no mechanical choke point; the plugin has twice proved the branded-sink pattern. +- **Options considered:** + - a route builder that is the only way to declare a route, so a route without an action does not compile + - a shared function plus one pin test per route + - pushing the owner filter into the job query itself — *not proposed by any candidate, raised at adjudication* +- **Decision:** the second, plus a brand demanded by `TaskRunner.enqueue/run/cancel` that only the ask + can mint. The third is rejected as the sole mechanism because the host's document rule cannot be + expressed as a query filter on the jobs collection; its concern (**SC3**) is answered by the brand. + The first is rejected on cost: it rewrites every handler's parse path to buy a guarantee the brand + already gives at the only sinks that have effects. +- **Consequences:** commits the plugin to brands as the enforcement idiom, a third family after + `ApprovedSource` / `ApprovedWrite`. The field route stays guarded by a call plus a pin test rather + than a type, because a brand there would have a single mint site today (**ST1**). + +#### D3. `AccessGuard` is kept; its argument gains the action + +- **Context:** published, required since 0.14.0; the ripple is three production call sites, one + implementer, about nine type consumers, about thirteen test files, three sites in `apps/`, and every + host that wrote a guard. The brief asserted the shape must change because the worker has no headers. +- **Options considered:** + - keep it untouched and add a second port beside it + - replace it, with one prerelease of deprecation and an adapter + - keep it unchanged and narrow only its documented meaning + - keep the type and extend the argument with the action — *grafted at adjudication* +- **Decision:** the fourth. The brief's premise dissolves under D1: the worker never calls the guard, + so the guard never needs a shape that works without headers. The change is therefore **additive + (CT1)**, not breaking — a host implementing `check({req})` is unaffected. Replacement is disqualified + under **CT1** because its own migration adapter leaves revocation undetected: breaking *and* weaker + has no version window that makes it safe. +- **Consequences:** one case breaks and is accepted — a host that constructs the argument itself must + add `action`. No shim, therefore no shim-removal trigger to forget. The plugin permanently forgoes + asking the host's guard on the worker path. + +#### D4. The asker moves down into `server/shared/access/` + +- **Context:** `src/types/` is not Payload-free; `src/core/` is, and is pinned by a test. The asker + must be callable from an HTTP request, a React server component and the worker. +- **Options considered:** a new port in `src/types/` plus an asker in `shared/access/` · no port, and + move the existing asker down. +- **Decision:** the second. `src/types/` gains only `TranslatorAction`. `visibleIds` and + `getAllCollectionIds` move from `features/_lib/` to `shared/payload/collections.ts`, because + otherwise `shared/` would import `features/` — an upward edge (**ST2**). Clears **ST2**, **ST3**, + **ST6**. +- **Consequences:** two pure moves land before any behaviour change, which is what makes the rest of + the sequence incremental. + +#### D5. The worker asks Payload; the hook asks nothing; jobs resolve to documents + +- **Context:** three paths — the worker (no request, a stored requester, runs later), the hook (inside + the editor's transaction), the module seam. `mustPropagate` returns `true` for any foreign error. +- **Options considered:** + - the worker and the hook both ask a policy, swallowing refusals + - the worker asks Payload with the stored requester; the hook asks nothing + - for the job question: `startedBy` on a job target · a new `describeJob` · an optional `assignmentsOf` +- **Decision:** the second, with the job variants merged into an optional `assignmentsOf` returning a + `JobAssignment` that carries `startedBy`. The worker's existing ask at `readSourceIfAllowed` **is** + the ask — with the real data, at the real target locale, against a freshly read user, before any + spend, and it adds no round trip. The hook asks nothing: a host callback that throws there destroys + the editor's save, and a refusal would only pre-empt a job the worker refuses anyway before paying. + `run/:id` additionally refuses a job whose `startedBy` is null. +- **Superseded 2026-10-26 — `assignmentsOf` is required, not optional.** Optionality was chosen to + keep step 6 non-breaking; the branch acquired two breaking commits regardless, so it bought only a + worse failure — a project that builds while run and cancel answer `403`. See + `2026-10-25-jobs-resolve-to-documents.task.md`, *Amended 2026-10-26*. Read every "optional + `assignmentsOf`" in this document, and in the build sequence below, as "required". +- **Consequences:** `TaskRunner` gains a required method; a runner that omits it does not compile. + **SC6 residue, named:** a run triggered by A on B's job is still attributed to B; closing that + properly means a stored-row change, which is out of scope. An interim log line names both. + +#### D6. Absence is trusted only when it was absence all along + +- **Context:** `identityOf` today downgrades a user to unattributed, with a warning, when a project has + several auth collections and the user carries no `collection` — and an unattributed request is fully + trusted. The in-process trust is deliberate and pinned by an integration test. +- **Options considered:** fail closed everywhere with an escape flag · the host decides · refuse on + HTTP but keep in-process trust. +- **Decision:** a three-valued `Identity` — named, nobody, unnameable — and the rule above. HTTP with + no user is refused at the record ask. In-process with no user stays trusted, so the pinned test stays + green. A request carrying a user the plugin cannot name is **refused**, not downgraded. +- **Consequences:** the silent privilege escalation — a real user becoming a trusted anonymous writer — + is removed. No configuration flag is introduced, so there is none to forget. + +#### D7. A throw is a refusal; only `true` allows; no timeout + +- **Context:** `withAccessCheck(withErrorHandler(handler), access)` puts the error handler *inside* the + guard, so a throwing guard yields a 500, and `if (!hasAccess)` accepts any truthy value. +- **Options considered:** bound the host call with a timeout and a fallback · fail closed on throw, + strict `true`, no timeout. +- **Decision:** the second. No timeout, because the guard is an in-process callback of the same class + as Payload's own access functions, and under D5 it is never inside a transaction (**CT6**). +- **Consequences:** a host whose guard throws now sees 403 instead of 500 — strictly safer, and worth a + release note. A pathological guard can hang one HTTP request. + +#### D8. The action union rides the coarse question only + +- **Context:** R1 asks that the question carry an action; R4 asks that a new action be one change. +- **Options considered:** + - an eleven-action union with a target-correlation table driving the record question + - the same union inside a replaced port + - no union at all: "there is one question" + - a closed union carried by the coarse question only — *grafted at adjudication* +- **Decision:** the fourth. The record question does not branch on the action — every document-scoped + action asks the same thing — and giving that function a parameter it never reads would break the rule + that a signature claims only what the answer depends on. The coarse question genuinely can branch: it + is the only place a host can say "editors may enqueue but may not cancel other people's bulk jobs". +- **Consequences:** the union is plugin-owned and closed; a host switching on it must keep a default + branch, because it grows additively (**CT1**). + +### Build sequence + +1. Move the permission asker down to `shared/access/permission.ts` — no behaviour change; the test moves with it · covers: R7 · via /sp-task · incremental +2. Move `visibleIds` and `getAllCollectionIds` to `shared/payload/collections.ts` — removes the only `shared → features` edge the next steps would need · covers: R7 · via /sp-task · incremental +3. The field route asks: `mayInitiate` after parse, before the source read and before the provider · covers: R2, R5 · via /sp-task · incremental +4. The action reaches the guard: `TranslatorAction`, `withAccessCheck(handler, access, action)`, strict `=== true`, a throw becomes a refusal; nine routes and two widgets pass their literal · covers: R1, R4, R6 · via /sp-task · incremental +5. Enqueue asks per document: `refusedTargets` for the explicit-id path; `select_all` keeps its read filter · covers: R2, R5 · via /sp-task · incremental +6. Jobs resolve to documents: `JobAssignment` + optional `assignmentsOf` on both runners; run, cancel and cancel-by-collection ask; `run` refuses an unattributed job · covers: R2, R5 · via /sp-task · incremental +7. The brand at the sink: `AuthorisedWork` / `AuthorisedHandle` demanded by the runner's three methods, minted by the four handlers and by the hook as `authorisedByTheSave()` · covers: R2 · via /sp-task · incremental +8. Identity stops downgrading: the three-valued `Identity`; HTTP refuses `unnameable`; the hook skips it with a warning; the no-user case is untouched · covers: R2 · via /sp-task · incremental +9. The module seam: `ModulePayload` refuses the run's own target collection; provenance needs no change · covers: R3, R5 · via /sp-task · **big-bang for host chain members**, with a release note +10. Pins and docs: one pin per route for both asks, the field-route spend pin, `@since` on the guard's `action`, the idempotency obligation in its docblock · covers: R1, R6 · via /sp-task · incremental + +### Open questions / unresolved + +- **A seventh gap the framing never asked about.** `POST /translate/stale/dismiss` checks read access + and then writes: `rebaseSourceFingerprint` re-bases the receipt, so a read-only caller can mark + translations accepted across the project. It is closed by the same `mayInitiate` call as the others. + **Recommend folding it into step 3**; reported rather than silently added because it was out of scope. +- **`run/:id` attribution.** The decided rule authorises by document and refuses unattributed jobs, but + a run triggered by A on B's attributed job is still recorded as B's. Closing it properly means + re-attributing the job (a stored-row change, out of scope) or splitting "who queued" from "who ran" + in the receipt. Deferred with a log line. Blocks nothing. +- **Unverified:** whether any host wants a record rule that is translator-specific rather than + collection-specific. None is known. If one appears, a port can be added additively later. + +### Accepted deviations + +- **SC1, "on every path", for in-process unattributed callers.** A save or local-API call carrying no + user still produces a job that runs with Payload's checks off. Permitted by the pinned decision in + `apps/dev/src/integration/translator/access-control-unattributed.int.test.ts` ("It was trusted before + this change and stays trusted"). Cost: a host whose server-side code is compromised can translate + into collections its users may not write. What would force revisiting: a caller reachable from + outside the host's process without a user, or a host asking for a strict mode. +- **ST2, the pre-existing `modules/provenance → features/staleness` edge.** Untouched by this work. + What would force revisiting: a second module needing a feature, or the staleness routes moving. + +### Rejected / grafted (for the record) + +- A `TranslationPolicy` beside the guard — disqualified: **ST5**, and **ST1** (its only verifiable implementation delegates to Payload). +- `OwnCollection` + `reserveCollection`, and `ModuleStore` + `ownedCollections` — disqualified: **ST1**, one in-repo caller each plus a hypothetical host module. +- Fail-closed unattributed with an escape flag — disqualified: it contradicts a pinned, deliberate decision; the flag is the admission. +- Replacing `AccessGuard` — disqualified: **CT1**, breaking a required published type with an adapter that weakens revocation detection. +- Asking the host policy inside `afterChange` — disqualified: a foreign throw propagates and destroys the editor's save. +- Routing the field route through `openTargetDocument` — disqualified: **ST2**, a sideways feature-to-feature edge, plus a question about data the route does not yet have. +- **Grafted:** the job question carrying `startedBy` · "a user that cannot be named is a denial" · the action union, narrowed to the coarse question · "a route cannot be registered without an action", in spirit, as the brand at the sink. +- **Replaced:** `assignmentsOf` returning `EnqueueAssignment` — that type is per-locale and reaches host lifecycle callbacks, which must not start carrying requester ids (**SC4**). diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-25-enqueue-asks-per-document.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-25-enqueue-asks-per-document.task.md new file mode 100644 index 000000000..9679c6f19 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-25-enqueue-asks-per-document.task.md @@ -0,0 +1,280 @@ +Build step: 2026-10-25-asking-payload-everywhere.design.md #5 + +## Task restatement + +`POST /translate/enqueue` takes a list of document ids and queues a translation for each. Nobody +asks, per document, whether the caller may change it. That is **gap 4** of the six measured. + +This step asks the host's `update` rule about each named id, drops the refused ones, and says which +they were. Covers **R2** (every entry point asks) and **R5** (the six gaps close). + +Out of scope by the design's own sequence: jobs resolving to documents (#6), the brand at the queue +sink (#7), the three-valued identity (#8), the module seam (#9), pins and `@since` (#10). + +## Measured, not assumed + +| Claim | Measured | +|---|---| +| the explicit-id path is unchecked | `handler.ts` passes `collection_id` straight into the task list; the only filter on the other branch is `getAllCollectionIds`, which is read-only | +| `select_all` is already read-filtered | `getAllCollectionIds` → `payload.find({ ...enforcedAtTheRead(user) })` | +| the client would see a refusal | **no** — `useQueueDocumentTranslation` types the response `{ data: null }` and reads nothing from the body | +| the integration suites read the body | **yes** — seven of them read `queued` or `assignments` | +| the worker refuses before spending | **yes** — `translateDocument.ts:40` `readSourceIfAllowed()` precedes `translateOrWrap` at `:54` | +| a refused caller already gets a silent empty answer | **pinned** — `read-access-reports.int.test.ts`: `select_all` for a caller who may read nothing answers `queued: 0` and success, not an error | + +## What already exists — platform · installed · project · verdict + +| Rung | Found | Verdict | +|---|---|---| +| platform | Payload folds a **read** rule into the query (`collections/operations/find.js`) | that is why `select_all` costs one query. The **update** rule cannot be folded — `docAccessOperation` answers per document | +| project | `mayInitiate` (`shared/access/permission.ts`, step 3) | **the mechanism**, unchanged | +| project | `visibleIds` (`shared/payload/collections.ts`, step 2) | **the read-side twin** of what this step adds: same arguments, same return-a-set shape. The new function copies its signature deliberately | +| project | `ServerResponse.success` with a conditional `assignments` key | the precedent for reporting an optional extra without changing the ordinary body | + +Nothing new is installed. + +## Decisions + +### D1 — a refused document is dropped, not a reason to fail the request + +**Chosen:** queue what the caller may change, skip what they may not, and name the skipped ones in +the response. + +**Rejected — `403` for the whole request when any id is refused.** A bulk selection of fifty with two +refused would lose the forty-eight the caller is entitled to. + +**Constraint cited:** the project already answered this question once and pinned the answer. +`read-access-reports.int.test.ts` asserts that `select_all` for a caller who may read nothing returns +`queued: 0` **and success**. Failing the explicit path would be a second, contradictory answer to the +same question on the same route. + +### D2 — `refusedTargets` is written now, with one caller + +The design names it with two callers. **Only one exists today** — this handler. Step 6 is the second +and is not in the codebase yet, so under the owner's rule this does not yet earn an abstraction. + +**Chosen anyway, and here is what carries it:** `visibleIds` already sits in the shared layer as the +read-side form of exactly this question, with exactly this signature. The update-side twin belongs +beside it rather than inlined in one handler, and placing it there is what lets step 6 ask the same +question without copying a loop. Recorded as a deliberate exception, not an oversight. + +**Rejected — inline the loop in the handler.** It would put a permission loop inside a feature, which +is the shape steps 1 and 2 moved out of the features layer on purpose. + +### D3 — the refused ids ride the existing success body + +**Chosen:** `refused` appears in the response **only when non-empty**, like `assignments`. + +**Rejected — always include it.** Every ordinary request's body would change for no reader: the +client types the response `{ data: null }` and reads nothing. + +### D4 — `select_all` keeps its read filter and gains no per-document ask + +Adopted from the design, and the cost is why: the update rule cannot be folded into a query, so +asking it under `select_all` would be one `docAccessOperation` — each of which loads the document — +per document in the collection. + +**The backstop, verified rather than assumed:** the worker asks again with the real data before it +spends. `readSourceIfAllowed()` runs at `translateDocument.ts:40`, `translateOrWrap` at `:54`. So a +document that slips through `select_all` costs a queue row, not a provider call. + +**The asymmetry is real and recorded:** a caller who passes explicit ids is told what was refused; a +caller who passes `select_all` is not. Both end in the same place. + +### D5 — one question per document, not one per document and locale + +A request fans out to `document × locale` tasks, and `mayInitiate` takes a `targetLocale`, so asking +once per task looked like the honest reading. **It is not, and the owner caught it.** + +Payload's `Access` function receives `{ collectionConfig, data, id, isReadingStaticFile, req }` +(`payload/dist/config/types.d.ts:232`) — **no locale argument, and no concept of per-locale +permissions.** Rights are granted on a document. The locale reaches a rule only as ambient +`req.locale`, and its only effect is which language copy Payload loads into `data`. + +Sharper still: `mayInitiate` asks with **empty `data`** on purpose, and its own docblock says a rule +branching on the payload answers a different question there. Splitting by locale would be precision +laid over a probe that is deliberately approximate. + +**Chosen:** one question per document. `refusedTargets` takes ids, as the design said. + +**Rejected — one question per `(document, locale)` task.** It multiplies the cost by the number of +target locales to cover a case Payload does not model, and the answer could only differ for a host +that hand-reads `req.locale`. + +**Which locale is passed is immaterial** for the reason above; the request's first resolved target is +used, so the call reads the same as the two single-document callers. + +### D6 — the per-document cost of the explicit path + +N named ids means N permission evaluations, each loading its document. Bounded by what the caller +listed, which the admin dashboard fills from a page of selected rows. No cap is added: a cap would be +a silent truncation, which is the failure D1 just rejected. Recorded so a later step can measure it +rather than discover it. + +### D7 — shape check + +Triggers that fired: a function gains a response field (`refused`), and a new exported function is +added. Not fired: no published type changes, no parameter added to an exported function. + +| | `refused: string[]` on the body *(chosen)* | a per-id result list `[{id, queued|refused}]` | +|---|---|---| +| cost of the next outcome of this kind | one more optional key | one more member of a union every reader must handle | +| states admitted vs produced | ids that were named and refused — 0 gap | admits "queued and refused", which cannot happen | +| second caller already in the repo | the integration suites read the body | same | +| breaks outside this repo | nothing: the key is additive and absent when empty | every reader of `queued` would have to be rewritten | + +**Chosen:** the first. The second describes a richer answer nobody asked for and makes `queued` +redundant. + +## Acceptance Criteria + +| # | Covers | Criterion | How it is checked (binding) | +|---|---|---|---| +| AC1 | R5 | A named id the `update` rule refuses is not queued | a case in `enqueue-translation/__tests__/` asserting the task list passed to `runner.enqueue` excludes it | +| AC2 | R5 | The other named ids in the same request **are** queued | the same case asserts the allowed id is still present — a refusal is not a blanket one | +| AC3 | R2 | The response names the refused ids | the same case reads `refused` from the body | +| AC4 | R5 | An ordinary request's body is unchanged — no `refused` key when nothing was refused | an existing success case still deep-equals what it asserted before | +| AC5 | R2 | The ask happens before `runner.enqueue`, so a refused document never reaches the queue | **mutation**: move the filter after the `enqueue` call and AC1 must go red | +| AC6 | R5 | `select_all` is unchanged — still read-filtered, still no per-document update ask | a case asserting `refusedTargets` was **not called**; **mutation**: make `select_all` ask and it must go red. *(Strengthened mid-build: the original check — "the existing cases stay green" — was run as a mutation and stayed green, so it guarded nothing. Those cases pass no user, and a null user is allowed without reaching Payload.)* | +| AC7 | R2 | An actor-less enqueue behaves exactly as it does today | the three integration suites that enqueue with no user stay green | +| AC8 | R5 | The baseline holds | real `turbo build` green; integration **225 passed / 8 skipped** | + +## Pre-flight — each check run against the CURRENT tree + +| # | Polarity | Result now | Evidence | +|---|---|---|---| +| AC1 | must FAIL | **fails** | `grep refusedTargets src` → no matches; the handler passes `collection_id` through untouched | +| AC2 | must FAIL | **fails** | same case does not exist | +| AC3 | must FAIL | **fails** | the body has no `refused` key | +| AC4 | must PASS | **passes** | the success cases pass today | +| AC5 | must FAIL | **fails** | there is no filter to move | +| AC6 | must PASS | **passes** | `select_all` cases and the integration suite pass today | +| AC7 | must PASS | **passes** | those suites pass today | +| AC8 | must PASS | **passes** | 196 files / 2523 checks, build green, 225/8 | + +## Risk notes + +**High risk.** This changes who may start a translation, and a translation spends money. + +- **The silent-drop hazard.** D1 drops refused ids. If the response did not name them, a caller would + believe work was queued that never was. AC3 is the guard, and it is why `refused` exists at all. +- **The cost of N evaluations** (D5) is the thing most likely to surprise someone later. +- **The asymmetry with `select_all`** (D4) is the thing most likely to be re-raised in review. It is + settled by the design and backstopped by the worker; the review log should say so once. +- A test is required before done, the regression sweep is evidenced, and Phase 5b runs several angles. + +## Criteria results + +| # | Outcome | Evidence, by the check the criterion named | +|---|---|---| +| AC1 | met | `handler.access.test.ts` — "does not queue a document the update rule refuses"; mutation M1 (drop the filter) reddens it | +| AC2 | met | same file — "still queues the documents in the same request that it allows"; M1 reddens it | +| AC3 | met | same file — "names the refused documents in the answer"; mutation M2 (drop the `refused` key) reddens it alone | +| AC4 | met | same file — "says nothing about refusals when there were none"; green before and after, by design | +| AC5 | met | M1 is the ordering proof: with the answer unused, three checks go red | +| AC6 | met | "asks nothing per document when the caller said `select_all`"; mutation M3 reddens it alone. **The criterion was strengthened mid-build** — its original check stayed green under M3 and so guarded nothing | +| AC7 | met | 39 integration files enqueue without a user; all unchanged and green | +| AC8 | met | real `turbo build` green (forced, uncached); integration **225 passed / 8 skipped**, unchanged | + +All three mutations were re-run after `/sp-review-comments` merged the doubled `select_all` branch, +and each still reddens exactly its own check. + +## Human choices + +**2026-10-25.** The owner directed an uninterrupted run through steps 5–10, stopping only at a +genuine fork. No fork arose here: D1 was settled by the pinned integration test, D4 by the design, +and D2 is recorded as a deliberate exception rather than a question. Commit authority for these steps +was granted in advance; no push, no pull request. + +## Review log + +**2026-10-25 — adversarial review of the guarantee.** One angle, asked to find any way to queue a +named id the `update` rule refuses. **No bypass found.** `runner.enqueue` is reachable from exactly +two places in `src` — this route and the auto-translate hook — and in this handler the ids reach the +task list only through `documentsToQueue`. Id coercion was checked for a mismatch between the id that +is *asked about* and the id that is *filtered*: there is none, because both read the same string. + +One finding fixed here: **`collection_id: [""]` passed the schema.** Only the array carried +`.nonempty()`, not its elements. Payload's `getEntityPermissions` sets `fetchData: id ? true : false`, +so an empty id **skips the document load entirely** and the rule is asked about nothing — a rule that +grants by role would say yes, and `""` would be queued. Confirmed by probe, then fixed with +`.nonempty()` on the element, red-first and mutation-proven. + +Four caveats, none a bypass, all recorded rather than changed: + +- **`select_all: true` with a named list ignores the list** and queues everything readable — identical + to the behaviour before this step, so not a widening. The worker is the backstop, verified again: + `readSourceIfAllowed` (`targetDocument.ts:121-147`) throws `TranslationRefused` before + `translateOrWrap`. The difference is only in reporting — there a refusal surfaces as a failed task, + not under `refused`. +- **A duplicate id** is evaluated once per occurrence and reported once (`refused` is a `Set`); a + permitted duplicate is queued twice. Neither old nor new code de-duplicates. +- **A nonexistent id** is queued, because `absenceIsNotARefusal` (step 3) deliberately does not read + absence as a refusal. The worker's own read reports it. +- **`docAccessOperation` evaluates every operation's rule, not just `update`.** A host rule for + `create`, `read`, `delete`, `readVersions` or `unlock` that throws on empty `data` fails the whole + request with a 500 rather than refusing one id. Same on every route that already uses `mayInitiate`. + +**2026-10-25 — `/sp-complexity`.** Neighbourhood 7 files, 20 commits. Both scenarios confirmed by +history: "a route starts asking about a document" (3 occurrences) and "the enqueue answer changes +shape" (3). One fix applied and then **reverted** in the same run: `targets[0] ?? ""` was replaced by +a destructure-and-check so an unreachable empty locale would stop existing in the type — and then the +abstraction audit removed the locale parameter entirely, which took the reason away. Recorded because +a reverted fix is still a fact about the code. For the scenario "add a fourth place that asks about a +document": CHANGE 1, READ 3, STATE 4 — nothing to propose. + +**2026-10-25 — `/sp-abstraction`** on `refusedTargets`. Audited by the author. Two findings applied, +one recorded. + +- **Position, not name, bound two string parameters.** Proven by probe: a call with `collection` and + `targetLocale` swapped **compiled silently**, because `CollectionSlug` resolves to `string` when the + host has no generated types — which is every plugin consumer. Fixed: the signature takes one named + object. Interchangeable string parameters 2 → 0. +- **`targetLocale` demanded what the answer does not depend on**, and the docblock never mentioned it, + so a caller could not tell whether it mattered. Removed; `refusedTargets` now names the project's + default locale itself, with the reason in one line. Parameters 5 → 4 (and the caller stops passing a + value it had to invent). +- **Recorded, not fixed:** the enqueue response body is typed `unknown` + (`ServerResponse.success(data: unknown = null)`), so the new `refused` key is invisible to the + type-checker and to every consumer. It exists only because a test asserts it. This is a boundary the + author's own brief had omitted. + +**2026-10-25 — `/sp-review-comments`.** 3 candidate blocks, 3 verdicts: 1 REWRITE-COMMENT, +1 REWRITE-CODE, 1 DELETE. Density on the tracked diff was **2.7 inline comment lines per 10 lines of +code** — judged too dense, and both offending blocks are gone. + +The substantive half was the `refusedTargets` docblock, which stated **three things that were not +true**. Two were caught by the mechanical check before the auditor saw them; the third was the +auditor's: + +1. *"this probe hides that anyway by asking with empty data"* — the reverse. `getEntityPermissions` + treats `{}` as "no data supplied", so Payload **fetches the document in `req.locale`** and hands it + to the rule. Empty data is what *causes* the locale-specific read. +2. *"Payload's `Access` function receives no locale"* — overclaimed. `AccessArgs` carries no locale + field, but it carries `req`, and `createLocalReq` sets `req.locale`. Only "no per-locale permission + entries" is true, which is what D5 actually rests on. +3. *"No locale is asked for"* — **the docblock contradicted its own function body**, which passes the + project's default locale. + +The REWRITE-CODE verdict found a split predicate the comment was covering for: the handler branched +on `select_all` **twice**, once to produce an empty `refused` placeholder and once to pick the ids. +Merged into one `documentsToQueue` with an early return per mode, so each mode shows its own gate and +the comment has nothing left to do. The three mutations were re-run against the new shape and each +still reddens exactly its own check. + +**Recorded, not fixed:** `refusedTargets` runs `Promise.all` over the ids with no concurrency limit, +and each evaluation does its own `findByID`. A long named list produces that many concurrent reads. +D6 counted the evaluations but not their concurrency. + +**2026-10-25 — `/sp-red-test` in audit mode** over the enqueue and access suites. Scaffolding deleted +and `git status` shown. + +- Stubbing `refusedTargets`: **11 of the 19** pre-existing `handler.test.ts` cases went red, so they + do reach the new path. Stubbing the whole permission module: 70 red across the package. +- **No toothless check found.** Everything that stayed green is green honestly: three files mock the + unit themselves (their own seam is what they test), and `targetDocument.contract.test.ts` imports + only types. +- **Finding, and it belongs to step 8:** those 11 cases now pass **because of the "no user means + allowed" rule.** Their Payload doubles carry no collections and no document read, so a permission + ask that reached Payload would throw — as `handler.identity.test.ts` did until it was isolated. When + step 8 makes identity three-valued, expect those 11 to need a user. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-25-identity-stops-downgrading.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-25-identity-stops-downgrading.task.md new file mode 100644 index 000000000..b351119b7 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-25-identity-stops-downgrading.task.md @@ -0,0 +1,222 @@ +Build step: 2026-10-25-asking-payload-everywhere.design.md #8 + +## Task restatement + +`identityOf` answers `{ requester: Requester | null }`, and that `null` carries **three** different +situations: + +1. **nobody** — the request had no user. Deliberately trusted, and stays so (**SC1**). +2. **a user with no id** — unnameable. +3. **a user with no `collection`, in a project with more than one auth-enabled collection** — + unnameable. The code already knows this is a downgrade: it logs *"It will be written without a + permission check, as an unattributed one is"* and proceeds. + +Cases 2 and 3 get the trust that was granted to case 1. This step separates them: `Identity` becomes +three-valued, the HTTP route refuses `unnameable`, and the auto-translate hook skips it rather than +enqueueing work nobody can be held to. + +Covers **R2**. Out of scope: the module seam (#9), pins and `@since` (#10). + +## Measured, not assumed + +| Claim | Measured | +|---|---| +| `null` covers three cases | `identityOf.ts` — `!user \|\| user.id == null` (two of them) and the multi-collection fallthrough | +| the existing warning will become false | it says the translation *"will be written without a permission check"*; after this step it will not be written at all | +| two production callers | `enqueue-translation/handler.ts:108` and `AutoTranslateEnqueue.hook.ts:82`. No others | +| **Payload's own auth cannot produce an unnameable actor on HTTP** | `payload/dist/auth/strategies/jwt.js:55` — `user.collection = collection.config.slug`. The built-in strategy always stamps it | +| so who can? | a host's **custom auth strategy** returning a user without `collection`, and a **local-API** caller passing a hand-made `user`. Both are real, neither is in this repo | +| the branch is already covered by tests | `identityOf.test.ts` has a case for each of the three, asserting `{ requester: null }` today | +| step 6's prediction about 11 enqueue cases | **does not apply here.** That was about `mayInitiate` trusting a null *user*; this step changes `identityOf`, and those cases carry no user at all, which stays `nobody` | + +## What already exists — platform · installed · project · verdict + +| Rung | Found | Verdict | +|---|---|---| +| platform | Payload's `TypedUser` and its JWT strategy | **the reason the HTTP case is narrow** — the built-in path always names the collection | +| project | `identityOf` | the one place that reshapes; it is edited, not replaced | +| project | `Requester` / `asRequester` (`RequestScope.shapes.ts`) | unchanged — a named identity still produces exactly what it produced | +| project | `RequestScope` | unchanged; what changes is **who may build one** | + +Nothing new is installed. + +## Decisions + +### D1 — a three-valued union, not a flag beside the requester + +**Chosen:** `Identity = { kind: "named"; requester } | { kind: "nobody" } | { kind: "unnameable" }`. + +**Rejected — `{ requester: Requester | null; unnameable: boolean }`.** Two fields that must agree: +it admits `{ requester: , unnameable: true }`, which the system never produces. States +admitted 4, produced 3 — a gap of 1, and the gap is exactly the confusion this step exists to remove. + +### D2 — a scope cannot be built from an unnameable identity + +**Chosen:** `scopeOf(identity: Exclude): RequestScope`. A caller +must narrow before it can reach a scope. + +**Rejected — `scopeOf(identity)` taking all three and returning `{}` for the unnameable one.** That +is the present bug with a new name: it would silently hand back the scope of *nobody*. + +**Constraint cited:** this is the same move step 7 made at the queue — make the wrong call fail to +compile rather than documenting that it is wrong. + +### D3 — `identityOf` keeps the diagnosis, callers own the consequence + +The warning stays where the detail is (which branch, and how to fix it), but its **text changes**: it +currently promises the work proceeds unchecked, which stops being true. The hook adds one line saying +it skipped; the route needs none, because a `403` is the signal. + +**Rejected — move the message to the callers.** It would be written twice, and neither caller knows +which of the two unnameable branches fired. + +### D4 — what the HTTP route answers + +**Chosen:** `403`, the same as any other refusal — a caller who cannot be named cannot be told apart +from one who is refused, which is the answer this plugin already gives everywhere else. + +**Rejected — `400`.** It would say "your request is malformed", which is a different claim and leaks +that the actor was the problem. + +### D5 — the no-user case is untouched + +Verified, not assumed: `nobody` keeps producing `{}` and every path that trusted it still does. +`access-control-unattributed.int.test.ts` is the pin and must stay green **unchanged**. + +### D6 — shape check + +Triggers: a function's answer gains states; two values (`requester` and "was it nameable") would +otherwise travel together. + +| | three-valued union *(chosen)* | `{ requester, unnameable }` | +|---|---|---| +| cost of the next kind | one member + the 2 exhaustive call sites | one more boolean, and every combination to reason about | +| states admitted vs produced | 3 / 3 — gap **0** | 4 / 3 — gap **1** | +| second caller in the repo | the enqueue route and the hook | same | +| breaks outside this repo | nothing — `identityOf` is not exported from `src/index.ts` | same | + +## Acceptance Criteria + +| # | Covers | Criterion | How it is checked (binding) | +|---|---|---|---| +| AC1 | R2 | `identityOf` answers `unnameable` for a user with an id and no `collection` when the project has more than one auth-enabled collection | a case in `identityOf.test.ts` | +| AC2 | R2 | …and for a user object with no id | a case in the same file | +| AC3 | R2 | …and `nobody` when there is no user at all — distinct from both | a case asserting the kind, not just the absence of a requester | +| AC4 | R2 | `POST /translate/enqueue` answers `403` for an unnameable actor and queues nothing | a case in `enqueue-translation/__tests__/` asserting `runner.enqueue` was not called | +| AC5 | R2 | The auto-translate hook enqueues nothing for an unnameable actor, and warns | a case in the hook's suite | +| AC6 | R2 | A scope cannot be built from an unnameable identity | a `@ts-expect-error` pin; `check-types` is the check | +| AC7 | R2 | The no-user case is unchanged | `access-control-unattributed.int.test.ts` stays green **and unmodified** | +| AC8 | R5 | The baseline holds | real `turbo build` green; integration **225 passed / 8 skipped** | + +## Pre-flight — against the CURRENT tree + +| # | Polarity | Now | Evidence | +|---|---|---|---| +| AC1–AC3 | must FAIL | **fail** | `identityOf` returns `{ requester: null }` for all three; `grep Identity src/server/shared/payload/identityOf.ts` finds no union | +| AC4–AC6 | must FAIL | **fail** | neither caller branches on it; there is no `scopeOf` | +| AC7, AC8 | must PASS | **pass** | 202 files / 2559 checks, build green, 225/8 | + +## Risk notes + +**High risk — it changes who is trusted**, in both directions. + +- **The narrowness is the thing to be honest about.** Payload's own auth always stamps the collection, + so this guards a case the plugin cannot produce by itself. It is reachable through a host's custom + auth strategy or a local-API caller passing a hand-made user — real, but not exercised here. +- **The opposite error is worse than the one being fixed:** treating *nobody* as unnameable would + refuse every legitimate in-process caller. AC7 is the guard, and it must pass **without the + integration test being edited**. +- **A message that is about to become false** is being changed, not deleted; a reader who has seen the + old log line will otherwise believe the old behaviour. +- A test is required before done; the regression sweep names what was grepped; Phase 5b runs several + angles. + +## Human choices + +**2026-10-25.** Uninterrupted run through steps 5–10, stopping only at a genuine fork. None arose: +D1, D2 and D5 follow decisions already recorded (the design's D6, SC1, and step 7's "make it not +compile"), and D4 follows the answer this plugin gives on every other refusal. Commit authority +granted in advance; no push, no pull request. + +## Criteria results + +| # | Outcome | Evidence, by the check the criterion named | +|---|---|---| +| AC1–AC3 | met | 6 checks in `identityOf.kinds.test.ts`, all three kinds told apart | +| AC4 | met | `handler.unnameable.test.ts` — 403 and `runner.enqueue` not called; **mutation**: removing the refusal reddens it | +| AC5 | met | two checks in the hook's identity suite; **mutation**: removing the skip reddens both | +| AC6 | met | two `@ts-expect-error` pins; **mutation**: widening `scopeOf` to all three kinds produces 2 type errors | +| AC7 | met | `access-control-unattributed.int.test.ts` green and **unmodified** — `git status` on that path is empty | +| AC8 | met | real `turbo build` green, forced; integration **225 passed / 8 skipped** | + +## Review log + +**2026-10-25 — what the investigation established before any code changed.** + +- The `null` covered **three** situations, and the code already knew one of them was a downgrade: it + logged *"It will be written without a permission check, as an unattributed one is"* and proceeded. +- **Payload's own auth cannot produce an unnameable actor over HTTP** — + `payload/dist/auth/strategies/jwt.js:55` sets `user.collection` itself. So this guards a case the + plugin cannot reach on its own; it is reachable through a host's custom auth strategy or a + local-API caller passing a hand-made user. Recorded rather than overstated. +- Step 6's prediction about 11 enqueue cases **did not apply**: it was about `mayInitiate` trusting a + null *user*, and those cases carry no user at all, which stays `nobody`. + +**`/sp-complexity`.** Neighbourhood 5 files. **One finding, fixed and proven by probe:** +`PlaceableIdentity` was `Exclude`, which makes any **future** kind +placeable by default. Adding a fourth kind and passing it to `scopeOf` produced **0** errors under +`Exclude` and **1** under `Extract`. Now the placeable kinds +are named rather than subtracted, so tomorrow's state is refused until someone decides otherwise. + +Also traced the second door into a scope and confirmed it is not a hole: `PayloadJobsRunnerProvider` +rebuilds a scope from the **stored** job row, which was filtered at enqueue time. Rows written before +this step carry a null requester and still read as `nobody`. + +**`/sp-abstraction`.** Audited by the author. Measures: honesty 3 states admitted / 3 produced, gap 0. + +- **Finding, fixed:** the two `unnameable` branches were asymmetric. "No `collection` with several + auth collections" logged a detailed diagnosis; **"no `id`" returned the same answer silently**, so a + host would have got a `403` with no explanation. Both now say why, mutation-proven. +- **A suspicion of mine, measured and confirmed:** `scopeOf` returning `{ requester: null }` rather + than `{}` is observed by **no reader** — `rebuildRequester` and the four `scope.requester?.…` sites + treat null and undefined identically. Exactly one test sees it. Kept anyway, because AC7's promise + was that the no-user case is untouched; recorded as a contract with a test, not with readers. + +**`/sp-red-test` in audit mode.** Stubbing `identityOf` reddened **52 checks across 9 files** — both +identity suites, four enqueue suites and three auto-translate suites. **No toothless check found.** +Scaffolding deleted, `git status` shown. + +**`/sp-review-comments`.** 8 blocks, 8 verdicts: 4 KEEP, 3 REWRITE-COMMENT, 1 REWRITE-CODE. Inline +density 0.5 per 10. + +Two of my own claims were overstated and are corrected: + +- The hook's comment said a throw inside `afterChange` *"would roll back the save"*. Checked + `swallowOrThrow`: it rethrows **only** when the caller is inside a transaction **and** the error + does not declare itself harmless. The comment now names that mechanism instead of the broad claim. +- `scopeOf`'s docblock implied a downstream reader depends on the explicit `null`. None does, so the + sentence would have stopped someone changing it for a reason that is not true. Cut. + +The REWRITE-CODE verdict removed a dead branch rather than explaining it: `named()` handled +`asRequester` returning null, which cannot happen because both arguments are guarded above. A +**non-null overload** on `asRequester` makes that structural — the branch and its comment are gone, +and no cast was introduced. + +**Found outside its own remit, and it was real:** the hook's warning check asserted +`stringContaining("auto-translate skipped")`, which **also matches the hook's other skip message** +("no source locale resolvable"). Tightened to the actor wording; a mutation substituting the other +message now reddens it, where before it would have passed. + +### Verification after every fix + +`check-types` 0 · oxlint 43 warnings / 0 errors (baseline) · package suite **204 files / 2570 +checks**, up from 202 / 2559 by exactly +2 files and +11 checks (6 identity kinds, 3 enqueue refusal, +2 hook skip) · real `turbo build` green, forced · integration **225 passed / 8 skipped**, with the +pinned unattributed test green **and unmodified** · `sp-diff-checks` clean · `sp-lint-delta` +introduced nothing. + +### Owed to step 10 + +A release-note line: a host whose custom auth strategy returns a user without `collection` now gets a +`403` on enqueue and a skipped auto-translate, where before the work proceeded with the host's rules +off. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-25-jobs-resolve-to-documents.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-25-jobs-resolve-to-documents.task.md new file mode 100644 index 000000000..7ca78586c --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-25-jobs-resolve-to-documents.task.md @@ -0,0 +1,298 @@ +Build step: 2026-10-25-asking-payload-everywhere.design.md #6 + +## Task restatement + +Three routes act on **jobs** without asking who the job belongs to or which document it touches: +`POST /translate/run/:id`, `DELETE /translate/cancel`, `DELETE /translate/cancel-by-collection/:slug`. +Those are gaps 1–3 of the six measured. + +A job is the plugin's own object, and the host has no rule about it. So the job question is +**reduced to the document question**: a job may be cancelled by anyone who may change its document, +and run by them too — unless nobody is recorded as having started it. + +Covers **R2** and **R5**. Out of scope by the design's sequence: the brand at the queue sink (#7), the +three-valued identity (#8), the module seam (#9), pins and `@since` (#10). + +## Measured, not assumed + +| Claim | Measured | +|---|---| +| `run/:id` checks nothing | `run-translation/handler.ts` parses an id and calls `runner.run(id)` | +| `cancel` checks nothing | `cancel/handler.ts` parses ids and calls `runner.cancel(ids)` | +| the requester is already stored | `jobInput.schema.ts:39-40` — `requester_id`, `requester_collection`, both nullable | +| a task already names its document | `types.ts:32-37` — `TaskInput` carries `collectionSlug` and `collectionId` | +| **`cancel-by-collection` already holds the documents** | it calls `findByCollection(slug, {excludeCompleted: true})` and gets `Task[]`, each with `input.collectionId` | +| the sync runner cannot run or cancel anything | `SyncTaskRunner.cancel` is a no-op; `run` returns `{success: false, error: "not_found"}` unconditionally | +| the jobs runner can read by handle | `PayloadJobsTaskRunner.cancel` already does `findRawJobs({ id: { in: taskIds } })` | +| the client reads neither cancel response | `useCancelDocumentTranslation` and `useCancelCollectionTranslations` both return `Promise` | + +## What already exists — platform · installed · project · verdict + +| Rung | Found | Verdict | +|---|---|---| +| project | `refusedTargets({payload, collection, ids, user})` (step 5) | **its genuine second caller is here** — but only on `cancel-by-collection`, which already has document ids. The other two routes hold handles, not ids | +| project | `TaskRunner.findByCollection` | already returns the rows `cancel-by-collection` needs. Nothing new is required there | +| project | `PayloadJobsTaskRunner.findRawJobs` | the read that `assignmentsOf` is built on | +| project | `Task` (`types.ts:50`) | carries `id` and `input`, but **not** the requester — `normalizeJob` drops it | +| platform | Payload's jobs queue | has no notion of who may cancel a job; the rule is the plugin's | + +Nothing new is installed. + +## Decisions + +### D1 — `cancel-by-collection` asks with the rows it already has + +**Chosen:** filter the rows `findByCollection` returned through `refusedTargets` over their +`input.collectionId`, before any of them is cancelled. + +**Rejected — route it through the new `assignmentsOf`.** It would re-read from the store what the +handler is already holding, and make a route depend on an optional method it does not need. + +**Constraint cited:** `cancel-by-collection/handler.ts` already builds `rows` from `findByCollection`, +and every row's `input.collectionId` is the document. The design's own wording — "jobs resolve to +documents" — is already satisfied there without a new seam. + +### D2 — `run` and `cancel` need a handle-to-document read, and that is the new method + +They receive handles and have no other way to reach a document. `TaskRunner` gains **optional** +`assignmentsOf(handles): Promise`, with +`JobAssignment = { handle, collectionSlug, collectionId, startedBy }`. + +**Rejected — widen `EnqueueAssignment`.** It is per **locale**, and it is handed to host lifecycle +callbacks, which must not start carrying requester ids (the design's own reason, SC4). + +**Rejected — surface the requester on `Task`.** `Task` reaches `findByCollection`'s callers and the +status routes; putting a requester id there would publish it far wider than the two routes that need +it. + +### D3 — the sync runner implements it; only a host runner is fail-closed + +**Chosen:** `SyncTaskRunner.assignmentsOf` answers from its in-memory `tasks` map, with +`startedBy: null` — it stores no requester. + +**Rejected — leave it unimplemented and let fail-closed cover it.** `apps/cms` wires the sync runner, +and a fail-closed `cancel` there would turn a harmless no-op into a `403`. Implementing it is eight +lines and keeps that path exactly as it is. + +**Fail-closed therefore binds only a runner supplied by a host**, which is where the design meant it. + +### D4 — a handle that resolves to nothing is **absent**, not refused + +**Chosen:** no assignment for a handle answers `404` on `run/:id`, which is what it answers today. +A handle that *does* resolve and whose document is refused answers `403`. On `run/:id` a resolved job +with `startedBy: null` also answers `403`. + +**Rejected — refuse an unresolvable handle.** It would turn the sync runner's permanent `not_found` +into `403`, changing a route's answer for every host on that runner without closing anything: there +is no job to protect. + +**Constraint cited:** step 3 settled the same question the same way — `absenceIsNotARefusal`, and +`sourceDocument.ts`'s "refused and absent are deliberately identical". Absence is not a refusal here +either. + +### D5 — `cancel` drops the refused handles and names them + +**Chosen:** cancel the handles the caller may change; answer `204` when nothing was refused, and +`200` with `{ refused: [...] }` when something was — mirroring step 5's `refused` key. + +**Rejected — refuse the whole request if any handle is refused.** It contradicts step 5's decision on +the same question, taken one commit earlier for the same reason: a caller entitled to most of a batch +should not lose it. + +**Rejected — stay on `204` and say nothing.** A caller would believe work was cancelled that was not. + +**Constraint cited:** both cancel routes are consumed by mutations typed `Promise` +(`useCancelDocumentTranslation`, `useCancelCollectionTranslations`), so a body added on the refusal +path is invisible to the only client in this repo. + +### D6 — shape check + +Triggers that fired: a **new optional method** on a port implemented outside this package; a new +published type (`JobAssignment`); a response gains a field. + +| | `JobAssignment` as its own type *(chosen)* | `startedBy` added to `Task` | +|---|---|---| +| cost of the next field of this kind | one place — the two runners that produce it | every consumer of `Task`, including the status routes | +| states admitted vs produced | `startedBy` nullable, and both halves occur (jobs runner records one, sync runner never does) — gap **0** | admits a requester on rows that never carry one | +| second caller already in the repo | `run` and `cancel` handlers | same | +| breaks outside this repo | nothing: the method is optional, so an existing runner still satisfies the interface | a host reading `Task` sees a requester id it never asked for | + +**Chosen:** the first. `startedBy` is nullable **because the system produces both**, not to paper over +an unknown — the sync runner stores no requester and says so. + +## Acceptance Criteria + +| # | Covers | Criterion | How it is checked (binding) | +|---|---|---|---| +| AC1 | R5 | `POST /run/:id` answers `403` for a job whose document the caller may not change, and does not run it | a case in `run-translation/__tests__/` asserting `runner.run` was not called | +| AC2 | R5 | `POST /run/:id` answers `403` for a resolved job with `startedBy: null` | a case in the same file | +| AC3 | R5 | `POST /run/:id` still answers `404` when no assignment resolves — unchanged | a case asserting the status is 404, not 403 | +| AC4 | R5 | `DELETE /cancel` cancels only the handles the caller may change | a case asserting `runner.cancel` received the allowed handle and not the refused one | +| AC5 | R2 | …and names the refused handles | the same case reads `refused` from the body | +| AC6 | R5 | `DELETE /cancel-by-collection/:slug` cancels no job whose document the caller may not change | a case in `cancel-by-collection/__tests__/` | +| AC7 | R2 | Every ask precedes the effect | **mutation**: move each filter after its `runner` call; AC1, AC4 and AC6 must go red | +| AC8 | R6 | A runner that omits `assignmentsOf` still satisfies `TaskRunner` | the pins file compiles a runner without it | +| AC9 | R5 | An actor-less caller behaves exactly as today | the integration suites that cancel and run without a user stay green | +| AC10 | R5 | The baseline holds | real `turbo build` green; integration **225 passed / 8 skipped** | + +## Pre-flight — each check run against the CURRENT tree + +| # | Polarity | Result now | Evidence | +|---|---|---|---| +| AC1–AC2, AC4–AC6 | must FAIL | **fail** | `grep assignmentsOf src` → nothing; all three handlers call the runner with no ask | +| AC3 | must PASS | **passes** | `run/:id` answers 404 for an unknown id today | +| AC7 | must FAIL | **fails** | there is no filter to move | +| AC8 | must PASS | **passes** | no runner implements a method that does not exist | +| AC9, AC10 | must PASS | **pass** | 197 files / 2531 checks, build green, 225/8 | + +## Risk notes + +**High risk.** This decides who may cancel and re-run other people's work, and it adds a method to a +port implemented outside this repo. + +- **The silent-drop hazard** is the same as step 5's, which is why AC5 exists. +- **`cancel` changes status code on one path** (`204` → `200` with a body). Invisible to this repo's + client, but it is a contract change and owes a release-note line at step 10. +- **The sync runner path must not change at all** — `apps/cms` runs on it. D3 and D4 exist to keep it + identical, and AC3 is the guard. +- **SC6 residue, already named in the design:** a run triggered by A on B's attributed job is still + recorded as B's. Closing it means a stored-row change, out of scope; a log line names it. +- A test is required before done; the regression sweep is evidenced; Phase 5b runs several angles. + +## Human choices + +**2026-10-25.** The owner directed an uninterrupted run through steps 5–10, stopping only at a +genuine fork. No fork arose: D1 and D4 follow decisions already taken in steps 3 and 5, D2 and D6 +follow the design's SC4, and D5 follows step 5's answer to the same question one commit earlier. +Commit authority granted in advance; no push, no pull request. + +## A test was changed to fit the code — stated plainly + +`apps/dev/.../locale-workflow-failure.int.test.ts` went red. It enqueued **with no user** and then +retried through `POST /translate/run/:id`, which now refuses a job nobody is recorded as having +started (D4, from the design's D5). + +I gave it an attributed editor rather than relaxing the rule. The reasons, so the choice can be +judged rather than taken on trust: + +- **The test's subject is retry semantics** — "stops there, leaves the locales after it untouched, + and resumes on retry" — not permissions. Attributing the job preserves what it tests. +- **The refusal is real and wanted.** A job with no requester runs with the host's rules off + (`enforcedAtTheWrite(null)`). Letting any HTTP caller trigger one by naming its handle is the + escalation this step exists to close. +- **It does not contradict SC1.** That deviation keeps *in-process* unattributed work trusted — a + save, a local-API call. `run/:id` is an outside caller asking the plugin to start that work. +- **Normal use is unaffected**, verified: auto-translate passes `identityOf(req, …)` when it + enqueues (`AutoTranslateEnqueue.hook.ts:79-81`), so a job from an editor's save is attributed. Only + a save made with no user at all produces an unattributed job. +- **The refusal is pinned independently** by AC2 at unit level, mutation-proven — so removing the + rule turns a check red whatever the integration fixture does. + +**Owed:** an integration pin for the refusal itself, and a release-note line. Both belong to step 10. + +## Review log + +**2026-10-25 — `/sp-complexity`.** Neighbourhood 8 files. Two scenarios counted. "A second optional +method on the runner port": CHANGE 4, READ 5, STATE 2 per method. "A route starts asking about a +document": CHANGE 1, READ 3 — unchanged from step 5, but one state grew, because there are now **two +shapes of asking** (by document id, by job handle) and a new route must pick. **No findings, no edit.** + +**2026-10-25 — `/sp-abstraction`** on `assignmentsOf` / `JobAssignment`. Audited by the author. + +- **Measured and confirmed rather than taken on trust:** `startedBy` is nullable honestly — + `PayloadJobsTaskRunner.ts:208` writes `requester_id: scope.requester?.userId ?? null`, and the sync + runner records nothing. Both halves occur. +- **What the author's own brief omitted:** `DELETE /translate/cancel` changes its **status code** on + one path (`204` → `200`), not just its body. No type can see that. +- **Finding, fixed:** the caller filtered the **input** handles rather than the ones the runner could + place, so a handle the runner never answered for went to `cancel` unasked. Harmless today in both + in-repo runners, but it held by contract rather than by code. Now only what was placed and allowed + is cancelled, pinned by a check and a mutation. + +**2026-10-25 — `/sp-red-test` in audit mode.** Scaffolding deleted, `git status` shown. + +- The first stub pattern **did not match** — the barrel imports `./readRequester.js`, not + `store/readRequester.js` — and a run against a non-matching alias looks exactly like "nothing + guards this". Caught by running a control: stubbing `refusedTargets` reddens 15 checks in 2 files, + so the mechanism works. +- **Finding, fixed:** with `readRequester` stubbed to throw, **the entire suite stayed green** — + 200 files, 2545 checks. It had zero coverage, and it is the function deciding whether a job is + attributed, which is what `run/:id` refuses on. Six checks added, three mutations. One of them + earns its place on its own: a naive `!id` would read **user id `0`** as unattributed and refuse a + legitimate re-run. + +**2026-10-25 — `/sp-review-comments`.** 7 candidate blocks, 7 verdicts + 1 MISSING: 2 DELETE, +3 REWRITE-COMMENT, 1 REWRITE-CODE, 1 restored placement. Inline density 0.4 lines per 10 — fine; the +cuts were to docblocks. + +Three substantive results, all defects rather than verbosity: + +- **A public type lost its documentation.** `JobAssignment` had been inserted *between* the + `EnqueueAssignment` docblock (`@since 0.16.0`) and `EnqueueAssignment` itself, so the public type + was left bare and the new one carried two stacked blocks. Adjacency restored. +- **`readRequester`'s one-liner was false.** It claimed `null` means "a row that recorded nobody, not + a row we failed to read" — but an `undefined` input and an empty `requester_collection` both answer + `null` too. The distinction the sentence drew is one the function does not enforce. +- **A comment was covering for a split predicate**, so the code was changed instead: `cancel` now + gets `{ allowed, refused }` from one function, and the cancel list can only come out of the ask. + +**Found outside its own remit, and it was a real bug:** the refusal verdict was keyed by **document +id alone**, so two collections holding the same id cross-contaminated — a refusal in `pages` would +spare a job in `posts`. It over-refused, which is the safe direction, but it was wrong. Now keyed by +`collection:id`, with a check and a mutation. + +### Verification after every fix + +`check-types` 0 · oxlint 43 warnings / 0 errors (baseline) · package suite **201 files / 2552 +checks**, up from 197 / 2531 by exactly +4 files and +21 checks (4 + 6 + 4 route checks, 6 for +`readRequester`, and 1 from the per-source-file throw test for the new file) · real `turbo build` +green, forced · integration **225 passed / 8 skipped** · `sp-diff-checks` clean · `sp-lint-delta` +introduced nothing. + +### Owed to step 10 + +`@since` on `assignmentsOf` and `JobAssignment` (the package requires it on published API; the +version depends on the release) · an integration pin for the unattributed-job refusal · release-note +lines for the `204` → `200` change on `DELETE /cancel` and for the refusal itself. + +## Amended 2026-10-26 — `assignmentsOf` is required, and its parameter is named + +Two decisions of this contract were overturned in review, after the whole sequence was read end to +end. The reasoning above is kept as written; what follows says what replaced it. + +**The method is no longer optional.** This contract chose optional so that a host's existing runner +would still compile, and accepted that run and cancel would answer `403` until the host added it. +The branch already carries two breaking commits (`a13052d8`, `1fa6e5d2`), so the major release that +optionality was protecting against is happening regardless — and with the compatibility gone, +optionality only buys a worse failure: the project builds and two routes stop working, discovered +from a `403` rather than from the compiler, which names neither the method nor the file. + +**AC8 is superseded.** It read *"A runner that omits `assignmentsOf` still satisfies `TaskRunner`"*, +pinned by a runner compiling without it. The opposite is now pinned: `cannotPlaceItsHandles` in +`TaskRunner.conformance.types.ts` is a `@ts-expect-error` over a provider that omits the member, so +restoring the `?` fails `check-types` with `TS2578`. Proven by that mutation. + +**One case was traded for another.** `cancel/__tests__/handler.access.test.ts` held *"refuses when +the runner cannot say what a handle stands for"*, which forged a method-less runner through +`as unknown as TaskRunner` to reach a branch the type system now forbids; the branch and the case +are both gone. What it also guaranteed at runtime — that `cancel` is not reached when the handles +cannot be resolved — was left holding on argument evaluation order alone, so a case replaces it: +*"cancels nothing when the runner cannot answer what the handles stand for"*, proven by mutating the +lookup to swallow its failure. The suite stays at 2599 checks. + +**The parameter is named.** `assignmentsOf(handles: string[])` was a bare primitive against the +house rule, while `refusedTargets.ts` already named the same shape `DocumentIds` and `cancel`/`run` +took a branded `AuthorisedHandle`. `JobHandle` is now declared in `task-runner/types.ts` and used in the +handle-bearing types and in the access layer: `Task.id`, `EnqueueAssignment.handle`, +`JobAssignment.handle`, this parameter, and the four positions in `shared/access/authorised.ts` +where a handle was indistinguishable from a document id — `authorisedWork` returns +`Split` and `authorisedHandles` returns `Split`. +`AuthorisedHandle` is minted over it rather than over `string`. It is an alias of `string`, so +nothing about assignability or runtime behaviour changed. + +**Not everywhere, and the gap is named rather than claimed closed.** A handle still travels as a +bare `string` through its own mint point (`payload-jobs-runner/store/handleOf.ts`), through +`queueWorkflow`, through `JobIdSchema` at the HTTP boundary, and through `cancel`/`run` as both +runners declare them — the brand ends at the interface, which method bivariance has always allowed. +Those are a separate pass, and branding the schema (zod's own `.brand()`, no cast) is where it would +start. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-25-one-run-one-context.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-25-one-run-one-context.task.md new file mode 100644 index 000000000..ee4a1bcec --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-25-one-run-one-context.task.md @@ -0,0 +1,390 @@ +# One run, one context + +**Risk: low.** No behaviour changes. One class, one production caller, and a path covered by 95 unit +checks in nine files plus the integration suites. + +## Task restatement + +The owner read `translate-document/handler.ts` and said he could not run his eye down it. He is +right, and the reason is measurable rather than stylistic: **`handle` is 117 lines of the file's +341**, and five or six values that no step decides are re-threaded through every private method. + +Counted in the file as it stands: `payload` written 29 times, `collection` 21, `input` 21, +`requester` 19, `scope` 18, `targetLng` 12. Parameter counts per method: + +| method | params | +|---|---| +| `saveTranslatedDocument` | **7** | +| `refuseUnlessAllowed` | **6** | +| `publishTargetLocale` | **5** | +| `leavesToSend` · `handle` | 3 | +| `answerTo` | 2 | +| `translateOrWrap` | 1 | + +Each of the top three rebuilds the same set from scratch, in a different order, with a different +subset. That is the owner's own rule one level up from where he has been applying it: **values that +always travel together are a type nobody named.** + +Second, `handle` mixes two altitudes. "Read the source document" sits beside +`payload.collections[collection].config.versions`, `depth: 0`, `draft: true`, `freshReq(scope)`. + +**This is a readability change, not a correctness fix** — the same framing as +`2026-10-09-name-what-the-workflow-handler-does.task.md`, which did this for the workflow handler +sixteen days ago after the owner said the same thing about it. + +## What already exists — platform · installed · project · verdict + +**Nothing new is invented. The project already has this shape, twice, in the file this handler +imports from.** `TranslationRun.interface.ts:20` and `:30`: + +```ts +export type BeforeTranslation = { + readonly payload: Payload; + readonly target: RunTarget; + readonly scope: RequestScope; + … +}; +export type AfterSave = { + readonly payload: Payload; + readonly target: RunTarget; + readonly scope: RequestScope; + … +}; +``` + +The module seam already opens every context with `{ payload, target, scope }`. Only the handler's +own private methods pass those three apart, positionally. `Run` is the handler-side sibling of two +types it already constructs. + +**Precedent for the task shape:** `2026-10-09-name-what-the-workflow-handler-does.task.md` — a +readability task on a handler, same complaint, same package. Its D3 is the clause to copy: it +dropped one of three proposed extractions and said so plainly rather than slipping it in. + +## Decisions + +### D1 — one `Run` context, built once per call + +**Chosen:** a type holding what every step needs and no step decides, assembled at the top of +`handle` and passed as the first argument to each step. + +```ts +/** What every step of one run needs and none of them decides. */ +type Run = { + readonly payload: Payload; + readonly target: RunTarget; + readonly scope: RequestScope; + readonly requester: RebuiltRequester | null; + readonly schema: FieldLike[]; + readonly layer: TargetLayer; +}; +``` + +**Rejected — keep threading.** The zero option, and it loses on the measurement above: three methods +carrying 5, 6 and 7 parameters, built from the same six values. + +**Rejected — hold the per-request values as instance fields.** It reads tidiest of all and it is a +**bug**. `wireTranslateRunner.ts:44` constructs the handler once at boot and the closure at `:53` +calls `handle` for every job; Payload's runner takes a batch. Two concurrent jobs would write each +other's `requester` and `target`. This is a constraint from the code, not a preference. + +**Constraint cited:** `TranslationRun.interface.ts:20-36` — the project already names this exact +triple as a context, twice. + +### D2 — `Run` lives in `handler.ts` and is not exported + +**Chosen:** declared beside the class that builds it. + +**Rejected — its own file.** It has one producer and one consuming module. The brake is "prefer +editing an existing file to creating a new one", and the package's own barrel rule says a name +leaves its module only when something outside uses it. If a second feature needs it, it moves then, +and that move is cheap. + +### D3 — `handle` reads as a list of named steps, with no Payload call in it + +**Chosen:** every Payload API call moves behind a named step. The test is mechanical: if +`payload.findByID` appears in `handle`, a step has not been named. Two such calls are there today +(`payload.collections[...]` at :158, `payload.findByID` at :171). + +**Rejected — extract everything that could be extracted.** `2026-10-09`'s D3 is the precedent for +restraint: it kept the least confusing of four mechanics inline because "the two that earned names +are the ones the owner could not read." The same applies here — `translateOrWrap` already has a name +and one parameter and is left alone. + +### D4 — the duplicated condition collapses; publication keeps its position + +`if (!translated?.translatedData)` and `if (translated?.translatedData)` stand back to back +(:225, :230). They become one `if/else`. + +**Publication stays outside that block, where it is today.** `if (publishOnTranslation && layer.kind +=== "drafts")` at :249 runs whether or not anything was translated, so a run that sent nothing still +publishes the target locale when asked. An early return from `handle` — which the shape invites — +would change that silently. **See `## Human choices`: this is the owner's to confirm, not the +agent's to decide.** Whatever he chooses, the step is named so the fact is visible in the list +rather than inferred from brace position. + +### D5 — the constructor takes one object + +**Decided by the owner at the gate, 2026-10-25.** `criteria` is the fourth positional parameter +with an empty default; `handler.criteria.test.ts:49` passes `[]` for `modules` purely to reach it; +`wireTranslateRunner.ts:44` destructures four names from its own params type and reassembles them +positionally, with no transformation. It is the same disease as D1 one level out — a set of values +that travel together, pretending to be four unrelated arguments. + +```ts +constructor(deps: { + translationProvider: TranslationProvider; + schemaMap: CollectionSchemaMap; + modules?: readonly ChainMember[]; + criteria?: Criteria; +}) +``` + +**Rejected — keep a positional overload for the old call shape.** Two forms instead of one, worse +than either end. **Rejected — defer it to its own task**, which was the agent's recommendation on +the precedent of `2026-10-09` ("existing tests not to be edited"); the owner overruled it, and the +reason the recommendation was weak is recorded under `## Human choices`. + +Cost, counted: **12 construction sites** across nine test files, plus `wireTranslateRunner.ts:44`. + +### Shape check + +Fired: **"two or more fields are always set together"** — `Run` is six. Not fired: nothing is added +to a published or externally implemented type; no exported function gains a parameter (three lose +four to six each); nothing new is optional. + +| | **one `Run`** (chosen) | two objects: `Request{payload,scope,requester}` + `Document{target,schema,layer}` | keep threading (zero option) | +|---|---|---|---| +| cost of the next value of this kind | 1 edit — add a field | 2 edits — pick an object, then add | 3–8 edits, one per signature | +| states admitted / produced | 1 / 1 — all six are always present | 1 / 1, but every step takes 2 args to say what 1 says | n/a | +| second caller already present | 6 — `leavesToSend`, `refuseUnlessAllowed`, `saveTranslatedDocument`, `publishTargetLocale`, the read step, the report step | same 6 | — | +| breaks for an implementor outside this repo | nothing — not exported, not published | nothing | nothing | + +The second shape loses on the middle row: the split is drawn where the *values* come from, not where +any step draws a line, so every step would take both halves. Splitting a context nobody splits is a +distinction the code does not make. + +### Placement + +Everything stays in `translate-document/handler.ts`. **Files are not split in this task** — breaking +the file up before the shared type exists would scatter the same threading across several files, and +the decision to split is worth taking against the measured result, not against a guess. Recorded as +a follow-up, not a liberty. + +### New surface + +One type (`Run`), not exported, six consumers inside the file. Named steps replace inline blocks; +none is exported. + +### Written contract + +`Run` owes its readers one thing a signature cannot state: **it is per call, never per instance** — +the handler is a boot-time singleton serving concurrent jobs (D1's rejected alternative). That is +one sentence in its docblock, and it is the thing a future reader is most likely to get wrong. + +No `/sp-red-test` path: no new interface, no new port, no suite that is itself the deliverable. + +### Escalate to `/sp-architect`? + +No. One class, one file, no new seam, no dependency change, no stored shape touched. + +## Acceptance Criteria + +| AC | Criterion | How it is checked | Passes when | +|---|---|---|---| +| AC1 | `handle` reads as a list of steps | count its lines: `awk '/^ async handle\(/{s=NR} /^ \}$/{if(s){print NR-s; exit}}' handler.ts` | ≤ 30 | +| AC2 | `handle` makes no Payload API call | the same `awk` range, piped to `grep -cE 'payload\.(findByID\|update\|collections\|create\|delete)'` | 0 | +| AC3 | No step rebuilds the context by hand | parameter count per method (script in the pre-flight below) | no method over 3; none over 2 besides `handle` and `leavesToSend` | +| AC4 | The file is shorter than it was | `wc -l handler.ts` | ≤ 260 | +| AC5 | **Behaviour is identical** | `bunx vitest run src/server/features/translate-document` **and** `git diff -- src/server/features/translate-document/__tests__/` | 95 passed, and the diff contains **no** changed `expect(` line — baseline captured 2026-10-25 against the untouched tree | +| AC6 | A run that translated nothing still publishes when asked | the existing check in `handler.test.ts` | green, unedited (invariant — unless the owner changes it at the gate) | +| AC7 | The duplicated condition is gone | `grep -c 'translated?.translatedData' handler.ts` | ≤ 1 | +| AC8 | No new type errors | `bun run check-types`, package and `apps/dev` | 0 each | +| AC9 | No new analyzer findings | `bunx oxlint .` | ≤ 43 warnings, 0 errors | +| AC11 | The constructor takes one object, and no caller passes a filler argument | `grep -rn 'new TranslateDocumentHandler(provider, schemaMap, \[\], ' src/` and the constructor's parameter count | 0 filler sites, 1 parameter | +| AC10 | The queue path still works end to end | rebuild from the root, then `bun run test:integration` in `apps/dev` | 225 passed / 8 skipped, unchanged | + +## Pre-flight — run 2026-10-25 against the untouched tree + +| AC | Result now | Kind | +|---|---|---| +| AC1 | **117** | change — fails now, correct | +| AC2 | **2** (`payload.collections` :158, `payload.findByID` :171) | change — fails now, correct | +| AC3 | **7** (`saveTranslatedDocument`), **6** (`refuseUnlessAllowed`), **5** (`publishTargetLocale`) | change — fails now, correct | +| AC4 | **341** | change — fails now, correct | +| AC5 | 95 passed; test diff empty | invariant — passes now, correct | +| AC6 | green | invariant — passes now, correct | +| AC7 | **2** | change — fails now, correct | +| AC8 | 0 and 0 | invariant | +| AC9 | 43 warnings, 0 errors | invariant | +| AC11 | **1** filler site (`handler.criteria.test.ts:49`), constructor takes **4** parameters | change — fails now, correct | +| AC10 | 225 passed, 8 skipped | invariant | + +The parameter count for AC3 is taken with: + +```bash +python3 -c " +import io,re +s=io.open('src/server/features/translate-document/handler.ts').read() +for m in re.finditer(r'^ (?:private |)(?:async )?(\w+)\(([^)]*)\)', s, re.M): + n=len([p for p in m.group(2).split(',') if p.strip()]) + print(n, m.group(1))" +``` + +## Risk notes + +- **The only way this task can do damage is by changing behaviour while claiming not to.** AC5 is + the guard, and it has two halves on purpose: the suite passing is not enough, because a moved + expectation also passes. The diff over `__tests__/` must be empty of changed assertions. +- **The publication ordering (D4) is the one real fork.** It is at the gate rather than decided + here. +- `translateOrWrap`'s docblock explains why an `APIError` is re-thrown unwrapped while anything else + is adopted. That reasoning must survive the move intact; it is load-bearing and was reviewed. +- The `Run` type must not become a bag. Six fields is the whole set; anything a step *decides* — + `sourceData`, the selection, what came back — stays an ordinary argument. + +## Human choices + +**2026-10-25 — the owner asked for this after reading the file**, following a `/sp-abstraction` and +a `/sp-complexity` pass over it. His words: the code is hard to read, over 300 lines, and he wants +to run his eye down linear calls in order and understand what is happening. + +**Answered at the gate, 2026-10-25:** + +1. **Publication after a run that sent nothing — keep today's behaviour.** The task changes no + behaviour at all; AC5 and AC6 guard it. The step is named so the fact reads from the list rather + than from brace position. +2. **The constructor folds into one object, in this task** (D5). The agent recommended deferring + it, on the `2026-10-09` precedent of not touching existing tests, and the owner overruled it. + +**The agent's recommendation was weak, and the correction belongs here rather than being quietly +dropped.** It rested on a tension with AC5 that does not exist: AC5 pins that no **assertion** +changes (`expect(` lines), not that no line of a test file changes. Construction sites are setup, +not assertions, so folding the constructor leaves the guard fully intact. The real cost is 12 edited +construction sites and nothing else. + +## Open, not solved here + +- Splitting `handler.ts` into several files. Decide against the measured result, not a guess. +- The literal `"stale"` declared twice (`Provenance.criterion.ts:13`, `runTargets.ts:8`), held equal + by two comments. Reported by `/sp-abstraction`; a consequence of two settled decisions. +- **`payload.collections[input.collection].config.versions` indexes unguarded**, so a slug Payload + does not know fails as a bare `TypeError: Cannot read properties of undefined`, with the slug + nowhere in the message. Eight lines above, the same method throws a named `TranslatorConfigError` + for the `schemaMap` miss, and the nearest neighbour doing the same lookup + (`translationPermission.ts:89`) writes `payload.collections[collection]?.config?...`. Raised by + the comment audit as a MISSING note. **Pre-existing** — `git show HEAD:…/handler.ts:78` has the + same unguarded index — and **not fixed here**, because a named error in place of a `TypeError` + is a behaviour change and AC5 forbids one. +- `plugin.ts:176` takes criteria from one source, so a second rule from another module has nowhere + to go. Settled as "no registry while there is one rule". + +## Acceptance criteria — walked with their declared checks, 2026-10-25 + +| AC | Verdict | Evidence | +|---|---|---| +| AC1 `handle` ≤ 30 lines | **met** | 117 → **25** | +| AC2 no Payload API in `handle` | **met** | 2 → **0** | +| AC3 no step rebuilds the context | **met on its first clause, not on the second** | widest signature 7 → **3**. The stricter half I wrote — "none over 2 besides `handle` and `leavesToSend`" — is not met: `beginRun`, `runModulesBefore`, `runModulesAfter` and `translateOrWrap` each take 3, and each of the three is something the step genuinely needs | +| AC4 file ≤ 260 lines | **NOT MET** | 341 → **374**. See below | +| AC5 behaviour identical | **met** | 95 passed, and **0** changed `expect(` lines across the nine test files | +| AC6 publish after a run that sent nothing | **met** | unedited and green; a reviewer confirmed `publishIfAsked` still sits outside the save branch | +| AC7 the duplicated condition is gone | **met** | 2 → **1** | +| AC8 no new type errors | **met** | 0 in the package, 0 in `apps/dev` | +| AC9 no new analyzer findings | **met** | 43 warnings, 0 errors; `sp-lint-delta` `mode=two-run`: nothing introduced | +| AC10 queue path end to end | **met** | rebuilt, then 225 passed / 8 skipped in `apps/dev` — unchanged | +| AC11 constructor takes one object | **met** | 4 positional → **1**; filler sites 1 → **0**; all 13 construction sites converted | + +### AC4 — why it failed, and whose error it was + +**The file grew: 341 → 374.** That is the opposite of what the criterion asked for, and the +criterion was mine. + +Where the lines are now: **38** import lines, **92** module level (the `Run` and `BothLocales` +types, the four reported messages, `emptyBecause`, and three write/seam helpers), **245** the class. +Reaching 260 means moving roughly 110 lines out of the file — which is exactly the file split that +**Placement** forbids in this task. **AC4 and the placement decision contradict each other**, and I +wrote both. The number came from an estimate ("expected: file ~250 lines") made before anything was +counted, and was then set as binding. + +A shrink pass was run rather than the number being quietly lowered: `onTheSeam` and `writingTo` +pulled out what the two chain contexts and the two target-locale writes repeat, `asksPermission` +pulled out what the two permission questions share, and the two chain wrappers were flattened. That +took 404 → 374. The remaining gap is structural, not slack. + +**What the change actually bought, stated honestly, since the identifier counts barely moved** +(`payload` 27 → 29, `collection` 20 → 21 — the values left the *signatures* and reappeared as +`run.payload`, `run.target.collection` in the bodies): + +| | before | after | +|---|---|---| +| `handle` | 117 lines, two altitudes mixed | **25 lines**, a list of named steps, no Payload call | +| widest signature | 7 parameters | **3** | +| signatures rebuilding the same six values | 3 | **0** | +| constructor | 4 positional, 2 defaulted | **1 object** | + +The owner's complaint was that he could not run his eye down the file. That is fixed. The file being +shorter was a second thing he asked for, and it is not. + +## Review log + +### 2026-10-25 · comment audit (fresh eyes, 9 blocks, 9 verdicts) + +Volume was under budget (0.16 inline lines per 10 code lines). The finding was composition, not +density, and **two of the nine blocks asserted things that are false**: + +1. `beginRun`'s docblock said "Everything the run is fixed on before it reads anything." It reads: + `rebuildRequester` → `findRequester` → `payload.findByID`. Worse, it contradicted a second note + two lines below in the same method, which said a read happens. Deleted. +2. The ordering note blamed `resolveTargetLayer` for throwing on an unknown collection. That + function contains no `throw`; what throws is the unguarded index in its *argument*. The auditor + also showed the restructure had **converted a structural ordering into a commented one** — HEAD + had two statements, and the rewrite had folded them into one object literal whose property order + became load-bearing. Both statements restored; both comment lines deleted. + +Two more became code rather than prose, which is what the owner's rule asks for: + +- `onTheSeam` → `moduleSeam`, annotated `Pick`, + so the claim "both chain contexts open with these three" is checked by the compiler instead of + asserted in a sentence. Comment deleted. +- `asksPermission` → `permissionQuery`, annotated + `Omit & { user: RebuiltRequester | null }` (`PermissionQuery` was already + declared in `translationPermission.ts` and is now exported). The old name read as "performs the + check" when it builds arguments; the sentence existed to correct that. Comment deleted. + +Four were true but derivable and were deleted: the `BothLocales` docblock (the field names say it), +`writingTo`'s (its own name says it), `readBothLocales`'s (every clause is in the five lines below +it — `Promise.all` and two `SourceUnreadable` throws), and the publish-placement note. That last one +matters: the placement is pinned by a **named** test — `handler.test.ts`, "publishes the target +locale even when there was nothing to translate" — so a reader who moves the call into the branch +gets that test red by name. The guard is structural; the note was an argument aimed at this diff's +reviewer. + +`Run`'s docblock was shortened to the one fact that is not derivable, with its citation. + +Net: **9 added comment blocks → 4**, two of which were false. + +### 2026-10-25 · phase 5 verification + +**One reviewer, low risk, aimed at falsifying "no behaviour change".** It found none, and checked +each merge field by field: `writingTo` against both original `payload.update` calls (identical, and +`layer.write` carries only `autosave`/`draft` so spread order cannot collide); `asksPermission` +against both permission calls (identical, with `user` overridden by the narrowed non-null value in +`refuseUnlessWriteAllowed`); publication still outside the save branch; `translateOrWrap`'s rebuilt +argument object field for field. + +**One finding, fixed here.** `beginRun` evaluated `await rebuildRequester(...)` before +`resolveTargetLayer(...)`, reversing HEAD's order. The reviewer rated it negligible — both are a +read and a pure call, and the only visible difference is which error wins on a collection Payload +does not know. It was reversed back anyway, so that "no behaviour change" is exact rather than +nearly exact, and the ordering now carries a one-line why. + +**Mechanical gates.** `sp-lint-delta` `mode=two-run`: nothing introduced. `sp-diff-checks`: three +test-integrity rows, **none from this task** — two are from earlier passes on this branch and one +from the fingerprint task earlier today. + +**Regression sweep.** All 13 construction sites converted and verified by grep; no positional call +survives anywhere in `src/` or `apps/`. The four names this change removed +(`refuseUnlessAllowed`, `publishTargetLocale`, `NOTHING_CAME_BACK`, `sentNothingBecause`) have zero +occurrences left. `ModuleState` was added to `translation-run`'s barrel because `handler.ts` is now +a consumer outside that module, which is what the package's barrel rule requires. +`TranslateDocumentDeps` was left unexported: nothing outside the file names it. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-25-pins-and-docs.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-25-pins-and-docs.task.md new file mode 100644 index 000000000..875b46b5d --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-25-pins-and-docs.task.md @@ -0,0 +1,207 @@ +# Step 10 — pins and docs + +Last step of `2026-10-25-asking-payload-everywhere.design.md`. Nothing about how the plugin asks +changes here. What changes is what a reader can find out without running it, and what a future +edit can break silently. + +## What the design asked for, item by item + +The design's step 10 reads: *"one pin per route for both asks, the field-route spend pin, `@since` +on the guard's `action`, the idempotency obligation in its docblock · covers: R1, R6"*. Plus the +debts steps 4, 6, 7, 8 and 9 each recorded as owed here. + +The zero option is applied to every item: an item that is not owed is dropped with the measurement +that drops it, not built because a contract once named it. + +| Item | Source | Verdict | +|---|---|---| +| one pin per route for the coarse ask | design | **build** — eight of nine routes are unpinned | +| one pin per route for the record ask | design | **drop** — three routes have no record ask to pin; the other six each already carry one | +| the field-route spend pin | design | **drop** — already exists | +| `@since` on the guard's `action` | design, step 4 | **build** | +| the idempotency obligation in the guard's docblock | design | **build** | +| `@since` on `TranslatorAction` | step 4 | **build** | +| `@since` on `assignmentsOf` | step 6 | **build** | +| `@since` on `JobAssignment` | step 6 | **drop** — not published | +| `@since` on `AuthorisedWork` / `AuthorisedHandle` | step 7 | **drop** — not published | +| `@since` on the three narrowed runner methods | step 7 | **drop** — a narrowed parameter is not an addition | +| integration pin: an unattributed job is refused | step 6 | **build** | +| release notes | steps 4, 6, 7, 8, 9 | **build** | +| proposed `README.md` text | step 4 | **report only** — the README is the owner's | + +### The measurements behind the drops + +- **The field-route spend pin exists.** `translate-field/__tests__/handler.access.test.ts:71` — + *"pays nothing for a caller it refuses"*. Building a second one would pin the same fact twice. +- **`JobAssignment`, `AuthorisedWork`, `AuthorisedHandle` are not published.** `grep` of + `src/index.ts` finds none of the three. The package rule scopes `@since` to *"anything re-exported + from `src/index.ts`"*. A host implementing `TaskRunner` never names them — contextual typing + covers the return position and the parameter positions — and a host *calling* the runner cannot + mint them at all, which is the point of the brand. What a caller needs to know goes in the release + note, which is a sentence rather than a tag. +- **A narrowed parameter is not an addition.** The package rule says `@since` *"applies to + additions"*. `enqueue`, `cancel` and `run` already existed; their parameter types got narrower. + That is a release-note fact. +- **Three routes have no record ask.** `get-document-status` and `get-collection-status` filter the + ids they report through `visibleIds` rather than asking per document; the staleness **read** route + asks nothing beyond the guard. Six routes ask per record and each already carries a + `handler.access.test.ts`. + +## The gap the coarse-ask pin closes + +Nine routes pass a literal action to `withAccessCheck`. `accessIsNotOptional.pins.ts` pins that a +route *cannot be built* without a guard and an action, and that the action is a closed union. It +does not pin **which** literal each route passes. + +So today a route can pass `"enqueue"` where it means `"cancel"`, and `check-types`, oxlint and the +whole suite stay green. A host whose guard answers per action would then be asked the wrong +question — the one failure this entire design exists to prevent, reachable by a one-word typo. + +One route is pinned: `enqueue-translation/__tests__/route.test.ts:41` asserts +`expect.objectContaining({ action: "enqueue" })`. Eight are not. + +## Acceptance Criteria + +| # | Covers | Criterion | How it is checked (binding) | +|---|---|---|---| +| AC1 | R1 | Each of the nine routes, built through its own factory and invoked, asks the guard exactly once and passes **its own** action literal | a new pin file building all nine with a spy guard; one check per route | +| AC2 | R6 | Changing one route's literal turns exactly that route's check red and no other | the mutation, run once per direction | +| AC3 | R2 | A job the store recorded nobody for is refused by `POST /run/:id`, against the real queue runner | a case in `apps/dev`'s translator integration suite | +| AC4 | R6 | `TranslatorAction`, the guard's `action` field and `TaskRunner.assignmentsOf` each carry `@since 1.0.0` | `grep` over `src/` | +| AC5 | R6 | `AccessGuard.check`'s docblock states that the guard is asked once by the route **and** again by each admin widget offering the action, so an implementation that counts, bills or spends on being asked is wrong | read against the two widget call sites | +| AC6 | R6 | `docs/DEPRECATIONS.md` carries one dated section naming every host-visible change of steps 4 to 9, each sentence true of the code as committed | each sentence walked to the line it describes | +| AC7 | — | The baseline holds | real `turbo build` green; package suite and integration counted before and after | + +## Decisions + +- **`1.0.0` is the version**, determined deterministically rather than by a dry run: the branch + already carries eight `@since 1.0.0` tags, written by earlier work on this same major, and the + package has never tagged a prerelease number in a `@since`. A ninth tag saying something else + would be the odd one out. +- **One pin file for all nine routes, not one per route.** The fact being pinned is a mapping — + route to action — and a mapping is read in one place. Nine files each asserting one pair would + make the gap (a route missing from the table) invisible, which is the failure the pin exists to + catch. +- **The release notes go in `docs/DEPRECATIONS.md`**, in the dated-section form the file already + uses for behaviour changes that remove no name (`## 2026-10-22`, `## 2026-10-24`, `## 2026-10-25`), + not in the `### anchor` entry form, which is for a named symbol going away. + +## Human choices + +- The README text is proposed in the final report and not written. Standing rule: the README is the + owner's. + +## Owed to no one + +This is the last step. Anything found here that is not built here is reported in the final report as +open, not recorded as owed to a step that does not exist. + +## Criteria results + +| # | Result | Evidence | +|---|---|---| +| AC1 | met | `src/server/shared/access/__tests__/everyRouteNamesItsAction.test.ts` — 18 checks over nine routes: one per route for the literal, one per route for *asked once*. The table is a `Record`, so a tenth action with no route named for it fails `check-types` rather than slipping past unpinned | +| AC2 | met | `run-translation/route.ts`'s literal changed `"run"` → `"cancel"`: **1 failed, 18 passed**, the failure being `createRunRoute asks about "run"`. Restored by copy, not by `git checkout`. A second mutation — `withAccessCheck` asking twice — reddened the nine *asks once* checks and no others. A third — a tenth member added to `TranslatorAction` — failed `check-types` at the table, `TS2741` | +| AC3 | met | `access-control-queue.int.test.ts` — *"refuses to re-run a row that recorded nobody"*. Proven by deleting `if (!job.startedBy) return ServerResponse.forbidden();` from the run handler and rebuilding: that case alone failed, 3 passed | +| AC4 | met | `@since 1.0.0` on `TranslatorAction`, on the guard's `action` field, on `TaskRunner.assignmentsOf` | +| AC5 | met, reworded | the obligation is stated, but not in the words the criterion used — see *The comment audit moved AC5* below | +| AC6 | met | `docs/DEPRECATIONS.md`, section `## 2026-10-25 — one permission decision point`, six sub-sections, one per step from 4 to 9 | +| AC7 | met | see below | + +### Numbers + +Package suite **206 files / 2599 checks**, up from 205 / 2581 by exactly +1 file and +18 checks — +the pin file and nothing else. `check-types` 0. oxlint **43 warnings / 0 errors**, the baseline +unchanged. Real `turbo build` green, forced and uncached. Integration **226 passed / 8 skipped**, up +from 225 by exactly the one new case. `sp-diff-checks` clean, 5 checks. `sp-lint-delta` introduced +nothing. + +### One claim corrected while writing + +The release note first said an enqueue whose ids are **all** refused "queues nothing and says so +rather than answering an empty success". The handler answers `200` with `success: true`, `queued: 0` +and the ids under `refused` (`enqueue-translation/handler.ts:110-116`). The sentence now says that. + +### A deviation from the design's wording + +The design asked for *"one pin per route for both asks"*. Three of the nine routes have no +per-record ask to pin: `document-status` and `collection-status` filter the ids they report through +`visibleIds` rather than asking per document, and the staleness **read** route asks nothing beyond +the guard. The other six each already carry a `handler.access.test.ts` from steps 3, 5 and 6. So +what was built is one pin per route for the **coarse** ask, which is the half that had none. + + +## The comment audit + +Run over the step's own diff by a reader who had not seen the code. Five blocks were handed over; +seven verdicts came back. Everything it found was applied. + +| Block | Verdict | What was wrong | +|---|---|---| +| the integration case's three-line note | rewrite the code | the name said *"re-run"* for a row that had never run, and the reason belonged in the name | +| `@since` on `assignmentsOf` | keep | the surrounding prose was re-checked against both handlers and is accurate | +| the obligation on `check` | rewrite the comment | **it overstated and understated at once** — see below | +| the prose on the `action` field | rewrite the comment | it restated `TranslatorAction`'s own docblock, which a reader reaches by hovering the type | +| `@since` on `TranslatorAction` | keep | — | +| the docblock on the route table | rewrite the code | not on the list — written after the audit began. The constraint is already in the `Record` type; the name now carries the intent and the docblock is gone | +| `user: editor as never` | missing | my own new line, and against a standing rule. The cast was also unnecessary: `callEndpoint` already accepts that shape | + +### The comment audit moved AC5 + +AC5 asked the docblock to say the guard is asked *"once by the route and again by each admin widget +offering the action"*. Measured, that is wrong in both directions: + +- **Overstated.** Only `enqueue` is asked twice. The two widgets that ask at render both ask + `"enqueue"`; `run`, `cancel`, `translate-field` and `dismiss-staleness` are asked once, at the + endpoint, and the field control does not ask at render at all. +- **Understated.** The status actions are *polled*. `useDocumentTranslation.ts:49` and + `useCollectionTranslationStatus.ts:46` both set a `refetchInterval`, so `document-status` and + `collection-status` are asked over and over with nobody acting at all — a far stronger reason to + keep the guard free of side effects than "more than once". + +The docblock now says the measured thing. The criterion's own wording was the inaccurate one. + +### Two smaller repairs in the same pass + +- The new integration case asserted on `String(assignment?.handle)` without first checking an + assignment came back. An empty answer would have sent the literal `"undefined"` and failed as a + `404`, which says nothing about the real cause. It now asserts the assignment exists first, so the + check fails for one reason. +- `user: editor as never` is now `user: editor`. The cast was copied from neighbouring integration + tests; those are not a reason. + +## The other three passes + +- **Complexity** — nothing to judge: no production code path changed. Every production edit in this + step is a comment. One counter did move, and deliberately: adding a tenth action now costs **three** + edits instead of two — the union, the route, and the table in the pin — and the third is named by + the compiler rather than left to memory. That is the pin working, not a cost to remove. +- **Abstraction** — nothing to judge: no boundary moved. `TranslatorAction`, `AccessGuard.check` and + `TaskRunner.assignmentsOf` gained documentation, not surface. `RouteUnderTest` is local to one test + file with one caller, which is a field, not a type anybody crosses. +- **Red test** — ran in full rather than in audit mode, because the checks are new: written from this + contract's AC1–AC3 before the code they guard was touched, then proven by three mutations (a route + literal, the asker asking twice, a tenth union member) and, for the integration case, by deleting + the refusal from the run handler and rebuilding. + +## Corrected 2026-10-26 — the `@since` on `assignmentsOf` rested on a measurement error + +The table above reads `@since` on `assignmentsOf` → **build**, three rows above +`@since` on `JobAssignment` → **drop — not published**. Both sit on `TaskRunner`, so only one of +them can be right. + +The verdicts were split because I grepped `\bTaskRunner\b` in `src/index.ts`, found a match, and +read it as an export. **It is a line of prose inside a comment.** `src/index.ts` re-exports +`TaskRunnerProvider`; `TaskRunner` is reachable only structurally, as the return type of +`create(payload, context)`, and the package's `exports` map carries only `.` and `./client/*`. So +the package rule — `@since` on *"anything re-exported from `src/index.ts`"* — never applied to a +member of `TaskRunner`, and the "not published" reasoning recorded for `JobAssignment` was the +correct reading for both. + +**The tag is dropped.** `assignmentsOf` carries no `@since`. What a host actually needs to know — it +is now a **required** member, so an existing runner stops compiling — is a release-note fact and +lives in `DEPRECATIONS.md`, under *one permission decision point*. AC4 above is amended to cover +`TranslatorAction` and the guard's `action` field only. + +Nothing else in this step moves: the eight `@since 1.0.0` tags on genuinely published API stand, and +the reason given for the version — eight tags already on this branch — is unaffected. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-25-the-action-reaches-the-guard.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-action-reaches-the-guard.task.md new file mode 100644 index 000000000..f58229132 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-action-reaches-the-guard.task.md @@ -0,0 +1,301 @@ +Build step: 2026-10-25-asking-payload-everywhere.design.md #4 + +## Task restatement + +The published `AccessGuard` is asked one question today — "is this a trusted caller" — with no idea +what the caller is about to do. This step gives the question an **action**, and hardens how the +answer is read. + +Three changes, one seam: + +- **`TranslatorAction`** — a closed, published union of the nine route actions, in `src/types/`. +- **`withAccessCheck(handler, access, action)`** — the action reaches the guard's argument, which + becomes `check({ req, action })`. +- **The answer is read strictly**: only the literal `true` allows, and a guard that throws is a + refusal rather than a crash. + +Covers **R1** (the question carries actor + action + target), **R4** (a new action is one change) and +**R6** (what becomes of `AccessGuard`, and how a host migrates). + +Out of scope, by the design's own sequence: the record question at enqueue (#5), jobs resolving to +documents (#6), the brand at the queue sink (#7), the three-valued identity (#8), the module seam (#9), +the pins and `@since` sweep (#10). + +## Measured, not assumed + +| Claim in the design | Measured today | +|---|---| +| nine routes | **nine** `withAccessCheck(...)` call sites across eight route files (`staleness/route.ts` builds two) | +| two widgets | **two** direct `access.check(...)` calls, both in `.server.tsx` widget components | +| ~thirteen test files | **ten** test files construct or assert the guard; **three** assertions pin the exact argument `{ req }`, all in `withAccessCheck.test.ts` | +| three sites in `apps/` | **three**: `apps/cms/.../plugins/index.ts:351`, `apps/dev/src/payload.config.ts:101`, `apps/dev/.../bootTestPayload.ts:158` | +| `if (!hasAccess)` accepts any truthy value | **confirmed by probe**: a guard answering `"yes"` returns **200** today | +| a throwing guard yields a 500 | **worse than stated**: the rejection **escapes `withAccessCheck` entirely**, because `withAccessCheck` wraps `withErrorHandler` on the *outside*. Probe returned `{"kind":"escaped"}` | + +## What already exists — platform · installed · project · verdict + +| Rung | Found | Verdict | +|---|---|---| +| platform | Payload's `Operation = 'create'\|'delete'\|'read'\|'update'` | **not this vocabulary.** "cancel someone else's bulk job" and "dismiss a staleness marker" have no CRUD equivalent, and the guard is not a Payload access function | +| platform | Payload's `docAccessOperation` | answers the *record* question, which D1 keeps separate from the coarse one. Already used by `mayWrite` | +| project | `AccessGuard` / `AccessGuardRequest` (`src/types/AccessGuard.ts`) | **kept and extended**, per D3 | +| project | `AnyAccessGuard` | unchanged — it ignores its argument | +| project | `TargetSelectionMode = "single" \| "multi"` | the naming idiom for a published union here: lowercase, unnamespaced, kebab for multi-word | +| project | `accessIsNotOptional.pins.ts` | the existing compile-time pin for "no route without a guard" — the place a "no route without an action" pin belongs | +| project | client → server imports already exist (both widget `.export` files import `AnyAccessGuard` from `server/shared`) | a shared asker in `server/shared/access/` **is** reachable from the widgets | + +Nothing new is installed. + +## Decisions + +### D1 — the nine literals, and their names + +**Chosen:** `"enqueue" | "run" | "cancel" | "cancel-by-collection" | "document-status" | +"collection-status" | "document-staleness" | "dismiss-staleness" | "translate-field"`, in +`src/types/TranslatorAction.ts`, re-exported from `src/index.ts`. + +Named for **what the caller is asking to do**, not for the HTTP route that carries it, so the union +survives a route being renamed or moved. Kebab-case and unnamespaced, matching `TargetSelectionMode`. + +**Rejected — namespaced pairs (`"translation:enqueue"`).** No second namespace exists or is planned, +so the prefix is a constant repeated nine times. The package has no precedent for it. + +**Constraint cited:** D8 fixes the union at the nine route actions and makes it plugin-owned and +closed; the naming idiom is `src/types/TargetSelection.ts`. + +### D2 — where the strict reading lives *(settled at the gate)* + +The answer must be read strictly in **three** places, not one: `withAccessCheck` and both widgets, +which today each write their own `if (!hasAccess)` with no `catch`. + +**Chosen — one shared asker**, `askTheGuard(access, req, action): Promise` in +`server/shared/access/`, holding the strict `=== true` and the throw-is-refusal rule once. Three +callers already in the codebase, so **ST1** is satisfied without a hypothetical. + +**Rejected — put the rule only in `withAccessCheck`.** The two widgets would keep a truthy check and +an uncaught throw, so a host whose guard misbehaves is refused on the HTTP route and crashes the admin +page render. Two readings of one answer is the drift this step exists to remove. + +**Constraint cited:** the probe above — the current widget code is `if (!hasAccess) return null` with +no `catch`, measured at `TranslateDocument.server.tsx:19` and `BulkTranslationDashboard.server.tsx:18`. + +### D3 — what the two widgets pass *(settled at the gate)* + +**Chosen — both pass `"enqueue"`**: it is the action each widget's primary control performs, so a +host that hides enqueueing from a user also stops rendering the control that offers it. + +**Rejected — give the widgets their own two literals.** D8 fixes the union at the *nine route +actions*; a widget is not a route, and two UI-only members would make the union mean two things. + +**Rejected — the dashboard passes `"collection-status"`.** It reads status first, but it exists to +start bulk translations; gating the whole widget on the read would leave it visible to someone who may +never use it. + +### D4 — the dead second copy of `AccessGuard` *(settled at the gate)* + +`src/client/shared/types/AccessGuard.ts` declares `AccessGuard` and `AccessGuardRequest` a second +time. **Measured: zero importers** — all four widget files import the root `src/types/AccessGuard.js`. + +**Chosen — delete it in this step.** It is a second declaration of the exact type this step +changes; left alone it silently loses the `action` property and the next reader cannot tell which is +authoritative. + +**Rejected — leave it and change both.** Keeping two declarations in sync by hand is the defect, not +the fix. + +**Rejected — leave it untouched.** It would then differ from the real type, which is worse than today. + +### D5 — the shape of the guard's argument (shape check) + +Triggers that fired: an exported function gains a parameter (`withAccessCheck`); a field is added to a +type that is **published and implemented outside this package** (`AccessGuard.check`'s argument). + +| | `check({ req, action })` *(chosen)* | `check(req, action)` | +|---|---|---| +| cost of the next field of this kind | 0 edits at any call site that destructures | every call site and every implementer | +| states admitted vs produced | 9 admitted, 9 produced — gap **0** | same | +| second caller already in the repo | `withAccessCheck` + two widgets | same | +| breaks outside this repo | only a host that **constructs** the argument; implementers destructuring `{ req }` are unaffected | every implementer's signature | + +**Chosen:** the first — which is what D3 of the design already decided. The shape check confirms the +ADR rather than re-opening it. `action` is **required**, not optional: an optional action would admit +a tenth state ("asked about nothing") that the system never produces. + +### D6 — a throw is a refusal, and nothing is logged away silently + +Adopted from the design's **D7** without re-litigation: fail closed on throw, strict `true`, **no +timeout**. The refusal is a `403`, and the original error is surfaced through the existing +`console.error` channel so a host can still find a broken guard. + +### D7 — a framework navigation signal is not an answer *(added in Phase 5, after review)* + +**Found in review, and it was a regression this step introduced.** Before this change neither widget +caught anything, so a guard calling Next's `redirect()` or `notFound()` propagated and the navigation +happened. `askTheGuard`'s blanket catch swallowed it: the widget vanished and a line went to the log. + +**Chosen:** rethrow an error carrying a `digest`. Verified against the installed Next 16.3.6 — +`redirect-error.d.ts` and `http-access-fallback.d.ts` both mark their errors with a `digest` string, +and `unstable_rethrow` keys on the same class. + +**Rejected — import `unstable_rethrow` from `next/navigation`.** `src/server/` imports nothing from +Next today (measured: zero matches), and `next` is not even a declared dependency of this package. A +property check needs no import and keeps that true. + +**Rejected — rethrow at the two widget call sites instead.** It would put the rule back in three +places, which is what D2 was decided to avoid. + +**Why this is safe on the HTTP path too:** a rethrow there escapes `withAccessCheck` — but that is +exactly what *every* guard throw did before this step (measured in the pre-flight: `{"kind":"escaped"}`). +So the narrow class is no worse than the baseline, and the broad class is now caught where it was not. + +### D8 — the asker is not generic *(corrected in Phase 5, after review)* + +The first implementation declared `askTheGuard(…)`, justified as +"so the host's guard still sees the caller's own request type". **That justification was wrong**, and +a probe proved it: `AccessGuard.check` is itself generic over `R`, so inside a host's `check` the +request is `R` constrained to `AccessGuardRequest` and nothing more — `({ req }) => req.searchParams` +fails to compile both before and after this change. The generic bought nothing a host could see, so +it is gone: `req: AccessGuardRequest`, which every call site satisfies. + +## Acceptance Criteria + +| # | Covers | Criterion | How it is checked (binding) | +|---|---|---|---| +| AC1 | R4 | `TranslatorAction` is a closed union of exactly the nine literals in D1, declared in `src/types/TranslatorAction.ts` and re-exported from `src/index.ts` | a type test using `expectTypeOf` that a tenth literal is **not** assignable, plus `grep` on `src/index.ts` | +| AC2 | R4 | `withAccessCheck(handler, access)` — two arguments — no longer compiles | a new `@ts-expect-error` line in `accessIsNotOptional.pins.ts`; `bun run check-types` is the check | +| AC3 | R1 | Each of the nine routes passes its own literal, and the guard receives it | one assertion per route in that route's existing `route.test.ts`, asserting `check` was called with `{ req, action: "" }` | +| AC4 | R1 | A guard answering a truthy non-`true` value is refused with `403` and the handler does not run | unit test in `withAccessCheck.test.ts`; **mutation-proven** by restoring the truthy check | +| AC5 | R6 | A guard that throws is refused with `403`; the rejection does not escape and is not a 500 | unit test in `withAccessCheck.test.ts`; **mutation-proven** by removing the `catch` | +| AC6 | R1 | Both widgets pass their literal, render nothing when refused, and render nothing when the guard throws | unit tests on the two `.server.tsx` components | +| AC7 | R6 | A host guard written as `({ req }) => boolean` still compiles untouched | the three `apps/` sites type-check unchanged; `bunx turbo run build` across the workspace | +| AC8 | R6 | `src/client/shared/types/AccessGuard.ts` is gone and nothing imports it | `grep` returns nothing; `check-types` and the build stay at 0 | +| AC9 | R6 | The baseline holds: real `turbo build` green, integration **225 passed / 8 skipped** | run both; any moved count explained in the report | + +## Pre-flight — each check run against the CURRENT, untouched tree + +| # | Polarity | Result now | Evidence | +|---|---|---|---| +| AC1 | must FAIL | **fails** | `grep -rn TranslatorAction src/ ../../apps` → no matches | +| AC2 | must FAIL | **fails** | `withAccessCheck(handler, guard)` is the *positive control* in `accessIsNotOptional.pins.ts` today | +| AC3 | must FAIL | **fails** | no route passes a third argument; `grep "withAccessCheck("` shows nine two-argument calls | +| AC4 | must FAIL | **fails** | probe: a guard answering `"yes"` returned **200** | +| AC5 | must FAIL | **fails** | probe: the rejection **escaped** — `{"kind":"escaped","message":"guard exploded"}` | +| AC6 | must FAIL | **fails** | neither widget passes an action; neither catches | +| AC7 | must PASS | **passes** | the three `apps/` sites build green today | +| AC8 | must FAIL | **fails** | the file exists | +| AC9 | must PASS | **passes** | 193 files / 2502 checks, build green, 225/8 | + +## Risk notes + +**High risk, and it binds.** This changes a **published, required** interface (`AccessGuard`, required +since 0.14.0) and the single chokepoint every HTTP route passes through. + +- **The accepted break (D3):** a host that *constructs* the guard's argument itself must add `action`. + A host that merely *implements* `check({ req })` is unaffected — all three `apps/` sites are of the + second kind, which is the evidence that the common case is safe. +- **The behaviour change hosts will notice:** a guard that throws now answers `403` instead of + crashing the request. Strictly safer, and it needs a release note — which belongs to step #10, so + this step records the obligation rather than writing the note. +- **A guard returning a non-boolean today silently allows.** After this step it silently refuses. That + is the intended direction, but it can turn a working host deployment into a 403, so it must be in + the same release note. +- **Three review angles are required** in Phase 5b, and a test is required before done. +- **The regression sweep is mandatory and evidenced**: it must name what was grepped and how many call + sites were read. + +## Human choices + +**2026-10-25, at the gate.** All three questions were put with a recommendation, and all three +recommendations were taken. + +1. **One shared asker** (D2). `askTheGuard(access, req, action)` holds the strict `=== true` and the + throw-is-refusal rule once, for the route wrapper and both widgets. The alternative — strictness in + `withAccessCheck` only — was declined because it would leave a misbehaving host guard refused on + the HTTP route and *crashing the admin page render*, which is two readings of one answer. +2. **Both widgets pass `"enqueue"`** (D3). The two alternatives offered were the dashboard passing + `"collection-status"` (declined: the widget exists to start translations, so gating it on the read + leaves it visible to someone who can never use it) and two UI-only union members (declined: it + breaks D8's "nine route actions" and makes the union mean two things). +3. **Delete the dead copy** `src/client/shared/types/AccessGuard.ts` (D4). Zero importers, measured. + Keeping two declarations in sync by hand was declined as the defect rather than the fix. + +## Review log + +**2026-10-25 — comment audit (closing Phase 3).** 10 candidate blocks, 10 verdicts: 4 KEEP (the three +compile-time pins, whose comments *are* the pin names, and the "written out, not derived" note in the +union's type test), 5 REWRITE-COMMENT, 1 DELETE. The three docblocks it cut hardest were the new ones: +`askTheGuard` 9 lines → 2, `TranslatorAction` 10 → 4, `AccessGuard.check` 8 → 3. + +Two of its cuts were substantive rather than stylistic: +- `{@link AccessGuard}` in `TranslatorAction.ts` had **no referent** — that file imports nothing, and + adding a type-only import would have created a cycle, since `AccessGuard.ts` imports it. Replaced + with backticks. +- The `check` docblock said a refusal means `403`. Since this step that is **incomplete**: the same + answer now also hides the admin widgets. Both that docblock and the interface's own headline + ("Gate for the translation API endpoints") were corrected to say so. + +It also raised three things outside its own remit, two of which were real and are fixed: `as never` +casts in the new widget fixtures (the owner's rule forbids them — replaced with named +`as unknown as ` casts) and a `console.error` spy restored in the test body rather than in +`afterEach`, so a failing assertion would have leaked it. The third led to D8 below. + +**2026-10-25 — Phase 5b, three independent angles**, run because the Risk notes classify this high +risk: it changes a published, required interface. + +| Angle | Asked to break | Outcome | +|---|---|---| +| the published contract | every host implementer shape; the generic; strictness; the throw path | additive as claimed; **one unrecorded regression found** (R1) | +| the tests | mock interception, revert-sensitivity, shared spies, compile-time checks | no toothless check; **one real gap** (R2) | +| shape and placement | cycles, barrels, the `client/ → server/` edge, the published surface | no cycle; **barrel surplus found** (R3); the generic disproved (D8) | + +### R1 — a framework navigation signal was being swallowed (found, fixed; now D7) + +`askTheGuard`'s catch turned **every** throw into a refusal, including Next's `redirect()` and +`notFound()`, which are non-local returns rather than failures. Before this step the widgets caught +nothing, so those propagated and worked. Verified against the installed Next 16.3.6 rather than from +memory: both `redirect-error.d.ts` and `http-access-fallback.d.ts` mark their errors with a `digest`, +and `unstable_rethrow` keys on the same class. Fixed by rethrowing a `digest`-bearing error; one new +check, proven by two mutations in opposite directions (catching the signal reddens only it; rethrowing +everything reddens only the ordinary-throw check). + +### R2 — no check proved the allowed path renders anything (found, fixed) + +All eight widget checks would have passed on a widget that returned `null` unconditionally: the one +positive case only inspected the guard call. Added "renders something when allowed" for both widgets. + +### R3 — the change added four barrel re-exports with no importers (found, fixed) + +The package's `CLAUDE.md` makes this mechanical: find the files importing the barrel, collect the +names, subtract. `TranslatorAction` had been added to three barrels and `askTheGuard` to one, and +nothing imported any of them through a barrel — both widgets and `withAccessCheck` import by path. +All four removed. The published re-export from `src/index.ts` stays: a host writing a per-action +policy table needs to name the type, and the alternative is +`Parameters[0]["action"]`. + +### Recorded, not fixed here + +- **`@since` on `TranslatorAction`** — the package requires it on anything re-exported from + `src/index.ts`. It depends on the release version, and the design puts the `@since` sweep in step + #10. Owed there. +- **The release note.** Three host-visible changes need one: a guard returning a truthy non-boolean + now refuses where it allowed; a guard that throws now refuses where it escaped; and the deep import + `@focus-reactive/payload-plugin-translator/client/shared/types/AccessGuard`, undocumented but + resolvable through the `./client/*` export, stops resolving. `README.md`'s "Access control" section + still says only "Return `false` and the request is rejected" — the README is the owner's, so the + text is proposed rather than written. Step #10. +- **The union's naming is not uniform** — `enqueue`/`run`/`cancel` are verbs, `document-status`/ + `collection-status`/`document-staleness` are noun phrases, and `dismiss-staleness`/`translate-field` + are verb-plus-object. A host cannot guess a tenth name from the pattern. The names are settled (D1, + owner-approved), so this is an observation for a later step, not a defect. + +### Verification after every fix + +`check-types` 0 · oxlint 43 warnings / 0 errors (baseline) · package suite **196 files / 2523 checks**, +up from 193 / 2502 by exactly +3 files and +21 checks (5 asker + 1 navigation signal + 2 wrapper + +10 widget + 2 type + 1 from the per-source-file throw test, since `askTheGuard.ts` is a new file under +`src/server`) · real `turbo build` green, forced and uncached · integration **225 passed / 8 skipped**, +unchanged · `sp-diff-checks` clean · `sp-lint-delta` introduced nothing. + +**Out of scope and not caused here:** `apps/dev`'s own `build` script fails on a `SeoFieldPaths` +mismatch from `packages/payload-plugin-seo` and a vitest config overload. Both files are untouched by +this change, and the integration suites pass regardless. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-25-the-asker-moves-down.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-asker-moves-down.task.md new file mode 100644 index 000000000..d8fab66a6 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-asker-moves-down.task.md @@ -0,0 +1,186 @@ +Build step: 2026-10-25-asking-payload-everywhere.design.md #1 + +## Task restatement + +Two pure moves, so that the five entry points that must start asking Payload's rules can reach the +function that asks, without any of them importing across a feature boundary. + +**Move 1.** `mayWrite`, `rebuildRequester` and `RebuiltRequester` leave +`features/translate-document/translationPermission.ts` for a new `server/shared/access/permission.ts`. + +**Move 2.** `visibleIds` and `getAllCollectionIds` leave `features/_lib/collection-utils.ts` for a new +`server/shared/payload/collections.ts`. `isCollectionAvailable` stays where it is. + +Covers **R7** of the design: the existing ask keeps working, unchanged, on every path. Nothing from +steps 3–10 is in scope — no new question is asked anywhere, and no handler gains a check. + +## What already exists — platform · installed · project · verdict + +Nothing new is built. Both targets are existing functions moving between existing directories. The +only judgement is where the pieces they drag with them should land. + +## Decisions + +### D1 — the two error classes move too, because they have to + +`mayWrite` throws `PermissionCheckFailed` (`translationPermission.ts:69`) and `rebuildRequester` +throws `RequesterMissing` (`:138`). Both classes live in `features/translate-document/`. + +**Chosen:** `PermissionCheckFailed.ts` and `RequesterMissing.ts` move to `server/shared/access/` +alongside the functions that throw them. + +**Rejected — leave them and import upward.** `shared/access/permission.ts` would then import from +`features/translate-document/`, which is the exact upward edge this move exists to remove. The move +would create the problem it was meant to solve. + +**Constraint cited:** nothing else throws or catches either class — +`grep` finds them only in `translationPermission.ts`, its own test, and +`shared/payload/__tests__/swallowOrThrow.test.ts`. + +**Side effect, and it is the good kind:** that last file currently reaches +`../../../features/translate-document/PermissionCheckFailed.js` — a test in `shared/` importing from +`features/`. After the move it imports a sibling. The design named this as an opportunity; it turns +out not to be optional. + +### D2 — the three mocked test files re-point, they do not gain a shim + +`handler.access`, `handler.enforce` and `handler.spend` each call `vi.mock("../translationPermission.js")` +and assert on the arguments `mayWrite` and `checkTranslationPermission` receive. A module's exports are +replaced for its *importers*; once `targetDocument.ts` imports `mayWrite` from `shared/access/`, mocking +the old path stops intercepting it. This was measured on this branch two tasks ago. + +**Chosen:** the three files re-point their mock to the new path. Assertions are untouched. + +**Rejected — keep a re-export in `translationPermission.ts` so the old mock path still works.** That is +a shim whose only consumer is a test, and it would leave the feature as the import path for a function +that no longer lives there — so a later reader would not learn from the code where the asker is. + +**Constraint cited:** the criterion this task is graded on is *assertion* lines unchanged, not test +files untouched. A mock target is setup. + +### D3 — whether `checkTranslationPermission` moves as well + +**This is the gate question.** The design's D4 names three things to move and is silent about the +fourth. After D1, what is left in `translationPermission.ts` is one eight-line wrapper that imports +everything it uses from `shared/access/`. + +Both options are buildable and both are honest; see the gate message. + +## Acceptance Criteria + +| AC | Covers | Criterion | How it is checked | Passes when | +|---|---|---|---|---| +| AC1 | R7 | The asker lives in the shared layer | `server/shared/access/permission.ts` exists and declares `mayWrite`, `rebuildRequester`, `RebuiltRequester`; `features/translate-document/translationPermission.ts` declares none of them | file present, 3 declarations there, `0` in the feature | +| AC2 | R7 | No file under `server/shared/` imports from `server/features/` | `grep -rln 'features/' src/server/shared/` | only `accessIsNotOptional.pins.ts` remains (it pins the plugin's own config surface); `swallowOrThrow.test.ts` is gone from the list | +| AC3 | R7 | The visibility helpers live in the shared layer | `server/shared/payload/collections.ts` declares `visibleIds` and `getAllCollectionIds`; `features/_lib/collection-utils.ts` declares only `isCollectionAvailable` | 2 and 1 | +| AC4 | R7 | Behaviour is unchanged | `bunx vitest run`, then count changed assertion lines across every pre-existing test file | **2490 or more** passing with the delta explained; **0** changed assertion lines | +| AC5 | — | The real declaration build passes | `bunx turbo run build --filter='./packages/payload-plugin-translator'` from the root | exit 0 | +| AC6 | — | No new type errors, no new analyzer findings | `bun run check-types` in the package and in `apps/dev`; `sp-lint-delta.sh -- bunx oxlint .` | `0` each; nothing introduced | +| AC7 | R7 | Access control still holds end to end | rebuild, then from `apps/dev`: `bun run test:integration` | **225 passed / 8 skipped** | +| AC8 | — | Nothing but a move happened | `git diff` of the moved function bodies against their previous text | identical apart from import lines | + +## Pre-flight + +| AC | Result now | Kind | +|---|---|---| +| AC1 | `shared/access/permission.ts` absent; both functions declared in the feature | must change — fails now ✓ | +| AC2 | **two** files under `shared/` import `features/` | must change — fails now ✓ | +| AC3 | `shared/payload/collections.ts` absent; both helpers in `_lib` | must change — fails now ✓ | +| AC4 | 190 files / **2490** passing | invariant — passes now ✓ | +| AC6 | `check-types` 0; oxlint 43 warnings, 0 errors | invariant — passes now ✓ | +| AC5, AC7 | build green and 225/8 at the last run on this tree | invariant — re-run in Phase 5 | + +**On AC4's number.** A pure move should leave the count alone, but the package has a check that runs +once per source file under `src/server` and `src/core`. Two or four new files therefore add two or +four checks. Any other movement is a finding to report, not a number to adjust. + +## Risk notes + +- **Low risk**, and it binds: no behaviour changes, no new question is asked, the public surface is + untouched, and the existing suites cover every moved function. Phase 5 runs one reviewer, not three. +- **The mock trap is the whole risk.** Three test files mock by module path. If a mock is re-pointed + wrongly, the test still passes — it simply stops intercepting, and the assertion then checks a call + that the real code made for a different reason. Each re-pointed mock needs its interception proved, + not assumed: break the real function and confirm the test goes red. +- **`enqueue-translation/__tests__/handler.test.ts` mocks `../../_lib/collection-utils.js`** and + supplies `getAllCollectionIds`. Move 2 moves that function out, so the same trap applies there. +- Not in scope: any new ask, the action union, the brand at the queue sink, the module seam — steps + 3–10. + +## Human choices + +**2026-10-25, at the gate.** *"Перевезти тоже, файл удалить"* — D3 resolves to moving +`checkTranslationPermission` as well, so `features/translate-document/translationPermission.ts` is +deleted and everything about asking the host's rule lives in one file in the shared layer. + +The reason that decided it is not tidiness: the rule *"with no requester, allowed"* lives inside that +wrapper, it is a plugin-wide rule rather than a translate-document one, and build step 8 of the design +is the step that changes it. Leaving it in a feature would mean step 8 editing a feature file to +change a rule that governs every path. + +The alternative — follow the design's D4 literally, move three names, leave the wrapper — was offered +and declined. Its cost was a one-function file importing everything it uses, and two mock paths in the +tests instead of one. + +## Result — every criterion walked with its declared check + +| AC | Evidence | Verdict | +|---|---|---| +| AC1 | `shared/access/permission.ts` declares all four names; `features/translate-document/translationPermission.ts` is deleted | met | +| AC2 | one file under `shared/` still imports `features/` — `accessIsNotOptional.pins.ts`, which the criterion named; `swallowOrThrow.test.ts` is off the list | met | +| AC3 | `collections.ts` declares 2; `collection-utils.ts` declares 1 | met | +| AC4 | **2491 passing** (was 2490). The delta is one check, proved rather than assumed: adding a scratch file under `src/server` took `every-throw-is-a-translator-error` from 260 to 261 and removing it took it back, so the per-file suite counts one check per source file and this change nets one new file. **0** changed assertion lines | met | +| AC5 | `turbo run build` exit 0 | met | +| AC6 | `check-types` 0 in the package and in `apps/dev`; `sp-lint-delta` `mode=two-run`: nothing introduced | met | +| AC7 | `bun run test:integration`: **225 passed / 8 skipped**, unchanged | met | +| AC8 | `diff` against the pre-move copies: `permission.ts` differs in **two import lines only**; the moved visibility helpers are byte-identical; both error classes are byte-identical to `HEAD` | met | + +`sp-diff-checks`: clean, 5 checks, no findings. + +**The predicted trap fired, and loudly.** `enqueue-translation/__tests__/handler.test.ts` mocked +`_lib/collection-utils.js` and supplied `getAllCollectionIds`; move 2 took that function elsewhere, so +the mock stopped intercepting and the case **failed**. That is the good outcome — a mock that stops +intercepting can just as easily leave a green test that checks nothing. Interception was then proved +for `handler.access.test.ts` by mutation: pointing its mock at a module that does not exist took five +of seven cases red. + +## Review log + +### 2026-10-25 — comment audit: not run, and why + +The change added **zero** comments. Every docblock travelled verbatim with the function it belongs +to, which a byte-level diff against the pre-move copies confirms. One comment was deleted — the +narration `// Mock collection-utils` above a mock block that the split made inaccurate anyway. The +mechanical half of the audit was done inline instead: the only citation inside a moved file is +`collections/operations/find.js`, a Payload-internal path the move does not affect. + +### 2026-10-25 — one review angle (low risk) + +**No findings.** The reviewer diffed both new files against the pre-move copies and confirmed the +bodies are byte-identical apart from import paths, checked that the moved test's other relative paths +still resolve from their new directory, and confirmed the two error classes are unchanged from `HEAD`. + +The part worth recording is the mock check, because that was this task's only real risk. All four +mocking suites were verified by resolving each mock path by hand against the import the production +code now makes: + +| suite | mocks | the module the code imports | +|---|---|---| +| `handler.access`, `handler.enforce`, `handler.spend` | `../../../shared/access/permission.js` | the same file `targetDocument.ts` imports | +| `enqueue-translation/__tests__/handler.test.ts` | `_lib/collection-utils.js` **and** `shared/payload/collections.js` | the handler imports one name from each | + +The reviewer also confirmed what a split could have changed silently and did not: `mayWrite` and +`checkTranslationPermission` are still in one file, so mocking one from outside still cannot reach +the other, and the three suites mean exactly what they meant before. + +**Found by the reviewer, not fixed here:** `features/_lib/index.ts` re-exports `collection-utils.js`, +which now holds one function, and nothing imports that barrel — every consumer imports the file +directly. A dead barrel, recorded below. + +## Follow-ups — recorded, not done here + +1. **`features/_lib/index.ts` is a dead barrel.** It re-exports one function nobody takes through it. +2. **`shared/access/__tests__/accessIsNotOptional.pins.ts` still imports from `features/`.** It pins + that the plugin's config refuses to build without an access guard, so the import is of the plugin's + own surface rather than of a feature's internals. Left alone; it is the one remaining + `shared → features` edge and the criterion names it. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-25-the-brand-at-the-sink.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-brand-at-the-sink.task.md new file mode 100644 index 000000000..241891123 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-brand-at-the-sink.task.md @@ -0,0 +1,252 @@ +Build step: 2026-10-25-asking-payload-everywhere.design.md #7 + +## Task restatement + +Steps 3–6 added asks. Every one of them is a call a later edit can delete, and nothing would notice. +This step makes deleting one **not compile**: `TaskRunner.enqueue`, `cancel` and `run` stop accepting +a plain task or handle and accept only a value that passing the ask produces. + +Covers **R2**. Out of scope: the three-valued identity (#8), the module seam (#9), pins and `@since` +(#10). + +## Measured, not assumed + +| Claim | Measured | +|---|---| +| the blast radius is a sweep | **no** — 7 files call `.enqueue(`, of which **2 are production**; `.cancel(` and `run` add 3 more production sites. Five production mint sites in total | +| host runner implementations break | **no, proven by probe**: TypeScript method parameters are bivariant, so an implementation declaring the *wider* `TaskInput[]` still satisfies an interface demanding the branded narrower type. A scratch file reproducing the exact shape type-checked clean | +| a string brand is forgeable | **yes, and the project already learned it** — `ApprovedWrite`'s docblock records that a symbol key is used precisely because "unlike a string brand no literal can name it" | +| the hook must not ask | settled in the design's D5: a host callback that throws inside the editor's `afterChange` destroys the save | + +## What already exists — platform · installed · project · verdict + +| Rung | Found | Verdict | +|---|---|---| +| project | `ApprovedWrite` (`targetDocument.ts`) — type-only `declare const … unique symbol`, one cast at a module-private mint | **the idiom to copy.** It brands *data that travels*, which is what a task and a handle do | +| project | `ApprovedSource` — a **runtime** `unique symbol` minted by a literal, no cast | the stronger form, but it binds a value that never leaves the module. A task is reshaped on its way into storage, so a runtime key could be dropped while the type still claimed it | +| project | `targetDocument.contract.test.ts` | the pin to copy: it proves neither brand is assignable from a plain object, nor from one guessing the brand | +| project | `refusedTargets` (step 5) | **the ask**. This step does not add a second one; it makes the ask the only place a branded value appears | + +Nothing new is installed. + +## Decisions + +### D1 — the **ask** mints, not the handler + +**Chosen:** the branded values come out of functions in `shared/access/` that do the asking. +A handler cannot produce one except by calling them. + +**Rejected — handlers mint after asking** (the design's own wording). A shared minter exported for +three feature modules to call *is* a forge: anything may call it, so the brand would prove only that +someone typed its name. The guarantee this step exists for would be a speed bump. + +**Constraint cited:** `approveWrite`, the only minter of `ApprovedWrite`, is **module-private** — the +type is exported, the minter is not. Three feature modules cannot share a private minter, so the mint +must move to where the ask already lives. + +### D2 — a type-only brand for both, one cast each + +**Chosen:** `declare const … unique symbol` for `AuthorisedWork` and `AuthorisedHandle`, with exactly +one cast per brand, both inside `authorised.ts`. + +**Rejected — a runtime symbol, as `ApprovedSource` uses.** A task is copied and reshaped on its way +into the stored row (`planEnqueue`, `toPayloadFields`). A transform that rebuilds the object would +drop a runtime key while the **type** still claimed the brand — a silent divergence. A handle is a +`string` and cannot carry a property at all, so the runtime form is not even available there. + +**Rejected — a string brand** (`{ __brand: "Authorised" }`). Proven forgeable in this repo already; +`ApprovedWrite`'s docblock records why it was replaced. + +### D3 — two brands, not one + +`AuthorisedWork` has **2** mint sites (the enqueue route, the auto-translate hook); +`AuthorisedHandle` has **3** (cancel, cancel-by-collection, run). Both clear the ≥2-callers rule on +their own, so neither is a type with one caller. + +**Rejected — one brand for all three methods.** `enqueue` takes tasks and `cancel`/`run` take +handles; one brand over both would let a handle be passed where work is expected. + +### D4 — the hook's non-ask becomes a named value + +`authorisedByTheSave()` mints `AuthorisedWork` **without** asking, and its docblock carries the +reason (D5: asking inside `afterChange` risks destroying the editor's save). Adopted from the design +unchanged, and this is what makes the non-ask **visible code** rather than the absence of a call. + +### D5 — the field route gains no brand + +It writes nothing through the runner, so there is no sink to guard, and a brand there would have one +mint site. Settled in the design (**ST1**); recorded here so Phase 5 does not read its absence as an +oversight. + +### D6 — shape check + +Triggers that fired: a parameter type narrows on **three methods of a port implemented outside this +package**. + +| | branded parameter types *(chosen)* | a second `authorisedEnqueue` method beside the existing one | +|---|---|---| +| cost of the next guarded sink | one brand, already written | another method, another deprecation | +| states admitted vs produced | only values the ask produced — gap **0** | both guarded and unguarded paths stay reachable forever | +| second caller already in the repo | 2 and 3 mint sites respectively | same | +| breaks outside this repo | **implementers: none** (bivariance, proven); callers of the runner: yes, and they must ask | nothing, and nothing is guaranteed either | + +## Acceptance Criteria + +| # | Covers | Criterion | How it is checked (binding) | +|---|---|---|---| +| AC1 | R2 | `runner.enqueue` does not accept a plain `TaskInput[]` | a `@ts-expect-error` pin; `bun run check-types` is the check | +| AC2 | R2 | `runner.cancel` does not accept a plain `string[]`, and `run` no plain `string` | two more pins in the same file | +| AC3 | R2 | Neither brand is assignable from an object guessing it | a type test in `__tests__`, mirroring `targetDocument.contract.test.ts` | +| AC4 | R2 | The only way to get `AuthorisedWork` from a request is through the ask | `grep`: every production `enqueue`/`cancel`/`run` call site takes its argument from `authorised.ts` | +| AC5 | R2 | The hook's non-ask is a named value, not a missing call | the hook calls `authorisedByTheSave`, and its docblock says why | +| AC6 | R2 | A refused document still does not reach the queue | the step 5 and 6 access suites stay green, unchanged | +| AC7 | R6 | A host runner implementing the wider parameter still compiles | a pin declaring such an implementation | +| AC8 | R5 | The baseline holds | real `turbo build` green; integration **225 passed / 8 skipped** | + +## Pre-flight — against the CURRENT tree + +| # | Polarity | Now | Evidence | +|---|---|---|---| +| AC1–AC3, AC5 | must FAIL | **fail** | `grep AuthorisedWork src` → nothing; the methods take plain types | +| AC4 | must FAIL | **fails** | all five production sites pass unbranded values | +| AC6, AC8 | must PASS | **pass** | 201 files / 2552 checks, build green, 225/8 | +| AC7 | must PASS | **passes** | proven by probe before any change | + +## Risk notes + +**High risk, and higher than steps 5–6.** + +- **Callers break, implementers do not.** Proven by probe, and the distinction is the whole + migration story. A host calling `runner.enqueue(tasks)` directly must now obtain the tasks from the + ask. That owes a release note at step 10. +- **The brand is a compile-time guarantee only.** It proves a call passed through the ask; it cannot + prove the ask answered honestly. The runtime guarantee stays where it was — in `refusedTargets` and + the worker's own check. +- **One cast per brand is the point, and more than one would undo it.** Phase 5 counts them. +- A test is required before done; the regression sweep names what was grepped and how many call sites + were read; Phase 5b runs several angles. + +## Human choices + +**2026-10-25.** The owner directed an uninterrupted run through steps 5–10, stopping only at a +genuine fork, and named the breaking-change question as the likely one. **It did not become a fork:** +a probe showed host implementations are unaffected, so there is no "break everyone now versus a +deprecation window" choice to put. D1 departs from the design's wording — the design said the +handlers mint — because a minter shared by three feature modules must be exported, and an exported +minter is a forge. Recorded rather than asked, since it strengthens the design's own stated intent. + +## Criteria results + +| # | Outcome | Evidence, by the check the criterion named | +|---|---|---| +| AC1–AC2 | met | 4 `@ts-expect-error` pins in `theQueueDemandsAnAsk.pins.ts`; **mutation**: widening `enqueue` back to `TaskInput[]` turns one into "Unused '@ts-expect-error' directive" and `check-types` fails | +| AC3 | met | 6 checks in `authorised.contract.test.ts`; **mutation**: replacing the symbol brand with a string brand reddens the forgery check | +| AC4 | met | all five production sinks read `authorised`, `thisJob` or `authorisedByTheSave(tasks)` | +| AC5 | met | `AutoTranslateEnqueue.hook.ts:80` calls `authorisedByTheSave`, whose docblock carries the reason | +| AC6 | met | the step 5 and 6 access suites are unchanged and green; **mutation**: dropping the ask in `authorisedWork` reddens 3, in `authorisedHandles` 6 — across all three job routes | +| AC7 | met | `aRunnerFromBefore` in the pins file declares the wider parameter types and compiles | +| AC8 | met | real `turbo build` green, forced; integration **225 passed / 8 skipped** | + +## Review log + +**2026-10-25 — the measurement that decided the step.** Narrowing the three methods produced **29 +type errors across 7 files**: five production sinks and two test files. The compiler enumerated every +place that reached the queue, which is the payoff — a later edit that deletes an ask now fails to +compile rather than passing silently. + +**`/sp-complexity`.** Neighbourhood 9 files; history mute for the new ones (born this commit), so no +edit. "A fifth named path to the queue": CHANGE 2, READ 3, STATE 4 → 5 — and the states *are* the +domain, one per answer to "what authorised this". "Another queue method narrows": CHANGE 1 in the +interface, and the **compiler** produces the site list, so READ does not grow with it. **No findings.** + +**`/sp-abstraction`.** Audited by the author. + +- **Finding, fixed:** the `AuthorisedWork` docblock claimed "every value comes from a function in this + module". **False** — `TaskRunner.invariants.ts` mints two with casts. Corrected to say what the + brand actually guarantees: a guard against *forgetting*, not against intent. +- **A suspicion of mine that measurement refuted:** `authorisedWork` returns bare document ids while + holding composite keys internally, so I expected an ambiguity when two collections share an id. + There is none — `collection_slug` is a single field, so one enqueue request covers one collection. +- **Observation:** the brand types are not re-exported from `src/index.ts`, so a host cannot name + them. Not a break (bivariance lets them declare the wider type), but it owes a release-note line. + +**`/sp-red-test` in audit mode.** No stub was written: the module also exports the types the whole +tree compiles against, so it cannot be stubbed out — a limit of the technique, not a finding. Three +mutations instead. + +- **`theQueueDemandsAnAsk.pins.ts` is genuinely type-checked**, proven by injecting a deliberate type + error into it and watching `check-types` report it. `tsconfig.check.json` includes `src/**/*`. +- Dropping the ask in `authorisedWork` reddens 3 checks; in `authorisedHandles`, **6** — three on + cancel, two on cancel-by-collection, one on run. All three job routes notice. +- **No toothless check found.** + +**`/sp-review-comments`.** 11 candidate blocks, 11 verdicts + 2 MISSING: 3 KEEP, 3 REWRITE-COMMENT, +2 DELETE, 3 REWRITE-CODE. Inline density 0.6 per 10 — fine; the weight was in docblocks on internal +helpers. + +The three REWRITE-CODE verdicts are the substance, and each replaced prose with structure: + +- **A comment explained that a verdict is keyed by collection *and* id.** Now it cannot be otherwise: + `refusedPerCollection` returns a lookup closure over a `Map>`, so an id + is only reachable *through* its collection. `documentKey`, the composite string and two comments all + disappeared. **Mutation**: flattening the map so ids are looked up without their collection reddens + exactly the "keeps two collections apart" check. +- **A comment explained that a helper deduplicates.** Renamed to `distinctDocumentsByCollection`, so + the name promises it — and its projection parameter went, because both callers passed the same one. +- **A docblock named `asWork`, which did not exist** — the cast was inline inside `input()`. Fixed the + code rather than the prose: `asWork` now exists beside `asHandle`, the two forgeries sit together + under names that say "cast", and the docblock is gone. + +Also from the MISSING entries: `SyncRunnerProvider.test.ts` had been reaching the queue through +`authorisedByTheSave` — a path whose docblock argues from the editor's `afterChange`. The test is not +a save. It now forges with a local cast, like its neighbour, so the production path's name stays tied +to the real hook. + +### Verification after every fix + +`check-types` 0 · oxlint **43 warnings / 0 errors** — back to baseline after removing three imports +this rewiring orphaned · package suite **202 files / 2559 checks**, up from 201 / 2552 by exactly +1 +file and +7 checks (6 brand-contract checks, and 1 from the per-source-file throw test for +`authorised.ts`) · real `turbo build` green, forced · integration **225 passed / 8 skipped** · +`sp-diff-checks` clean · `sp-lint-delta` introduced nothing. + +**The step removed more lines than it added** — 106 inserted against 137 deleted. The brand replaced +hand-written checks rather than accumulating on top of them. + +### Owed to step 10 + +`@since` on `AuthorisedWork`, `AuthorisedHandle` and the three narrowed methods · a release-note line +for callers of the runner (implementers are unaffected) and for the brand types not being published. + +## Amended 2026-10-26 — the brand split `dist` from `src`, and one consumer was mixing them + +The brand is a `unique symbol`, so the built copy of `AuthorisedWork` and the source copy are +unrelated types. That is the mechanism working as designed — but it means any file importing the +package by name *and* by a path into `src/` holds two mutually unassignable `TaskRunner`s. + +One file did: `apps/dev/src/integration/translator/task-runner-contract.int.test.ts` took +`createPayloadJobsRunner` from the published name and `assertTaskRunnerContract` from a deep path +into `src/server/modules/task-runner/__tests__/`. Repo-wide `check-types` has been failing on it +since this step landed, and nothing caught it: the per-step verification ran the **package's** +`check-types`, which does not see `apps/dev`. + +**Fixed by publishing the suite rather than by weakening the brand.** `TaskRunner.invariants.ts` +moves from `__tests__/` to `src/testing/` and ships from a new `./testing` entry point, so both +imports resolve to one copy. The brand is untouched, and no minter is exported: `asWork` and +`asHandle` stay private to the module, and the brand types appear nowhere in the emitted +declarations — `grep Authorised dist/testing/*.d.ts` is empty. The suite does hand branded work to +the runner under test, as any caller of `enqueue` must; what it does not hand out is a way to mint +one. + +**It also makes a promise true that could not be kept.** `TaskRunner.interface.ts` told a runner +author to call that suite once, while the suite lived in a directory the build excludes. The +docblock now names the published path. + +`vitest` is declared an optional peer dependency. The suite imports it at module level, so it is +needed at **import** time: a non-test module importing the entry point fails at load, not when the +function is called. + +**The verification step of every contract on this branch was too narrow.** It ran the package's own +`check-types`, its suite, its lint and the real build, plus the integration suites — but never +`turbo run check-types` across the repo, which is the only thing that type-checks `apps/dev` +against the built package. It is green now, 9 of 9. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-25-the-document-asks-before-it-writes.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-document-asks-before-it-writes.task.md new file mode 100644 index 000000000..efe9998d0 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-document-asks-before-it-writes.task.md @@ -0,0 +1,296 @@ +# The document asks before it writes + +## Task restatement + +`translate-document/handler.ts` holds three unrelated jobs: it runs the translation, and it +also knows who the requester is, what the collection's rules say about them, and how to write +the document. The owner wants the second and third out of the handler, and wants the check to +be **impossible to forget, move, or quietly re-condition** — enforced by the contract rather +than by test coverage. + +Extract the requester identity, the permission condition and both document writes into one +unit built once per run: the target document as this requester sees it. The handler then reads +and writes through it and never names a permission. + +**No behaviour changes.** The same three questions are asked, about the same data, in the same +order, and Payload receives the same arguments. + +## What already exists — platform · installed · project · verdict + +| Rung | Found | Verdict | +|---|---|---| +| platform (Payload) | `overrideAccess: false` + `user` on the write already makes Payload apply the collection's rules (`enforcedAtTheWrite`, `handler.ts:55-60`) | **already used.** The explicit pre-check is not what makes the write safe; it decides *when* the refusal lands — before the provider is paid, and before a `Forbidden` inside the editor's transaction kills their save (`enforcedAtTheRead.ts:5-8`) | +| platform | `docAccessOperation` — Payload's own evaluator, the one behind `/api//access` | **already used**, `translationPermission.ts:53` | +| project | `Requester` — brand + sole constructor, `RequestScope.shapes.ts:14-34`, with the stated reason *"so it can never drift"* | **adopted as the precedent** for the approval brand | +| project | `IdPath` — same idiom, `idPath.ts:22`, minted at `idPath.ts:77` | same | +| project | `TargetLayer` — a union whose dangerous combination is unbuildable, `targetLayer.ts:17-34` | **constrains the design**: the new write surface must not reintroduce those states as an options bag | +| project | `fetchSourceDocument`, `enforcedAtTheRead`, `freshReq` | moved, not rewritten | + +Nothing new is installed. The one new concept is the approval brand, below. + +## Decisions + +### D1 — one unit holding identity, condition and both writes + +**Chosen:** a new `translate-document/targetDocument.ts`, opened once in `beginRun` from +`payload + target + scope`, which rebuilds the requester, reads both locales, and performs both +writes. + +**Rejected — extract only the writer, leave the reads in the handler.** The handler would still +import `enforcedAtTheRead`, `fetchSourceDocument` and `freshReq`, and would still have to hold +`requester` to pass to the read. The requester would then live in two places, which is the thing +the extraction exists to stop. + +**Rejected — a port in `core/` with a Payload adapter in `server/`.** One implementation, one +caller. The task brakes forbid an abstraction without two real callers, and there is no second. + +**Constraint cited:** `run.requester` has exactly five readers today — `handler.ts:210` (source +read), `:220` (target read), `:244` (`permissionQuery`), `:335` (`refuseUnlessWriteAllowed`), +and `:64` via `writingTo` — and every one of them is a read or a write of this one document. +Nothing else in the file touches it. + +**Honest note on reuse:** the new unit has **one** caller. It is a relocation driven by a stated +requirement, not a shared abstraction, and it is not exported from any barrel. + +### D2 — the approval is a branded value, not a remembered step + +**Chosen:** `ApprovedWrite` — a branded payload minted only by `approveWrite()` in +`translationPermission.ts`, which asks the collection's rules and throws `TranslationRefused` +on a refusal. The private write inside `targetDocument.ts` demands an `ApprovedWrite`, so the +only way to reach a write is through the ask. + +**Rejected — fold the check into the write method and rely on the file being small.** Nothing +then stops a third write added later from skipping it, which is precisely the failure the owner +asked to make impossible. + +**Rejected — a decorator around `handle`, or a check lifted to the caller.** Settled in +conversation: two of the three checks ask about data that does not exist until mid-run, because +the collection's rule reads `data` (`translationPermission.ts:99-102`). + +**Constraint cited:** `Requester` (`RequestScope.shapes.ts:14-34`) is this package's own answer +to the same problem — a brand plus a sole constructor, so every producer has to come through one +place. The cast at the minting site is that idiom, not a silenced type error. + +**Residual, deliberate:** `run.payload` stays on the handler's `Run` for the module seam, the +logger and nothing else, so `run.payload.update(...)` remains *writable* by hand. Closing it +means narrowing `Run.payload` to a shape without `update`, which the module seam forbids — +`moduleSeam` hands modules the full `Payload` (`TranslationRun.interface.ts`). Not fixed here. + +### D3 — no options bag on the write + +**Chosen:** the document exposes `saveTranslation(data, write)` and `publish(scope)`, each taking +exactly the shape its call site already holds — `TargetLayer["write"]` and `PublishScope`. + +**Rejected — one `write(data, options)` with an options bag.** A flat bag of optional fields +would admit `{ draft: true, publishSpecificLocale }` on a collection with no drafts, which +`targetLayer.ts:17-23` deliberately made unbuildable and `targetLayer.contract.test.ts` pins. +Reintroducing it as optional fields would give back a guarantee the project already paid for. + +**Constraint cited:** `targetLayer.ts:17-23` and its contract test. + +### D2a — amended in Phase 3: the brand is minted in `targetDocument.ts`, not in `translationPermission.ts` + +D2 placed `approveWrite` beside the condition it asks. Building it there broke one check, and the +break was not cosmetic: `handler.access.test.ts:112` pins that the **write-time** ask is handed the +real requester rather than `null`, and it does so by asserting on `mayWrite`, which it mocks by +module path. A function calling its own module sibling cannot be intercepted that way, so minting +inside `translationPermission.ts` would have forced that assertion to be rewritten — spending the +evidence that this task changes no behaviour. + +`approveWrite` therefore lives in `targetDocument.ts` and calls `mayWrite` across the module edge, +exactly as `handler.ts` did. Forging the brand still takes a cast either way, so the guarantee is +unchanged; what moved is only where the legitimate mint sits. + +**The cost, named:** "nobody to ask about → allowed" is now expressed twice in `targetDocument.ts` — +once inside `checkTranslationPermission` for the pre-provider ask, once as an explicit `if (requester)` +for the write. That duplication is what `handler.ts` already had; it is preserved, not introduced, +and collapsing it is a separate change with a test assertion to re-earn. + +### D3a — `PermissionQuery` goes back to being module-private + +The previous contract exported it for `handler.ts`'s `permissionQuery`. That consumer is gone, the +regression sweep found no other, and the package rule is that a name is exported for a consumer that +exists. Reverted to a plain `type`. + +### D4 — the pre-provider check stays a visible step + +**Chosen:** `handle` keeps one line, `doc.refuseUnlessTranslationAllowed(sourceData)`, before the +provider call. + +**Rejected — fold it into `readBothLocales` so it cannot be forgotten either.** It would be +unforgettable, but a reader running their eye down `handle` would no longer see that a permission +is asked, and the owner's last task on this file was spent making that list legible. The stakes +differ from the write checks: forgetting this one costs a provider call, not an editor's save. + +**This decision is the gate question.** + +## Acceptance Criteria + +| AC | Criterion | How it is checked | Passes when | +|---|---|---|---| +| AC1 | `handler.ts` performs no Payload write | `grep -c 'payload\.update' src/server/features/translate-document/handler.ts` | `0` | +| AC2 | `handler.ts` names no permission and no read-enforcement helper | `grep -cE 'translationPermission\.js\|enforcedAtTheRead\.js\|sourceDocument\.js' .../handler.ts` | `0` | +| AC3 | `handler.ts` raises no refusal of its own | `grep -c 'new TranslationRefused' .../handler.ts` | `0` | +| AC4 | A payload nobody asked about cannot be made into an `ApprovedWrite` — a compile error, not a review catch | a `@ts-expect-error` check in `__tests__/targetDocument.contract.test.ts` passing a plain object where `ApprovedWrite` is demanded; `bun run check-types` | exit 0 with the check present, and **non-zero when the `@ts-expect-error` line is deleted** (the mutation) | +| AC5 | The condition lives in one file | every `mayWrite(` call site outside `__tests__` is in `translationPermission.ts` | true | +| AC6 | Behaviour is unchanged — the nine existing suites still pin it, with no assertion rewritten | `bunx vitest run src/server/features/translate-document/__tests__/`, then `git diff -U0 -- .../__tests__/ \| grep -c '^[-+].*expect('` | 95 passed; **0** changed assertion lines | +| AC7 | The three checks still ask about the same data in the same order | `handler.access.test.ts` and `handler.spend.test.ts` green **unmodified** apart from mock targets | exit 0 | +| AC8 | Payload still receives the same write arguments | `handler.enforce.test.ts` and `handler.transaction.test.ts` green | exit 0 | +| AC9 | Access control still holds end-to-end | rebuild, then from `apps/dev`: `bun run test:integration` | 225 passed / 8 skipped | +| AC10 | No new type errors | `bun run check-types` in the package and in `apps/dev` | `0` each | +| AC11 | No new analyzer findings | `sp-lint-delta.sh -- bunx oxlint .` | nothing introduced | +| AC12 | The build still emits declarations | `bunx turbo run build --filter='./packages/payload-plugin-translator'` from the root | exit 0 | +| AC13 | `handler.ts` is shorter than 310 lines | `wc -l` | ≤ 310 (arithmetic in Risk notes; **report the real number**) | + +## Pre-flight + +| AC | Command | Result now | Kind | +|---|---|---|---| +| AC1 | `grep -c 'payload\.update' handler.ts` | **2** | must change — fails now ✓ | +| AC2 | `grep -cE '…Permission\|…AtTheRead\|sourceDocument'` | **5** | must change — fails now ✓ | +| AC3 | `grep -c 'new TranslationRefused'` | **2** | must change — fails now ✓ | +| AC4 | `grep -rc 'ts-expect-error' __tests__/` | **0 files** | must change — fails now ✓ | +| AC5 | `mayWrite(` outside tests | **3 sites, two of them in `handler.ts`** | must change — fails now ✓ | +| AC6 | `bunx vitest run .../translate-document/__tests__/` | **9 files / 95 passed** | invariant — passes now ✓ | +| AC10 | `bun run check-types` | **0** | invariant — passes now ✓ | +| AC13 | `wc -l handler.ts` | **365** | must change — fails now ✓ | +| AC9, AC11, AC12 | integration / lint / build | taken from the run that closed `2026-10-25-one-run-one-context.task.md` this session: 225 passed / 8 skipped · 43 warnings, 0 errors · build green | invariant — passed then; re-run in Phase 5 | + +## Risk notes + +- **High risk, and the classification binds.** The change moves access-control code. Behaviour + must be identical, and the evidence for that is the nine unit suites plus the eight + `access-control-*` / `read-access-*` integration suites in `apps/dev`, which run against a real + Payload. Phase 4's regression sweep is mandatory and evidenced; Phase 5b runs three review + angles. +- **Three unit suites mock `../translationPermission.js` by path** (`handler.access`, + `handler.enforce`, `handler.spend`). The new file imports from that same module, so the mocks + keep intercepting — but if a mock has to be re-pointed, that is setup, not an assertion, and + AC6 counts only assertion lines. +- **AC13's arithmetic**, so the number is not a guess: leaving `handler.ts` are `readBothLocales` + (28), `refuseUnlessTranslationAllowed` (4), `permissionQuery` (12), `refuseUnlessWriteAllowed` + (12 incl. docblock), `saveTranslatedDocument` (9), the publish write (7), `writingTo` (7), + `enforcedAtTheWrite` (11 incl. docblock) and 8 import lines — about 98 out, about 12 back for + the `doc` field and the four call sites. 365 − 86 ≈ 280, so 310 carries headroom. The previous + contract on this file (`2026-10-25-one-run-one-context.task.md`, AC4) failed because its number + came from an estimate with no arithmetic behind it; this one is recorded as a measurement to + report, not a target to hit. +- **Not in scope:** the residual in D1 (`run.payload.update` remains hand-writable); the + unguarded `payload.collections[input.collection]` index at `handler.ts:182`, which is older + than this task and whose fix is a behaviour change. + +## Human choices + +**2026-10-25, at the gate.** + +1. *"Клеймо на проверенных данных (рекомендую)"* — D2 is in scope for this task. The write is + closed at the type level, not by the file being small. +2. *"Видимым шагом (рекомендую)"* — D4 stands: the pre-provider check keeps its own line in + `handle`. Forgetting it costs a provider call, not an editor's save, and the step list stays + legible. + +Earlier in the same conversation, before the contract existed: + +3. A decorator around `handle`, and lifting the check to the caller, were both proposed by the + owner and withdrawn once it was shown that two of the three checks ask about data that does + not exist until mid-run. +4. *"Мне не нравится что управление разрешениями зашито прямо в хэндлер"* — the requirement + behind D1. + +## Result — every criterion walked with its declared check + +| AC | Declared check | Evidence | Verdict | +|---|---|---|---| +| AC1 | `grep -c 'payload\.update' handler.ts` | `0` (was 2) | met | +| AC2 | `grep -cE` the three permission/read modules in `handler.ts` | `0` (was 5) | met | +| AC3 | `grep -c 'new TranslationRefused' handler.ts` | `0` (was 2) | met | +| AC4 | the type check in `__tests__/targetDocument.contract.test.ts` + `check-types` | passes; **mutation A** (brand removed) → 2 type errors; **mutation B** (string brand restored) → the forgery check fails alone | met | +| AC5 | every `mayWrite(` call site outside tests in `translationPermission.ts` | two sites: `translationPermission.ts:154`, `targetDocument.ts:93` | **NOT MET** — see above; the criterion was superseded by D2a and is reported failed rather than rewritten | +| AC6 | the nine suites green, 0 changed assertion lines | 97 passed (95 pre-existing + 2 new); **no existing test file was written at all** — mtimes show all nine unchanged since before this task | met | +| AC7 | `handler.access` + `handler.spend` green unmodified | green, byte-unmodified | met | +| AC8 | `handler.enforce` + `handler.transaction` green | green, byte-unmodified | met | +| AC9 | `bun run test:integration` from `apps/dev` after a rebuild | **225 passed / 8 skipped**, unchanged | met | +| AC10 | `check-types` in the package and in `apps/dev` | `0` and `0` | met | +| AC11 | `sp-lint-delta.sh -- bunx oxlint .` | `mode=two-run`, nothing introduced; 43 warnings / 0 errors, unchanged | met | +| AC12 | `turbo run build` for the package | exit 0 | met | +| AC13 | `wc -l handler.ts` | **251** (was 365), against a ceiling of 310 | met | + +Package suite: **190 files / 2485 checks**. The brief carried 2482 as the baseline; measured by +excluding only the new file, the baseline is **2483**, so this task adds exactly the 2 checks it +wrote. The brief's number was one stale. + +`sp-diff-checks.sh`: three `test-integrity` rows, all on files this task did not touch +(`provenance.int.test.ts`, `chainOrder.test.ts`, `Provenance.module.test.ts`) — they belong to +earlier work on this branch. Reviewed diff pinned at `a55cf098a83d`. + +## Review log + +### 2026-10-25 — comment audit (9 blocks, 8 candidates; one block held two comments) + +DELETE 1 · REWRITE-CODE 2 · REWRITE-COMMENT 6 · KEEP 0. Every verdict applied. Two findings were +substantive rather than stylistic: + +- **`WriteDestination` did not do what its docblock claimed.** An untagged union does not stop a + literal mixing both members, and a probe confirmed that + `{ locale, fallbackLocale, draft: true, autosave: false, publishSpecificLocale }` compiled. Fixed + in code with `?: never` on each member's foreign keys; the docblock was then unnecessary and was + deleted. +- **Two comments of mine restated an overclaim I had already fixed elsewhere** — the test's docblock + and its directive both said "a payload nobody asked about is not an `ApprovedWrite`", which is + false when there is no requester, because `approveWrite` then mints one without asking. The test + was rewritten as a type assertion (`expectTypeOf(...).not.toExtend()`), which + needed no prose at all. + +The comment moved verbatim from `handler.ts` onto `enforcedAtTheWrite` also had to be rewritten: +"it does not throw, so this is safe" is true of Payload's *field* rules and false of the +collection's `update` rule, which does throw `Forbidden` — the very reason `approveWrite` exists. +Next to `approveWrite` the two statements contradicted each other. + +### 2026-10-25 — three review angles (high risk) + +**Behaviour equivalence — no findings.** Reads, both writes, the spread order inside the update +arguments, the three permission asks, `beginRun`'s ordering, and the error types and their order +were each compared against `handler.ts` as it was. No difference found. + +**Attacking the guarantee — one real defect, fixed.** The brand was +`Record & { readonly __brand: "ApprovedWrite" }`, and a string brand is a public +name: `{ title: "x", __brand: "ApprovedWrite" }` type-checked with **no assertion at all**, so the +write was reachable without asking. Replaced with a `unique symbol` key the module never exports, +and a second check pins the forgery. Two mutations prove both checks discriminate: removing the +brand fails `check-types` twice, and restoring the string brand fails the forgery check alone. + +**Shape — no change made.** The four exported names each have a consumer; nothing captured by the +closure is unused or mutable; no per-run state reaches the handler instance; every `Run` field has +a reader. Two notes are carried below rather than fixed. + +### AC5 is NOT MET as written, and the criterion is left standing rather than reshaped + +AC5 says every `mayWrite(` call site outside `__tests__` is in `translationPermission.ts`. After the +D2a amendment there are two: `translationPermission.ts:154` and `targetDocument.ts:83`. The +criterion was written against D2's original placement and D2a moved it, for a reason recorded with +its evidence — so the criterion is wrong, not the code, and it is reported failed rather than +quietly rewritten to match what was built. + +What is true, measured: the condition itself — the one function that evaluates the collection's +rules — is in `translationPermission.ts`, and **no call site is in `handler.ts`**, which is what the +owner asked for. + +## Residuals and follow-ups — recorded, not fixed here + +1. **The module seam is a second route past the approval.** `moduleSeam` hands every chain module + the full `Payload` (`handler.ts:34-38`), so a module — including a host's own — can write the + target document with nothing asked. This is the same class as the residual D1 records, but worth + stating separately: modules are a published extension point, not just this file. +2. **The target-locale read duplicates `fetchSourceDocument`.** `targetDocument.ts`'s second read + sends that helper's exact argument set by hand, which is the drift its own docblock warns about + (`sourceDocument.ts:22-28`). Switching to it would also drop the `enforcedAtTheRead` import here. + **Deferred deliberately:** the helper's `id` is a `string`, so the call would pass + `String(documentId)` where the read passes `documentId` today. That is a behaviour change, however + small, and this task promised none. It is the recommended next step. +3. **`Requester` and `IdPath` carry the same forgeable string brand** this task has just replaced on + `ApprovedWrite`, and `Requester` is exported from `src/index.ts`. A consumer can write + `{ userId, userCollection, __brand: "Requester" }` with no assertion. Separate task; it touches + the published surface. +4. **`permissionQuery` sets `user` and the write ask overrides it** with the narrowed non-null value + (`targetDocument.ts:83`). It reads as though the two could differ; they cannot. This is the shape + `handler.ts` already had — see D2a — and is left alone. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-25-the-module-seam.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-module-seam.task.md new file mode 100644 index 000000000..3dba07892 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-module-seam.task.md @@ -0,0 +1,197 @@ +Build step: 2026-10-25-asking-payload-everywhere.design.md #9 + +## Task restatement + +A module on the translation-run chain is handed the whole `payload` (`BeforeTranslation.payload`, +`AfterSave.payload`). With it, it can write **the very document the run is translating** — past every +check steps 3–8 installed, because those guard the routes and the queue, not the chain. + +This step hands modules a `payload` that refuses writes to the run's own collection. + +Covers **R3** and **R5** (gap 6). Out of scope: the pins, `@since` and the release note itself (#10) — +but what breaks for a host is recorded here so step 10 can write it. + +## Measured, not assumed — and the design is wrong on two points + +| The design says | Measured | +|---|---| +| "`ModulePayload` — the **narrowed type** of the seam's `payload`" | **a narrowed type cannot work.** `Provenance.module.ts:63,74` passes `state.payload` into `ProvenanceStoreFactory = (payload: Payload, …)`. A type with fewer members is not assignable to `Payload`, so narrowing breaks provenance — which the same design says needs no change | +| "refuses the run's own target collection" (implied at the type level) | **inexpressible in types.** The target collection is a runtime value typed `CollectionSlug`, so `Exclude` collapses to `never`. Proven by probe | +| "**big-bang** for host chain members" | **not a big bang.** Because the type stays `Payload`, nothing breaks at compile time. What changes is runtime: a module that writes to the run's own collection now throws where it used to succeed | +| "provenance needs no change" | **true, re-verified.** Its store writes only `this.collection`, the sidecar (`Provenance.store.ts:90-146`), and `assertProvenanceSlugFree` already refuses a sidecar slug that collides with a translated collection | + +## What already exists — platform · installed · project · verdict + +| Rung | Found | Verdict | +|---|---|---| +| platform | Payload's own access control | **does not help here.** The chain runs inside the worker with the requester's rights at best; a module calling `payload.update` with `overrideAccess` default writes unchecked | +| platform | `Object.create(payload)` | **the mechanism.** Payload is a class instance, so a spread would drop its prototype methods; prototype delegation keeps every member and lets three be overridden | +| project | `assertProvenanceSlugFree` | the precedent for "a module must not collide with a translated collection", already enforced at config time for the sidecar's **slug** | +| project | `TranslatorError` subclasses with `mustPropagate` | the error idiom; a refusal here must propagate, not be swallowed | + +Nothing new is installed. + +## Decisions + +### D1 — a wrapper whose type stays `Payload`, not a narrowed type + +**Chosen:** `payloadForModules(payload, target)` returns an object with `payload` as its prototype, +overriding `create`, `update` and `delete` to refuse the run's own collection. Its type is `Payload`. + +**Rejected — a narrowed `ModulePayload` type** (the design's wording). Measured above: provenance +hands `state.payload` to a factory declaring `Payload`, so a narrower type breaks the one module the +design says needs no change. The narrowing would also buy nothing, because the refusal it is meant to +express is inexpressible. + +**Rejected — a brand on the wrapper** (`Payload & { guarded: true }`). A module declares `Payload`, +so it would never see the brand; it would mark the seam without constraining anyone. + +### D2 — the refusal is a runtime guard, and that is forced rather than chosen + +Proven by probe: `Exclude` is `never`, because the run's +collection is a value, not a literal type. No type can say "any collection but this one" here. + +**Recorded consequence:** this step's guarantee is weaker in kind than step 7's. The brand at the +queue makes forgetting *not compile*; this makes writing to the wrong collection *throw*. Both are +real, and the difference should not be blurred in the release note. + +### D3 — which operations are guarded + +**Chosen:** `create`, `update`, `delete`. Reads are untouched. + +**Rejected — guarding reads too.** A module legitimately reads the document under translation; the +staleness recompute does exactly that. Refusing reads would break a thing the plugin itself does. + +### D4 — what a refusal looks like + +**Chosen:** a `TranslatorError` subclass with `mustPropagate = true`, so it reaches the run rather +than being swallowed into a silent no-op. + +**Rejected — returning without writing.** A module that believes it wrote and did not is worse than +one that fails loudly. + +### D5 — shape check + +Triggers: a field's **type** changes on a type implemented outside this package (`BeforeTranslation.payload`, +`AfterSave.payload`). + +| | wrapper typed `Payload` *(chosen)* | a narrowed structural type | +|---|---|---| +| cost of the next guarded operation | one more override | one more member to list, and every module's declared type to match | +| states admitted vs produced | the type admits every write; the guard refuses one collection at run time — the gap is **real and named**, not hidden | admits only what is listed, but cannot express the one refusal that matters | +| second caller already in the repo | both seam sites (`beforeTranslation`, `afterSave`) | same | +| breaks outside this repo | nothing at compile time; a write to the run's own collection throws | **every** chain module, including read-only ones | + +## Acceptance Criteria + +| # | Covers | Criterion | How it is checked (binding) | +|---|---|---|---| +| AC1 | R5 | A module's `payload.update` on the run's own collection throws | a case in `translation-run/__tests__/` | +| AC2 | R5 | …and so do `create` and `delete` | two more cases | +| AC3 | R3 | A module's write to **any other** collection still goes through | a case asserting the underlying `payload.update` was called | +| AC4 | R3 | Reads are untouched, including of the document under translation | a case asserting `findByID` reaches the real payload | +| AC5 | R3 | Everything else on `payload` is still reachable | a case touching a member the wrapper does not override | +| AC6 | R5 | Provenance needs no change | its suites stay green **and its files unmodified** | +| AC7 | R5 | The refusal reaches the run rather than being swallowed | the error is a `TranslatorError` with `mustPropagate === true` | +| AC8 | R5 | The baseline holds | real `turbo build` green; integration **225 passed / 8 skipped** | + +## Pre-flight — against the CURRENT tree + +| # | Polarity | Now | Evidence | +|---|---|---|---| +| AC1–AC2, AC7 | must FAIL | **fail** | `grep payloadForModules src` → nothing; the seam hands the raw `payload` | +| AC3–AC5 | must PASS | **pass** | they describe what the raw payload already does, and must keep doing | +| AC6, AC8 | must PASS | **pass** | 204 files / 2570 checks, build green, 225/8 | + +## Risk notes + +**High risk, and the risk is not where the design put it.** + +- **The guarantee is runtime, not compile-time** (D2). A reader who has followed steps 3–8 will + expect the step-7 kind of guarantee and will not get it. The release note must say so plainly. +- **Prototype delegation is the mechanism** (D1). A module that enumerates `payload`'s own keys, or + checks `instanceof`, would see a difference. No in-repo module does either — swept in Phase 4. +- **A host module that writes to the translated collection breaks at run time**, with no compile + warning. That is the whole point, and it is also the thing most likely to surprise. +- A test is required before done; the regression sweep names what was grepped; Phase 5b runs several + angles. + +## Human choices + +**2026-10-25.** The owner asked to be stopped only if the measurement showed `ModulePayload` breaking +**every** chain module including read-only ones. It showed the opposite — the honest implementation +breaks **nothing** at compile time — so no fork was raised. The design's wording is contradicted on +three points (narrowed type, compile-time refusal, big-bang), each recorded above with its +measurement rather than quietly re-interpreted. + +## Criteria results + +| # | Outcome | Evidence, by the check the criterion named | +|---|---|---| +| AC1–AC2 | met | `payloadForModules.test.ts` refuses all **five** guarded writes (three named in the contract, plus `duplicate` and `restoreVersion` found in review); **mutation**: disabling the guard reddens 5 checks | +| AC3 | met | a write to another collection reaches the real payload | +| AC4 | met | `findByID` on the document under translation reaches the real payload | +| AC5 | met | an unguarded member (`config.serverURL`) is still reachable | +| AC6 | met | provenance **unmodified** — `git status` on its directory is empty — and its suites green | +| AC7 | met | `mustPropagate === true`, with the condition now stated accurately | +| AC8 | met | real `turbo build` green, forced; integration **225 passed / 8 skipped** | + +## Review log + +**2026-10-25 — the measurements that contradicted the design**, taken before any code was written and +recorded above in `## Measured`: a narrowed type breaks provenance; the refusal is inexpressible in a +type (probe: `Exclude` collapses to `never`); and therefore the step is **not** the big bang the +design called it — nothing breaks at compile time. + +**`/sp-complexity`.** Neighbourhood 6 files; history mute for the new ones. "A fourth guarded +operation": CHANGE 2 in one file, READ 2. "A new chain member": CHANGE 1, and the guard applies by +itself because it sits in `runChain`. Verified `forTheModule` is the only path to a module. +**No findings.** + +**`/sp-abstraction`.** Audited by the author. + +- **The honesty gap is real but forced.** The type admits writes to every collection; the system + refuses one. It cannot be closed — the signature cannot carry it, so the docblock does. +- **Prototype delegation priced by probe, not assumed.** Surviving: `instanceof`, inherited members, + unguarded methods, overridden methods. Differing, exactly two: `Object.keys` on the view returns + the guarded names rather than Payload's own properties, and `hasOwnProperty` on an inherited member + is false. **Swept: no module in the repo does either.** +- **Finding, fixed:** `operation: string` in the error, where exactly three values existed. Named as + `GuardedWrite`. + +**`/sp-red-test` in audit mode.** Replacing the guard with a pass-through reddened **5 checks in 3 +files** — three on the refusal itself, two on the seam actually handing the guarded view. No toothless +check. Scaffolding deleted. + +**`/sp-review-comments`.** 4 blocks, 4 verdicts + 1 MISSING: 1 KEEP… in fact 0 KEEP — 2 +REWRITE-COMMENT, 1 DELETE, 1 REWRITE-CODE. Inline density 0.36 per 10. + +- The 12-line docblock on `payloadForModules` was cut to 5. Two of its paragraphs argued with a + **reviewer** ("why isn't this a type?", "`Object.create` keeps everything") rather than telling a + caller anything; both facts already live in `## Decisions`. What replaced them is the consequence a + module author actually needs: own-key enumeration sees only the guarded methods. +- The error's docblock said *"Propagates"* unconditionally. **False against the code**: + `swallowOrThrow` rethrows only inside a transaction. Now states the condition. +- `forTheModule`'s note repeated its own name; the view is now built inside the closure handed to the + module, so `carried` and `state` visibly stay unguarded and the note had nothing left to say. +- The MISSING entry was an unexplained cast; the helper was reshaped so the cast has one stated + reason beside it. + +**Found outside its own remit, and it was a real gap:** the guard covered `create`, `update` and +`delete` only. Checked Payload's surface — **`duplicate` and `restoreVersion` also write and also take +a collection**, and both were reachable. Added, with two checks and a mutation. `payload.db` reaches +the database directly and **cannot** be covered this way; that limit is now stated in the docblock. + +### Verification after every fix + +`check-types` 0 · oxlint 43 warnings / 0 errors · package suite **205 files / 2581 checks**, up from +204 / 2570 by exactly +1 file and +11 checks (9 for the guard, 2 from the per-source-file throw test +for the two new files) · real `turbo build` green, forced · integration **225 passed / 8 skipped** · +both gates clean. + +### Owed to step 10 + +A release-note line, and it must not overstate: this guarantee is a **runtime** refusal, unlike step +7's compile-time brand. It stops a module from forgetting, not from intending — `payload.db` and +reaching through the prototype both remain. A host module that writes the translated collection now +fails where it used to succeed. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-25-the-read-asks-first.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-read-asks-first.task.md new file mode 100644 index 000000000..b127aaf06 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-read-asks-first.task.md @@ -0,0 +1,187 @@ +Build step: 2026-10-25-the-run-as-a-list.design.md #1 + +## Task restatement + +Today `TargetDocument` offers two separate calls — read both locales, then ask the collection's +rules about the source — and `handler.ts:119-120` makes them in that order. Nothing but a test keeps +the ask before the provider is paid, and nothing stops a future reader of the source from skipping +it entirely. + +Merge them into one call that returns a value only it can produce, and make every step that carries +the source onward demand that value. Then "the provider was paid before anyone asked" stops being a +convention and becomes a compile error. + +Covers **R1** and **R5** of the design. The class, `model.ts`, the module seam and the four parked +concerns are **not** touched — those are steps 2, 3 and 4. + +## What already exists — platform · installed · project · verdict + +| Rung | Found | Verdict | +|---|---|---| +| project | `ApprovedWrite` in the same file: a brand on a type-only `declare const asked: unique symbol`, minted by one `as ApprovedWrite`, demanded by the file's single `payload.update` | **the mechanism, already proven here.** Copied in shape, not in its minting | +| project | `Requester` (`RequestScope.shapes.ts:14-34`) and `IdPath` (`idPath.ts:22`) — brand plus sole constructor | same idiom, two more precedents | +| platform (TypeScript) | `const x: unique symbol = Symbol("…")` is a **runtime** symbol, so an object literal with that computed key carries it in its inferred type — no assertion at the minting site | **this is the new part**, and it is what keeps the owner's "no assertions" rule | +| platform (tsc) | a non-exported `unique symbol` referenced by an exported type survives declaration emit — the shipped `dist/.../targetDocument.d.ts:16-18` already carries `declare const asked: unique symbol;` beside the exported `ApprovedWrite` | the `.d.ts` risk the design flagged is **answered for the type-only form**; the runtime form still has to be proven by AC5 | + +Nothing new is installed. + +## Decisions + +### D1 — `ApprovedSource` is a standalone shape; `BothLocales` is deleted, not hidden + +**Chosen:** declare the two fields inline on `ApprovedSource` and remove `BothLocales` entirely. + +**Rejected — keep `BothLocales` and write `ApprovedSource = BothLocales & { readonly [s]: true }`.** +The design says `BothLocales` stops being exported. But an exported alias that references a +non-exported local alias is the classic declaration-emit failure, and this step is explicitly +required to pass the real `tsc` build. Keeping it exported to dodge that would leave a name with no +consumer, which the package's own export rule forbids. + +**Constraint cited:** `handler.ts:28,221` is the only importer of `BothLocales` in `src/`, and it +needs the approved type there anyway — so after this change the name has zero consumers. + +### D2 — the minting symbol is its own, and the write brand is not touched + +**Chosen:** a second, separate symbol for the source. + +**Rejected — reuse `asked`.** `BothLocales` is a type alias and so receives an implicit index +signature; `BothLocales & { readonly [asked]: true }` would therefore satisfy `ApprovedWrite`, and a +value approved only for reading would be accepted where a write payload is demanded. That silently +weakens the guarantee step 1 is meant to extend, not erode. + +**Constraint cited:** `sendToPayload`'s `data: ApprovedWrite` parameter is the single write path; +anything assignable to it can be written. + +### D3 — the mint uses a runtime symbol so no assertion is needed + +**Chosen:** `const approvedSource: unique symbol = Symbol("approvedSource")`, and the minting +function carries an explicit `Promise` return type. + +**Rejected — `return value as ApprovedSource`,** matching `approveWrite`'s existing cast. The owner's +rule forbids assertions that silence the checker, and here one is avoidable. `approveWrite` keeps +its cast because its brand sits on the object handed to Payload as `data`, where a real symbol key +would be copied into written data; `ApprovedSource` is a wrapper that is never sent anywhere. + +**The condition, recorded because it is silent when missed:** without the explicit return type, +`[approvedSource]: true` widens to `boolean` and stops satisfying `readonly [approvedSource]: true`, +and the assertion comes back. AC9 is what catches that. + +### D4 — which steps demand the approved value + +**This is the gate question.** The design says narrowing, the module chain and the provider call all +take it. The narrower alternative is to require it only at the provider call, which is the only step +that spends money and the only one R1 names. + +## Acceptance Criteria + +| AC | Covers | Criterion | How it is checked | Passes when | +|---|---|---|---|---| +| AC1 | R1 | No read that skips the decision survives | `grep -c 'readBothLocales\|refuseUnlessTranslationAllowed' targetDocument.ts handler.ts` | `0` in both (now 4 and 2) | +| AC2 | R1, R5 | A source nobody asked about cannot be made into an `ApprovedSource` | a type assertion in `__tests__/targetDocument.contract.test.ts`; `bun run check-types` | exit 0 with it present, **non-zero when the brand is removed** (the mutation) | +| AC3 | R1 | The write guarantee is not weakened: an `ApprovedSource` is not an `ApprovedWrite` | a second type assertion in the same file | exit 0; **non-zero when the two brands share one symbol** (the mutation) | +| AC4 | R8 | Behaviour is unchanged — the ten existing suites still pin it | `bunx vitest run src/server/features/translate-document/__tests__/`, then count changed assertion lines in those files | 97 passed; **0** changed assertion lines | +| AC5 | — | The real declaration build passes, not only `tsgo` | `bunx turbo run build --filter='./packages/payload-plugin-translator'` from the root | exit 0, and `dist/.../targetDocument.d.ts` carries the runtime symbol | +| AC6 | — | No new type errors | `bun run check-types` in the package and in `apps/dev` | `0` each | +| AC7 | — | No new analyzer findings | `sp-lint-delta.sh -- bunx oxlint .` | nothing introduced | +| AC8 | R1 | Access control still holds end to end | rebuild, then from `apps/dev`: `bun run test:integration` | 225 passed / 8 skipped | +| AC9 | R5 | The mint needs no assertion | `grep -rc 'as ApprovedSource' src/` | `0`; and `as ApprovedWrite` stays at exactly `1` | + +## Pre-flight + +| AC | Command | Result now | Kind | +|---|---|---|---| +| AC1 | the grep | **4** in `targetDocument.ts`, **2** in `handler.ts` | must change — fails now ✓ | +| AC2, AC3 | the type assertions | `ApprovedSource` does not exist, so the test cannot compile | must change — fails now ✓ | +| AC4 | the feature suite | **10 files / 97 passed** | invariant — passes now ✓ | +| AC6 | `check-types` | **0** | invariant — passes now ✓ | +| AC9 | `as ApprovedSource` / `as ApprovedWrite` | **0** and **1** | invariant — passes now ✓ | +| AC5, AC7, AC8 | build / lint / integration | taken from the run that closed `2026-10-25-the-document-asks-before-it-writes.task.md`: build green · 43 warnings, 0 errors · 225 passed / 8 skipped | invariant — passed then; re-run in Phase 5 | + +## Risk notes + +- **High risk, and it binds.** The change rearranges the access decision on the path that spends + money and writes a document. A test is required, the regression sweep is evidenced, and Phase 5b + runs several review angles rather than one. +- **The declaration-emit risk is real but bounded.** The type-only form is already proven in the + shipped `dist`; the runtime form is not, and AC5 exists to prove it rather than assume it. If the + real build fails, D3 falls back to the assertion and that is a finding to report, not to paper + over. +- **The ask moves inside a call.** After this step a reader of `handle` sees `readSourceIfAllowed` + rather than a separate line saying a permission is asked. That trade was reopened by the owner and + settled in the design (ADR D2); it is recorded here so a later reviewer does not re-raise it. +- **Not in scope:** the module seam can still write the target document unasked (design D6, carried + to its Open questions); the class, `model.ts` and the four parked concerns (steps 2 and 3). + +## Human choices + +**2026-10-25, at the gate.** *"Все три — как в проекте"* — D4 resolves to the design's own answer: +`leavesToSend`, `runModulesBefore` and `translateOrWrap` each demand `ApprovedSource`, not a bare +source record. Two of the three read only `sourceData`, so the parameter is wider than what they +touch; that is accepted deliberately, because what their answer depends on is **that the source was +asked about**, and the proof travels on the wrapper. The narrower alternative — the brand only at +the provider call — was offered and declined. + +The two decisions above it (D1 delete `BothLocales`, D2 a separate symbol) were taken without a +question because both are forced: the first by declaration emit, the second by the write brand's +assignability. + +## Result — every criterion walked with its declared check + +| AC | Evidence | Verdict | +|---|---|---| +| AC1 | `readBothLocales` / `refuseUnlessTranslationAllowed`: **0** in `targetDocument.ts` (was 4), **0** in `handler.ts` (was 2) | met | +| AC2 | the type check passes; **mutation**: removing the brand fails `check-types` on that case alone | met | +| AC3 | the second type check passes; **mutation**: giving both brands one symbol fails that case *and* breaks the mint itself | met | +| AC4 | 10 files / **99 passed** (was 97; +2 are the new type checks). **0** changed assertion lines, and only one test file was written to at all — by addition | met | +| AC5 | `turbo run build` exit 0; `dist/.../targetDocument.d.ts:5` carries `declare const approvedSource: unique symbol;` beside the exported type | met | +| AC6 | `check-types` 0 in the package, 0 in `apps/dev` | met | +| AC7 | `sp-lint-delta` `mode=two-run`: nothing introduced; 43 warnings / 0 errors, unchanged | met | +| AC8 | `bun run test:integration`: **225 passed / 8 skipped**, unchanged | met | +| AC9 | `as ApprovedSource`: **0**. `as ApprovedWrite`: **1**, the pre-existing one | met | + +`sp-diff-checks`: clean, 5 checks, no findings. + +## Review log + +### 2026-10-25 — comment audit (3 candidates, 4 verdicts) + +REWRITE-COMMENT 1 · DELETE 1 · KEEP 2. Both verdicts applied. + +The `ApprovedSource` docblock said the value carries *"the proof that the collection's rules were +asked about this source — or asked nothing, when there is no requester"*, which contradicts itself: +a value that can be minted with no ask proves the gate was passed, not that an ask happened. It also +said "the collection's rules" where the fact that matters is narrower — the `update` rule, evaluated +with `sourceData` rather than with the payload a write would send. That narrower statement is +exactly what explains why this brand must never satisfy `ApprovedWrite`, so naming it made the test's +inline comment unnecessary, and that comment was deleted in favour of renaming the case. + +The two pre-existing docblocks in the file (`approveWrite`, `enforcedAtTheWrite`) were checked for +staleness caused by the merge and are not stale: both describe the write path, which this change +does not touch. + +### 2026-10-25 — three review angles (high risk) + +**Behaviour equivalence — no findings.** Both reads still carry the same arguments inside one +`Promise.all`; the two `SourceUnreadable` checks still precede the ask; the ask is still +`checkTranslationPermission(permissionQuery(sourceData))` and still runs before narrowing, before +any module and before the provider; all three rewired methods read the same fields they read before. + +**Shape — no defects.** `BothLocales` has zero references left. Nothing is orphaned; +`permissionQuery` still serves both the read and the write. The two brands use different mechanisms +for a stated reason and should stay that way. + +**Attacking the guarantee — one finding worth recording, no change made.** + +A deliberate `{ ...approved, sourceData: somethingElse }` **compiles**: the brand is an own +enumerable property, so a spread copies it, and the result vouches for data nobody asked about. The +brand therefore attests that the value *came from the read*, not that its fields are the ones that +were checked. Nothing in this change spreads an `ApprovedSource` — the handler only reads +`.sourceData` and `.currentTarget` — so this takes a future, deliberate edit, and it is visible in +review. Closing it would mean hiding the fields behind accessors on a class with a `#private` +member, which is heavier than the thing it protects and is not the package's idiom anywhere else. +Recorded rather than fixed. The same weakness applies to `ApprovedWrite` and predates this work. + +Independently confirmed, and already recorded as Open question 3 of the design: `translate-field` +reads a source and pays a provider **without ever asking the `update` rule** — it checks only the +collection's `read` rule through `fetchSourceDocument`. The guarantee this step establishes covers +`translate-document` only. Nothing in the types says so. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-25-the-receipt-holds-a-map.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-receipt-holds-a-map.task.md new file mode 100644 index 000000000..32fcc248c --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-receipt-holds-a-map.task.md @@ -0,0 +1,297 @@ +# The receipt holds a map + +**Risk: HIGH.** Changes how a stored column is read, so a value already in someone's database +answers differently afterwards. + +## Requirements / Task restatement + +`SourceFingerprint` is a union of two shapes: `{ kind: "document"; hash }` — the single sha256 the +plugin wrote until 0.15.0 (2026-10-05) — and `{ kind: "fields"; hashes }`, the per-leaf map it has +written since. Nothing has produced the first shape for twenty days; it exists only so that a +receipt written by an older version still reads back. + +Stop reading it. A stored value that does not parse as a per-leaf map becomes `null` — the same +answer as "there is no receipt". With one member left, the `kind` tag distinguishes nothing, so the +wrapper goes too and a receipt holds `RecordedFingerprints | null` directly. + +The second walk falls out with it. `Provenance.service.sourceAsItStands` hashes the source document +twice on every staleness read — once per leaf, once whole — and the whole-document hash exists only +to compare against a legacy receipt. `2026-10-02-per-field-fingerprints.task.md` recorded that +double walk as a known cost it did not know how to remove without duplicating the hashing rule +(`### Not fixed, with the reason`). Removing the reader removes the need, so neither of the two +options it weighed is required. + +**Out of scope, named rather than implied:** the disagreement between `isRecordStale` and +`driftedAddresses` when a leaf is *removed* from the document (recorded in +`2026-10-24-dismissal-writes-the-receipt.task.md`, `### Open, found here, not this task`). Both +sides of it are touched by this change and both must come out behaving exactly as they do now. + +## What already exists — platform · installed · project · verdict + +Nothing new is being built; this task only removes. No search up the ladder. + +**Precedent, in this module, three days ago:** `2026-10-24-dismissal-writes-the-receipt.task.md` +removed `dismissedFingerprint` from the receipt type, the port and the sidecar collection, and +recorded the stored-data consequence in `docs/DEPRECATIONS.md`. Same module, same shape of removal, +same artifact. This task follows it. + +## Decisions + +### D1 — `RecordedFingerprints` is the surviving name; `SourceFingerprint` the type is deleted + +**Chosen:** every site that held a `SourceFingerprint` holds `RecordedFingerprints | null`. + +**Rejected:** keep `SourceFingerprint` as an alias of `RecordedFingerprints`. It loses because two +names for one concept is the thing this task exists to remove, and of the two it is the weaker +name: `RecordedFingerprints` says what the data is ("keyed by leaf address, holding every leaf the +run saw — sent or not", `SourceFingerprint.ts:18-21`), while `SourceFingerprint`'s own docblock +(`:26-30`) exists solely to explain that there are two member shapes. With one shape it explains +nothing. + +**Constraint cited:** that docblock. A type whose documentation is a list of its members has no +meaning left once the list has one entry. + +**The file and the two function names stay.** `SourceFingerprint.ts`, `parseSourceFingerprint` and +`serializeSourceFingerprint` are named after the **column** — `sourceFingerprint`, declared at +`Provenance.collection.ts:30` — not after the deleted type. They keep describing what they read and +write. + +### D2 — `recordsEachLeaf` and `CurrentFingerprint` go; the check happens at the call site + +**Chosen:** `recordsEachLeaf(x)` becomes `x !== null`, written where it is used (two sites: +`Provenance.criterion.ts`, `Provenance.service.ts`, plus `alreadyClaimed` in the same service). +`CurrentFingerprint` was `{ document, fields }`; with `document` gone it wraps one field, so +`isRecordStale` takes `FieldFingerprints` directly. + +**Rejected:** keep `recordsEachLeaf` as a null-check helper. It loses because a named predicate over +one `!== null` is the same shape the owner removed from `leavesMovedSince` on 2026-10-25 — a wrapper +folding a guard into a computation, where the guard no longer narrows anything a reader cannot see. + +**Constraint cited:** the owner's instruction on this branch, and the fact that `driftedAddresses` +already takes the map rather than the wrapper. + +### D3 — an unreadable stored value is `null`, indistinguishable from no receipt + +**Chosen:** `parseSourceFingerprint` returns `null` for an empty column *and* for text that does not +parse as a per-leaf map. + +**Rejected:** a third state that keeps the two apart — an `unreadable` marker, so a caller could +tell "a receipt exists but we cannot read it" from "no receipt". It loses because no caller would +branch on it: **after this change** all three readers treat the two identically — stale, no +addresses reported, every leaf taken. + +*Said precisely, because the loose version is wrong:* before this change they did **not** treat them +identically. A document digest matching the current source read as **current**, while an empty +column read as out of date. That difference is the behaviour this task removes, and it is what the +`DEPRECATIONS` entry describes. An earlier draft of the `parseSourceFingerprint` docblock claimed no +reader had ever acted on them differently; the comment audit caught it as false and it was +rewritten. + +**Constraint cited:** the shape check's second question. A state the type admits and the system +never acts on is a gap between what is expressible and what is legal. + +### D4 — the behaviour change is recorded in `docs/DEPRECATIONS.md` + +**Chosen:** an entry under a new 2026-10-25 heading, naming what a host sees. + +**Rejected:** treat it as internal and say nothing, on the grounds that no published type changes. +It loses because what changes is not a type but an **answer about stored data**: a host upgrading +with provenance rows from 0.10–0.14 sees locales flip to "out of date". `DEPRECATIONS.md` already +carries two entries of exactly this kind (`2026-10-05`, per-leaf receipts; `2026-10-24`, the +dismissed fingerprint). + +### Shape check + +Triggers walked. Three do not fire: no field is added to a published or externally implemented +type; no fields are added at all; no exported function gains a parameter; nothing becomes optional. + +**One fires — "two functions answer parts of one question".** `isRecordStale` and +`driftedAddresses` both run over the same receipt and the same current hashes, and together answer +"is this locale out of date, and where". Ownership, stated: `isRecordStale` owns *whether*, +`driftedAddresses` owns *where*, and **neither owns a leaf the document no longer has** — +`sameFields` compares key counts so it calls that stale, `driftedAddresses` walks current leaves +only so it names nothing. That is the recorded open defect, and this task must leave it exactly +where it is. AC8 pins it. + +Shapes considered for the receipt's fingerprint member: + +| | `RecordedFingerprints \| null` (chosen) | tagged union of one member | a wrapper object `{ hashes }` | +|---|---|---|---| +| cost of the next member of this kind | add a member to the map; one place | add a union arm + a `switch` arm in 3 readers | same as chosen, plus unwrapping | +| states admitted / produced | 2 / 2 | 2 / 2, through a tag nothing reads | 2 / 2 | +| second caller already present | 3 readers, 2 writers | — | — | +| breaks for an implementor outside this repo | nothing; the type is not published | same | same | + +The second is what we have, and its tag is read by `switch` statements with one reachable arm. The +third adds a level for no distinction. One shape survives. + +**New surface:** none. **Written contract owed:** `parseSourceFingerprint` must say what `null` +means — no receipt and an unreadable one, deliberately the same. One sentence in its docblock, not +a `/sp-red-test` case: the unit exists, has tests, and this is a removal. + +**Escalate to `/sp-architect`?** No. It stays inside one feature's own files +(`core/domain/provenance` + `server/modules/provenance`), adds no seam, needs no migration, and the +data question — whether old receipts may stop being read — was settled by the owner before this run +with the evidence in `## Human choices`. + +## Acceptance Criteria + +| AC | Criterion | How it is checked | Passes when | +|---|---|---|---| +| AC1 | No fingerprint carries a `kind` tag anywhere | `grep -rn 'kind: "fields"\|kind: "document"\|kind === "document"' src/` | 0 | +| AC2 | `recordsEachLeaf` and `CurrentFingerprint` no longer exist | `grep -rn "recordsEachLeaf\|CurrentFingerprint" src/` | 0 | +| AC3 | The staleness read hashes the document once, not twice | `grep -c "computeSourceFingerprint" src/server/modules/provenance/Provenance.service.ts` | 0 | +| AC4 | `computeSourceFingerprint` still serves auto-translate | `grep -c "computeSourceFingerprint" src/core/domain/auto-translate/hasSourceContentChanged.ts` | ≥1 (invariant) | +| AC5 | A stored value that is not a per-leaf map parses to `null` | `bunx vitest run src/core/domain/provenance/SourceFingerprint.test.ts` | the case named "reads a value it cannot parse as no receipt" is green, and was red before the change | +| AC6 | The store reads such a row as a receipt with no fingerprint, without throwing | `bunx vitest run src/server/modules/provenance/__tests__/Provenance.store.test.ts` | `result.sourceFingerprint` is `null` for both a bare sha256 and arbitrary text | +| AC7 | A locale whose receipt cannot be read reports out of date, naming no leaves | `bunx vitest run src/server/modules/provenance/__tests__/Provenance.service.test.ts` | `isStale: true` and `changedLeaves` absent | +| AC8 | The removed-leaf disagreement is unchanged | `bunx vitest run src/core/domain/provenance/staleness.test.ts -t "is stale when a leaf was removed"` | green, with the same verdict as before | +| AC9 | The four legacy-receipt checks are **deleted**, not rewritten to a new answer | `grep -c "a receipt written before per-field fingerprints" src/core/domain/provenance/staleness.test.ts` | 0 | +| AC10 | Every non-legacy staleness verdict is unchanged | `bunx vitest run src/core/domain/provenance src/server/features/staleness` | green with no expectation edited — baseline is the suite as it stands at `HEAD` of this working tree | +| AC11 | The published surface is untouched | `bunx turbo run build --filter='./packages/payload-plugin-translator'` then `grep -c SourceFingerprint dist/index.d.ts` and `grep -c TranslationProvenanceRecord dist/index.d.ts` | 0 and ≥1 (both invariant) | +| AC12 | `docs/DEPRECATIONS.md` names what a host sees | read the new 2026-10-25 entry | it states the flip to "out of date" and how it clears | +| AC13 | No new type errors | `bun run check-types` in the package and in `apps/dev` | 0 each | +| AC14 | No new analyzer findings | `bunx oxlint .` | ≤43 warnings, 0 errors | +| AC16 | `README.md` no longer promises the three withdrawn refusals | `grep -c "it is refused alongside \`select_all\`" README.md` and a read of the replacement paragraph | 0, and the paragraph describes what the route does now | +| AC15 | The queue path still works end to end | rebuild, then `bun run test:integration` from `apps/dev` | 225 passed / 8 skipped, unchanged | + +## Pre-flight — each check run against the untouched tree, 2026-10-25 + +| AC | Command | Result now | Kind | +|---|---|---|---| +| AC1 | the grep | **71** | change — fails now, correct | +| AC2 | the grep | **24** | change — fails now, correct | +| AC3 | the grep | **2** | change — fails now, correct | +| AC4 | the grep | 3 | invariant — passes now, correct | +| AC5 | the suite | `parseSourceFingerprint(LEGACY)` returns `{kind:"document"}` (`SourceFingerprint.test.ts:14`) | change — fails now, correct | +| AC6 | the suite | returns `{kind:"document", hash}` (`Provenance.store.test.ts:161,172`) | change — fails now, correct | +| AC7 | the suite | a legacy receipt matching the document hash reports `isStale: false` | change — fails now, correct | +| AC8 | the case | green | invariant — passes now, correct | +| AC9 | the grep | **1** | change — fails now, correct | +| AC11 | the greps | 0 and 1 | invariant — passes now, correct | +| AC13 | `check-types` | 0 and 0 | invariant | +| AC14 | `oxlint` | 43 warnings, 0 errors | invariant | +| AC15 | `test:integration` | 225 passed, 8 skipped | invariant | + +Baseline for AC10: `bunx vitest run` over the whole package is 189 files / 2494 checks at the start +of this task. + +## Risk notes + +- **The one behaviour change.** A locale whose receipt predates 0.15.0 and whose source has not + moved flips from "up to date" to "out of date". It clears on the next translation of that locale + or on the editor hiding the mark, either of which writes a per-leaf receipt. The direction is a + false alarm, never a false all-clear. +- **`toStoredData` already writes `null` into a `required: true` column** when a receipt's + fingerprint is null (`Provenance.store.ts:55-58` against `Provenance.collection.ts:30`). It is + unreachable today — `upsert` has one production caller and it always passes a map — and stays + unreachable. This task does not widen it, but it moves the latent case one step closer, because + `null` becomes reachable on the *read* side for a legacy row. Nothing reads a fingerprint and + writes it back, so no path joins the two. Checked, not assumed. +- **`claims.pins.ts`** is a type-level pin file that is never imported and never run; `check-types` + fails if one of its `@ts-expect-error` lines stops being an error. It names `RecordedClaim`, not + the union, so it should survive untouched — but it is the kind of file a sweep misses. +- **`driftGuard.test.ts`** pins that the fingerprint side and the collector mint byte-identical + addresses. This task must not disturb it; AC10's suite covers it. + +## Human choices + +**2026-10-25 — the owner authorised dropping the legacy read before this run started**, after asking +for it to be verified first. The verification he accepted: + +- The published surface is not touched: `package.json` publishes only `.` → `src/index.ts`, which + exports `TranslationProvenanceRecord` alone, and that type deliberately excludes the fingerprint + (`ProvenanceStore.interface.ts:18-20`). `SourceFingerprint`, `ProvenanceReceipt`, + `ProvenanceStore` and `parseSourceFingerprint` are exported from `src/core/index.ts`, which is not + a package entry point. Confirmed against the built `dist/index.d.ts`: 0 occurrences. +- The `NOT NULL` column is safe: `store.upsert` has one production caller + (`Provenance.service.ts:140`) and always passes a map. +- No crash path: every reader already handles `null`, which is the "no receipt" case. +- No wrong translation: on a legacy receipt `alreadyClaimed` already resolves to `{}`, and the + `stale` rule already takes every leaf. +- `apps/cms` is unaffected: provenance is not enabled there and no migration carries the sidecar. + +**Superseding an earlier decision, recorded so no reviewer re-raises it.** +`2026-10-02-per-field-fingerprints.task.md` D4 ("unknown is never stale") and its rejected +alternative ("treating a legacy record's document-level drift as *every leaf is stale*") rested on +the `skip_existing` strategy, which refreshed a leaf only on `sourceChanged === true`. That strategy +was deleted in `2026-10-23-fields-not-a-strategy.task.md`, and `Provenance.criterion.ts` already +takes every leaf when the receipt is not per-leaf. D4 was therefore already superseded before this +task; this task does not touch the criterion. + +**2026-10-25, at the gate — the owner chose the full collapse** ("Да, как в договоре") over keeping +`SourceFingerprint` as an alias of the map, and over the smaller step of dropping only the +`document` branch while leaving a one-member union behind. + +**2026-10-25, at the gate — the owner asked for `README.md:471` to be corrected in this same pass** +("Поправить в этом же проходе"). It still promises three refusals the enqueue route stopped making +earlier the same day: a `leaves` filter refused alongside `select_all` or a second id, a positional +address refused, and an unknown rule name refused. Unrelated to this task's subject, folded in +because it is a false statement in a published document. AC16 covers it. + +**2026-10-25, after the review log — the owner asked for `vouchedFor` to be renamed** and for the +"vouch" vocabulary to be made one word across `src`. It was the only place in the mechanism using +that verb while everything around it said *answer* (`Criterion` "answers with", `answerTo`, the +local `answered` that is the argument passed in); the name hid that the parameter is a list **of +lists**; and it was wrong for half its callers, since `narrowSelection` is also called with the +addresses a person named, which nobody vouched for. `LeafAnswer` now names one answer, +`NarrowSelection`'s second parameter is `answers: readonly LeafAnswer[]`, and `Criterion` returns a +`LeafAnswer`. The plan files keep the old word: they record decisions as they were made. + +## Open, not solved here + +- `isRecordStale` and `driftedAddresses` still disagree when a leaf is removed from the document. + Unchanged by this task on purpose; AC8 pins it. +- `toStoredData`'s `null` branch against a `required: true` column. Two reviewers raised it + independently and both found it unreachable: `ProvenanceStore.upsert` has one production caller + (`Provenance.service.ts:140`) and it always passes a map, and nothing reads a fingerprint and + writes it back. What changed is the margin, not the reachability — `null` is now a value the read + side produces, so a future caller that round-trips a receipt through `upsert` would write it. The + honest fix is to give `upsert` a write shape whose fingerprint is not nullable, which means a + second receipt type for one caller; that is the "no new abstraction without two callers" brake, so + it waits for the second caller rather than being built here. + +## Review log + +### 2026-10-25 — comment audit (fresh eyes, 4 blocks, 4 verdicts) + +Density 0.31 comment lines per 10 code lines — ok. **One finding was a real error of fact and is +the reason this pass earned its place:** the `parseSourceFingerprint` docblock claimed "no reader +has ever acted on them differently" about an empty column and a legacy digest. False about the past +— until this change a digest matching the current source read as *current* while an empty column +read as out of date, which is precisely the behaviour this task removes. Rewritten to state the +contract without the claim. Two more blocks deleted (one repeated the docblock and the same false +claim; one described a test helper as "what the source hashes to now" when it also builds stored +claims). One rewritten: the `changedLeaves` docblock said "when no receipt could be read", but +`getStaleness` only produces an entry for a receipt it *did* read — what is absent is its +fingerprint. + +### 2026-10-25 — three review angles (high risk) + +**Unintended behaviour change: none found.** Each site was traced against `HEAD` and shown +equivalent for every non-null input — `isRecordStale`, `getStaleness`'s `changedLeaves` emission, +`driftedLeaves`' guard, `record`'s `alreadyClaimed`, `dismiss`'s written value, and the serializer's +output for a map. + +**Test integrity: no coverage lost.** Every deleted check had a subject that no longer exists, and +every legacy input survives — pushed through `parseSourceFingerprint` instead of constructed. Two +assertions came out *stronger* (`toBeNull()` where `?.kind` was compared; the store's upsert check +now writes a value that differs from the record's). Two weak spots were raised and both fixed here: +`theWatermark.test.ts` asserted only `not.toBeNull()` where `{}` would have passed, now +`toEqual({title, body: UNANSWERED})`; and `translate-document/__tests__/handler.test.ts` still cast +to `{ kind: string; hashes: ... }`, a shape that no longer exists, hidden from the checker by the +`as` — now `RecordedFingerprints`. + +**Stored data: no throw, no loss, no false all-clear.** Every column value a real database could +hold was walked end to end. Two inaccuracies in `DEPRECATIONS.md` were found and fixed: + +1. "clears the first time that locale is translated" overstated it. A legacy row carries no earlier + claims, so a **narrowed** run records every unsent leaf as unanswered and the mark stays. The + entry now says *translated in full*, and names what a narrowed run does. +2. The 2026-10-24 entry tells a host to copy `dismissedFingerprint` into `sourceFingerprint` to keep + hidden marks. For a mark hidden before 0.15.0 that value is a bare sha256, so copying it now + writes something that reads as no receipt and the mark returns anyway. The new entry says to copy + only JSON maps, and that those locales need the button pressed again. + +The claim that the error direction is always safe survived an attempt to falsify it: there is no +path from a legacy, corrupt or unanswered value to "current". diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-25-the-run-as-a-list.design.md b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-run-as-a-list.design.md new file mode 100644 index 000000000..a2a505384 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-run-as-a-list.design.md @@ -0,0 +1,354 @@ +## Architecture Design — the document-translation run + +### The ask (ground truth) + +Reshape everything between "a job says translate document D from locale A into locale B" and "the +document is written and published", so the run reads as a list, the access checks are not inlined +mid-procedure, and `TranslateDocumentHandler` in its current form does not survive. + +Constraints that shaped it: the inline path runs inside the editor's `afterChange`, where Payload +calls `killTransaction` from the `catch` of every operation — hence the standing rule *ask before +acting, never catch `Forbidden`* · the handler is built once at config time and the queue runs jobs +in batches, so no per-run state may rest on it · two of the three checks ask about data that does +not exist until mid-run, so no wrapper around the entry can perform them. + +Requirements: +- R1 (in) No path that writes the target document can skip the access decision, and the read-side + decision cannot run after the provider has been paid. +- R2 (in) The run's order is readable as a list, without opening any step's body. +- R3 (in) `TranslateDocumentHandler`, the `Handler` interface it implements, and + `TranslateDocumentOutput` do not survive. +- R4 (in) Adding a step, or a narrowing rule, is a change in one place. +- R5 (in) The data handed between steps stays compiler-checked. No untyped bag. +- R6 (in) No per-run state rests on a long-lived object. +- R7 (in) The design states what becomes of the module seam and what that costs. +- R8 (in) The transition is possible without observable behaviour change, or names where it is not. +- R9 (out) `core/translation-pipeline`'s internals · the enqueue route · the stored job row · + auto-translate · everything in `src/client/`. + +**Vectors:** structure + security (leading: structure) — structure because the ask is literally +"where does each responsibility live and what shape is the run"; security because R1 is an +`authz-placement` question and SC1 ("every path passes the same decision") is the invariant the +current code fails. `contract` was considered and dropped: the module seam is not published +(`src/index.ts` exports none of its types, `package.json` `exports` blocks deep imports, and a grep +of `apps/` and every other package found zero references), and the one published seam this work +touches, `TranslationProvider`, is unchanged by it. + +### Decision points + +- D1 [load-bearing] How the run's order is made evident and, where it matters, enforced (axis: abstractions, dataflow-state) +- D2 [load-bearing] Where the read-side permission decision attaches (axis: authz-placement, abstractions) +- D3 [load-bearing] Whether the handler survives, and as what (axis: placement, dependencies) +- D4 [load-bearing] Where the four parked concerns go, and whether the module chain joins the list (axis: placement) +- D5 [minor] Whether a named step concept exists at all (axis: abstractions) +- D6 [minor] Whether every write path passes the decision, the module seam included (axis: authz-placement) +- D7 [minor] What replaces the entry's signature, and whether lifecycle reporting leaves the wire file (axis: placement, abstractions) + +### How it lands on the system + +**Reused rather than rebuilt:** the existing `Run` value and its "never on `this`" rationale · +`targetDocument.ts`'s `ApprovedWrite` brand, which already closes the write side · `targetLayer.ts`'s +union, which keeps "publish only on a drafts layer" unbuildable · `runChain.ts`'s ordered fold with +`swallowOrThrow` · the whole leaf-narrowing algebra in `core/domain/selection` and +`core/domain/content-projection` · `TaskHandlerInput` from `modules/task-runner`. + +**Affected:** `handler.ts` (becomes `translateDocument.ts`) · `targetDocument.ts` · +`wireTranslateRunner.ts` · two barrel lines · `plugin.ts` (compile-only) · seven test files +(~1,400 lines) that construct the class · `shared/access/handler.ts` and `model.ts`, both deleted. + +**Not a consumer, and this decides D5:** `translate-field/handler.ts` takes a `PayloadRequest`, +returns a `Response`, writes nothing, and has no scope, no requester, no module chain, no criteria +and no target layer. It shares `translateContent` and `fetchSourceDocument` and nothing else. + +**The precedent that is deliberately not copied:** `core/translation-pipeline` types every stage +`(ctx: PipelineContext) => PipelineContext` over a context with seven required and six optional +fields, so misordering, dropping or swapping stages all type-check; the only guard is a runtime +throw. Its *declaration* style is readable; its *typing* would fail R5. + +### The design + +**Abstractions** + +- `ApprovedSource` (new, `targetDocument.ts`) — carries the proof that the collection's rules were + asked about this source payload. Invariant: unmintable outside `openTargetDocument`. Callers: + `selectLeaves`, `runModulesBefore`, `translateOrWrap` — three, all existing. +- `TargetDocument` (existing, narrowed) — gains `readSourceIfAllowed(): Promise` and + **loses** `readBothLocales` and `refuseUnlessTranslationAllowed`, so unapproved source data is + unrepresentable outside its file. `BothLocales` stops being exported. +- `Translated | NothingSent` (new) — a discriminated union returned by `translateOrWrap`, so a save + cannot be written against a run that translated nothing. +- `ApprovedWrite` (existing, untouched). +- **No step interface, no step array, no registry, no `.stage.ts` tag** — ST1, on the verified fact + that `translate-field` is not a second consumer. The package `CLAUDE.md` convention table is not + extended. +- `TaskHandlerInput` (existing, adopted) — `model.ts` is deleted. + +**Dependency direction.** `plugin.ts` → `features/translate-document/index.ts` (one export, +`wireTranslateRunner`) → `translateDocument.ts` → `{selectLeaves, translateOrWrap, reportNothingSent, +runModules, targetDocument, targetLayer}` → `server/modules/{translation-run, task-runner}` + +`server/shared/payload/*` → `core/*`. All edges downward, no cycle. The `features → modules/task-runner` +type edge the `TaskHandlerInput` graft needs already exists (`model.ts:2` imports from there today). + +**Placement** + +| Responsibility | File | +|---|---| +| the run's order | `translateDocument.ts` (renamed from `handler.ts`) | +| leaf narrowing (28 lines) | `selectLeaves.ts` | +| provider call + error wrap (21) | `translateOrWrap.ts` | +| "nothing was sent" (39) | `reportNothingSent.ts` | +| module-seam plumbing (31) | `runModules.ts` | +| the access decision | `targetDocument.ts` + `translationPermission.ts` (unchanged) | +| lifecycle reporting | `reportRunnerEvent.ts` (optional, last step) | +| deleted | `model.ts` · `server/shared/access/handler.ts` + its two barrel lines | + +The directory stays flat, with a named trigger for revisiting: when the listing passes ~20 entries, +or a second Payload-I/O unit lands here, split by role then. The provider-error wrap stays beside +the translate step and does **not** move into `core/translateContent`, because `translate-field` is +that function's other caller and its behaviour would change. + +The entry's body, which is the whole of R2: + +``` +const run = await openRun(deps, payload, input, scope); +const source = await run.doc.readSourceIfAllowed(); +const owed = await selectLeaves(deps.criteria, run, source, input.filters ?? []); +const state = await runModulesBefore(deps.modules, run, source, input.previousAttempts); +const outcome = await translateOrWrap(deps.translationProvider, run, source, owed); +await saveOrReport(run, outcome, deps.modules, state); +await publishIfAsked(run, input.publishOnTranslation); +``` + +**Data flow & ownership.** One `Run` built once by `openRun`, never on an object (R6). `moduleState` +has one owner, `runChain.ts`'s fold, merged by module name. `ApprovedSource` owns the source payload +and has one minter; `ApprovedWrite` has one minter. No second source of truth is introduced. + +**Security** — `authz-placement`: the read-side decision binds to the read (`readSourceIfAllowed`), +the write-side stays bound to the write (`approveWrite`), the publish asks about `{ _status }`. Three +payloads, three asks, one answering function (`translationPermission.ts`) — SC2. `authn-identity`: +`scope.requester` → `rebuildRequester`, once per run; the entry's `scope` parameter becomes +**required**, because the run's answer depends on it and an absent scope silently downgrades the run +to an unchecked write. `data-exposure` and `secrets-privilege`: unchanged, nothing new leaves the +server and no credential is created or moved. + +### Requirement coverage + +| R | Design element | +|---|---| +| R1 | `readSourceIfAllowed` → `ApprovedSource` (read-side, pre-provider, type-enforced) · `approveWrite` → `ApprovedWrite` (write-side, existing) · `publish` asks about `{_status}` · the module-seam path per D6 | +| R2 | the seven-line body of `translateDocument.ts`, first in its file; steps are hoisted top-level `function` declarations in sibling files | +| R3 | class deleted · `Handler` and `shared/access/handler.ts` deleted · `TranslateDocumentOutput` deleted, the entry returns `void` | +| R4 | a step is one sibling file plus one line in the list; a narrowing rule is one `criteria` entry, read only by `selectLeaves.ts` | +| R5 | `ApprovedSource` and `Translated \| NothingSent` as explicit hand-offs; no widening context | +| R6 | `openRun` returns a per-call `Run`; `deps` is the only long-lived value and holds no per-run state | +| R7 | the seam is **kept** — `ChainMember`, `BeforeTranslation`, `AfterSave`, `ModuleState` and `runChain.ts` unchanged — and called from the list through `runModules.ts`. Cost: it still hands a module a `Payload` (D6) | +| R8 | behaviour-preserving except the two points named in D6 and D2's consequences | + +### Vector gates + +- **structure** — no gate beyond ST1–ST6; applied per decision below. ST1 is the binding one and it + is what rules out a step framework. +- **security, the wrong-caller gate** — **(a) no requester:** `rebuildRequester` returns `null`, + `checkTranslationPermission` returns `true`, and the write runs with Payload's default + `overrideAccess: true`. This is the existing documented rule for an internal job, preserved; the + design hardens it one notch by making `scope` required, so the downgrade cannot happen by + forgetting an argument. **(b) a valid user who may not update this document:** + `readSourceIfAllowed` throws `TranslationRefused` before the provider is paid, and after this + design, reaching `translateOrWrap` without an `ApprovedSource` does not compile. **(c) a rule that + answers differently for source and translation:** three asks with three real payloads; `mayWrite` + passes `data` to Payload's own evaluator, the same argument `updateByID` uses. **(d) revoked + mid-run:** `enforcedAtTheWrite` sets `overrideAccess: false`, so Payload re-evaluates at the write. + Two honest residuals, both pre-existing: Payload *strips* a field the requester may not write + rather than throwing, so a field-level revoke yields a silently partial write; and the requester + record is rebuilt once per run, so a user deleted mid-run is not re-fetched. + +### Decisions (ADRs) + +#### D1. A plain sequence, with a typed hand-off where the order is load-bearing + +- **Context:** R2 wants a readable order, R5 forbids an untyped bag, and the repo's only stage + precedent types every stage `(ctx) => ctx` over a context whose products are optional — so + misordering type-checks and a runtime throw is the only guard. +- **Options considered:** + - an ordered array of step objects, the repo precedent — disqualified by all three candidates, because the registry would have one consumer (ST1) and copying its typing fails R5 + - a plain sequence relying on data dependencies alone (`min-new-abstraction`) + - a plain sequence **plus** branded hand-off values (`cleanest-boundaries`, agreed by `max-reuse`) +- **Decision:** the third. Top-level `function` declarations in sibling files, listed in the entry's + first lines, with two typed hand-offs: `ApprovedSource` and `Translated | NothingSent`. Clears + **ST1** (no new interface, no single-caller seam), **ST5** (the citation is R1 plus the measured + unsafety of the precedent, not "pipelines are clean"), **ST4**, **R5**. + The recorded counter-argument — *a closure forces steps below their use, so the list moves to the + bottom* — is **refuted**: it applies to `const` arrows inside a factory, not to hoisted `function` + declarations in sibling modules. +- **Consequences:** adding a step is one file plus one line. The order of `selectLeaves` relative to + `runModulesBefore` stays list order plus tests, not types — forcing it would hand `translateOrWrap` + a parameter its answer does not depend on, which the owner's signature rule forbids. That is the + design's main residual stiffness and it is deliberate. + +#### D2. The read-side decision binds to the read + +- **Context:** the write side is already closed by `ApprovedWrite`; the read-side check is a bare + call in the middle of `handle()` and only tests keep it before the provider call. A decorator and + lifting to the caller are already ruled out. +- **Options considered:** + - keep the explicit call (proposed by nobody) + - merge read-and-ask and **remove** the old methods (`min-new-abstraction`, `cleanest-boundaries`) + - merge but **keep** the old methods on the type, to spare a contract test (`max-reuse`) +- **Decision:** merge and remove. The third option is disqualified under **SC1**: leaving a reachable + read path that skips the decision, on the very type that owns the decision, is the failure SC1 + names. Its stated justification was also checked and is false — `targetDocument.contract.test.ts` + is 13 lines and asserts only about `ApprovedWrite`, referencing neither removed method. Clears + **SC1**, **SC2**, **ST1** (three existing callers), **ST4**. +- **Consequences:** an unapproved source becomes unrepresentable outside one file. The earlier + sanctioned ground — *the list should show that a permission is asked* — is honoured by naming: the + line reads `readSourceIfAllowed` and the value is typed `ApprovedSource`. This is a real trade and + the owner reopened it deliberately: the ask is no longer a standalone greppable call. + +#### D3. Module-level functions and a deps parameter; no class, no factory + +- **Context:** R3 kills the class, R6 forbids per-run state on a long-lived object, and the four + config-time dependencies are built once in `wireTranslateRunner.ts`. +- **Options considered:** + - the class (killed by R3) + - a factory closing over deps (carries the "steps move to the bottom" objection) + - module-level functions taking `deps` first (all three candidates) +- **Decision:** the third. Clears **R6**, **ST6** (the entry owns orchestration only), **ST1**. +- **Consequences:** `deps` is threaded explicitly into the three steps that need a slice of it, which + is the honest cost and is also what makes each signature claim only what its answer depends on. + Testing loses a construction step. + +#### D4. Four siblings, flat; the module chain is a line in the list + +- **Context:** ST6 — the entry must not keep four unrelated clusters. +- **Options considered:** + - three siblings with the seam plumbing inline (`min-new-abstraction`) + - four siblings including a file for the seam (`max-reuse`) + - six files plus a `store/` folder holding the I/O trio and the four error classes + (`cleanest-boundaries`) +- **Decision:** four siblings, flat, no `store/` folder. The seam gets its own file because with + D6's guard the plumbing is no longer three lines. The `store/` folder is declined on the + convention's own test — *would it change if Payload changed its storage format?* — which the four + error classes fail; and because there is no import-boundary linter, so "steps → store, never back" + would be prose. Clears **ST6** without churn no rule enforces. +- **Consequences:** ~17 entries, flat, with the named revisit trigger above. + +#### D5. No named step concept + +- **Context:** a `Step` interface is the obvious move and ST1 is the test. +- **Options considered:** a `PipelineStage`-like interface reused from `core/` · a new server-side + step interface · none. +- **Decision:** none. Re-checked at adjudication against `translate-field/handler.ts` and confirmed: + it returns `Response`, writes nothing, and has no scope, module chain, criteria or target layer, so + a step interface would have one consumer. **ST1** disqualifies it; **ST5** holds because the reason + is a verified fact rather than taste. +- **Consequences:** R4 is met as "one function plus one line", not "append to an array". Nothing in + the requirements asked for data-driven registration. + +#### D6. The module-seam write path — the one decision left to the owner + +- **Context:** `moduleSeam` hands a module `{ payload, target, scope }` with `BeforeTranslation.payload` + typed `Payload`, so a module can `payload.update` the target document with nothing asked, and with + Payload's local-API default `overrideAccess: true`. This is an **SC1** violation on a path the + design otherwise closes. +- **What was disproved:** all three candidates left it open citing "provenance needs the full + `Payload`". Verified false: every provenance write names `this.collection`, its own sidecar, and + `refuseACollidingSlug` → `assertProvenanceSlugFree` guarantees that slug differs from every host + collection. Provenance never writes the target document. A `.shapes.ts` slice would not close it + either, because the hole is about *which record*, not *which method*. +- **Options considered:** + - leave it open and record it (all three candidates; rejected — the justification was false) + - a `Proxy` over `payload` in `runModules.ts` refusing `update`/`delete` addressed at the run's own + target (adjudication). Needs no assertion and no signature change, but it is runtime interception + of a framework object whose type still says `Payload` + - hand modules a capability instead of a `Payload` — structural, and it changes + `BeforeTranslation`, `AfterSave` and the provenance service factory +- **Decision:** carried to Open questions. The evidence is settled; the remedy is the owner's call, + because option two buys closure with a runtime trick the type system hides, and option three is a + separate piece of work. Until it is decided, the hole stands as it does today. +- **Consequences:** build step 4 is blocked on this answer. Nothing else is. + +#### D7. `TaskHandlerInput` is adopted; `scope` becomes required + +- **Context:** the entry's signature must stop lying, and `model.ts` may be a duplicate. +- **Options considered:** + - keep `TranslateDocumentInput` and delete only the `TranslateDocumentOutput` type + - adopt `TaskHandler` wholesale, signature included (`max-reuse`) + - adopt `TaskHandlerInput` but declare the entry's own signature +- **Decision:** the third. `TaskHandlerInput` is field-for-field identical to + `TranslateDocumentInput`, both importing `RunAsk` from the same module — one shape with two owners. + `model.ts` is deleted and the module's type survives, because the alternative forces a + `modules → features` edge (**ST2**). `TaskHandler` itself is **not** adopted as the entry's type: + it declares `scope?`, and an absent scope means an unattributed, access-overridden write, so the + entry demands `scope`. Lifecycle reporting leaves the wire file (**ST6**, two unrelated clusters). +- **Consequences:** `wireTranslateRunner`'s seven-field hand-copy goes. `modules/task-runner/index.ts` + gains one barrel line; `features/translate-document/index.ts` is pruned to the one export + `plugin.ts` uses. + +### Build sequence + +1. `readSourceIfAllowed` and the `ApprovedSource` brand on `TargetDocument` — add the runtime + `unique symbol`, the brand and the merged read-and-ask; remove `readBothLocales` and + `refuseUnlessTranslationAllowed`; keep the existing call sites. The minting function must carry an + explicit return type, or `[approvedSource]: true` widens to `boolean` and the assertion comes back. + Verify with the real `turbo build`, not only `check-types`. — establishes the type that makes D1 + and D2 enforceable; unblocks 2 and 3 · covers: R1, R5 · via /sp-task · incremental +2. Extract the four parked concerns into `selectLeaves.ts`, `translateOrWrap.ts`, + `reportNothingSent.ts`, `runModules.ts`, still called from the class's private methods — keeps + every check green at each commit; unblocks 3 · covers: R4, R8 · via /sp-task · incremental +3. Delete the class — `translateDocument(deps, payload, input, scope)`; delete `Handler`, + `shared/access/handler.ts` and its two barrel lines; delete `model.ts`; export `TaskHandler` and + `TaskHandlerInput` from the task-runner barrel; drop the seven-field copy; prune the feature + barrel. · covers: R2, R3, R6, R7 · via /sp-task · big-bang within the package (29 construction + sites move in one commit; a compatibility shim buys nothing for an internal rename) +4. Close the module-seam hole, **blocked on D6's open question** · covers: R1 · via /sp-task · incremental +5. Split lifecycle reporting out of the wire file · covers: R4 · via /sp-task · incremental +6. Move `PermissionCheckFailed` out of `features/`, so `shared/payload/__tests__/swallowOrThrow.test.ts` + stops importing upward across the gradient · covers: R8 · via /sp-task · incremental + +**What in the test rewrite is not mechanical.** The rename at 29 sites is. What is not: `handler.test.ts` +(five inline constructions) must be split once the four concerns are sibling files, and deciding what +moves versus what stays an end-to-end order check is judgement · `handler.spend.test.ts` changes +meaning, proving at runtime what `ApprovedSource` now proves at compile time, and should stay with a +stated reason · `handler.moduleFailure.test.ts` changes fixture shape in step 2 and gains a case in +step 4 · `targetDocument.contract.test.ts` needs two cases **added**, not a rewrite. Checked and found +*not* at risk: `handler.access.test.ts` mocks `translationPermission.js` and asserts on call +arguments, which the merge preserves. + +### Open questions / unresolved + +- **D6 — how the module-seam write path is closed.** The evidence is settled (provenance is not the + obstacle); the remedy is not. Blocks build step 4 only. +- **Who may run an unattributed translation.** "No requester → no check" lives in code comments, not + in a recorded decision. Making `scope` required removes the accidental path but does not decide the + policy. Blocks nothing; should be written down before the next access change. +- **`translate-field` pays a provider with no translation-permission ask.** It checks the collection's + *read* rule and nothing else. It writes nothing, so R1 does not reach it, but it is a second path + that spends money on a document. Its own decision; blocks nothing here. +- **What `core/translateContent` may assume about provider errors.** The wrap sits in the feature + because moving it would change the field path, which leaves that function's error contract + implicitly "whatever each caller wraps differently". + +### Accepted deviations + +None. + +### Rejected / grafted (for the record) + +- Keeping `readBothLocales` / `refuseUnlessTranslationAllowed` on the type (`max-reuse`) — + disqualified: **SC1**; its cited justification was verified false. +- Minting the brand with `as ApprovedSource` (`min-new-abstraction`, `max-reuse`) — disqualified by + the owner's binding "no type assertions to silence the checker". Both candidates flagged it + themselves and shipped it anyway. The runtime-`Symbol()` mint needs none. +- The `store/` folder (`cleanest-boundaries`) — rejected on the package's own role-folder test. +- Seam plumbing inline in the entry (`min-new-abstraction`) — rejected: the entry would own a fifth + concern, **ST6**. +- Lifecycle reporting staying in the wire file (`min-new-abstraction`, `max-reuse`) — rejected: + **ST6**. Grafted from `cleanest-boundaries` as an optional last step. +- Grafted from `max-reuse`: `TaskHandlerInput` adopted and `model.ts` deleted (one shape, two owners, + **ST4**; the survivor must be the module's or **ST2** breaks), and the warning that the source brand + must not reuse the write brand's symbol — verified: a type alias gets an implicit index signature, + so a shared symbol would make the read's result assignable to `ApprovedWrite`. +- Grafted from `cleanest-boundaries`: the assertion-free mint, the `Translated | NothingSent` union, + removing `BothLocales` from the exports, the wire-file split. +- All three leaving D6 open "because provenance needs the full `Payload`" — **overruled on evidence**; + the remedy is carried to Open questions rather than decided here. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-25-the-run-becomes-a-function.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-run-becomes-a-function.task.md new file mode 100644 index 000000000..c8bddd540 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-25-the-run-becomes-a-function.task.md @@ -0,0 +1,244 @@ +Build step: 2026-10-25-the-run-as-a-list.design.md #2 + +## Task restatement + +Two build steps in one task, in this order, with the checks green in between. + +**Step 2** moves four blocks out of `handler.ts` into sibling files, while the class still calls +them from its private methods. Nothing about behaviour changes. + +**Step 3** deletes the class. `handler.ts` becomes `translateDocument.ts` holding one module-level +function; `Handler` and `model.ts` go; the entry adopts the task runner's existing +`TaskHandlerInput`; `wireTranslateRunner` stops re-spelling the input field by field. + +Covers **R2, R3, R4, R6, R7, R8** of the design. Step 4 (the module-seam write path) and step 5 +(splitting lifecycle reporting out of the wire file) are **not** in this task. + +## What already exists — platform · installed · project · verdict + +| Rung | Found | Verdict | +|---|---|---| +| project | `TaskHandlerInput` in `modules/task-runner/TaskRunnerProvider.interface.ts:9-17` — `RunAsk` plus the same six fields as `TranslateDocumentInput` | **adopted.** `model.ts` is a duplicate of a type one layer down, and the duplicate is the one with no owner | +| project | `TaskHandler` in the same file — `(payload, input, scope?) => Promise` | **not adopted as the signature.** Its `scope?` is optional, and an absent scope means an unattributed write with access checks off. Conformance is checked at the one wire site instead | +| project | `moduleSeam`'s `Pick` | reused as the parameter type of the extracted module calls — it is already a structural slice naming exactly what the chain needs | +| project | `core/domain/selection`, `core/domain/content-projection` | unchanged; `selectLeaves` stays a thin shell over them | +| platform | nothing new | — | + +## Decisions + +### D1 — `Run` moves to its own file; the four siblings do not import the entry + +**Chosen:** a new `run.ts` holding the `Run` type and `openRun` (today's `beginRun`). + +**Rejected — leave `Run` in `translateDocument.ts` and have the siblings `import type` it.** A +type-only import is erased, so there would be no runtime cycle — but the source graph would show +`selectLeaves.ts` importing from the file that imports it, and the package has no linter to keep +that honest. **ST3** asks for zero cycles, not zero *runtime* cycles. + +**Rejected — give each sibling a structural slice and never name `Run`.** Closest to "demand only +what you touch", and it needs no new file; it loses because the slices are four and five fields +wide, so each signature becomes the wide shape the previous task spent its effort removing. + +**Constraint cited:** `selectLeaves` needs `payload`, `scope`, `target`, `schema`; `runModules` +needs `payload`, `target`, `scope`; `translateOrWrap` needs `schema`, `target`; +`reportNothingSent` needs `payload.logger` and `target`. No sibling needs all of `Run`, and none +needs the entry. + +**This is a refinement of the design's file list**, which named four new files. The fifth is forced +by ST3 once the blocks leave the entry; recorded rather than taken quietly. + +### D2 — the config-time dependencies travel as a parameter, sliced per callee + +**Chosen:** `translateDocument(deps, payload, input, scope)`, and each extracted function takes only +the slice it reads — `deps.criteria`, `deps.modules`, `deps.translationProvider`. + +**Rejected — pass the whole `deps` record to each.** Three of the four would then receive three +values they never read, which is the rule about signatures claiming only what the answer depends on. + +**Constraint cited:** `wireTranslateRunner.ts:44` builds these four values once at config time and +nothing else ever writes them, so they are a plain immutable record, not state. + +### D3 — `scope` is a required parameter of the entry + +**Chosen:** `scope: RequestScope`, no default and no `?`. + +**Rejected — adopt `TaskHandler` wholesale.** With no scope, `rebuildRequester` returns `null`, +`checkTranslationPermission` answers `true` and the write runs with Payload's default +`overrideAccess: true`. A caller that forgets the argument silently downgrades the run to an +unchecked write; today's `scope: RequestScope = {}` default makes that possible. + +**Constraint cited:** `translationPermission.ts:152` returns `true` for a null user, and +`enforcedAtTheWrite(null)` adds nothing to the write. + +**Consequence to watch:** every test that calls `handle(payload, input())` with two arguments must +gain an explicit `{}`. That is a visible change at each site, which is the point. + +## Acceptance Criteria + +| AC | Covers | Criterion | How it is checked | Passes when | +|---|---|---|---|---| +| AC1 | R3 | The class is gone; the entry is a function in `translateDocument.ts` | `test -f handler.ts` and `grep -c 'export class TranslateDocumentHandler'` across `src/` | file absent; count `0` (now 1 class, 13 construction sites) | +| AC2 | R3 | The false interface is gone | `src/server/shared/access/handler.ts` absent; `grep -rc 'Handler<'` in `src/` | file absent, `0` importers (now 4 files name it) | +| AC3 | R3 | `model.ts` is gone and the task runner's type is the survivor | file absent; `grep -c TaskHandlerInput src/server/modules/task-runner/index.ts` | `≥1` (now 0) | +| AC4 | R6 | The entry demands a scope | `grep -A4 'export async function translateDocument' translateDocument.ts` | the parameter is `scope: RequestScope`, with no `?` and no `= {}` | +| AC5 | R4 | The four concerns live in their own files | `selectLeaves.ts`, `translateOrWrap.ts`, `reportNothingSent.ts`, `runModules.ts` exist; none of their symbols is declared in `translateDocument.ts` | all four present; `REPORTED`, `emptyBecause`, `moduleSeam` not declared in the entry | +| AC6 | R2 | The order still reads as a list | count the **top-level statements** of the entry function body, one per step | ≤ 9 — see the amendment below | +| AC7 | R7, R8 | Behaviour is unchanged | `bunx vitest run`; then rebuild and `bun run test:integration` from `apps/dev` | 190 files and **2487** checks or more, all passing; **225 passed / 8 skipped** | +| AC8 | — | No module imports the entry except the wire and the tests | `grep -rl 'translateDocument.js' src/` | only `wireTranslateRunner.ts` and files under `__tests__/` | +| AC9 | — | The real declaration build passes | `bunx turbo run build --filter='./packages/payload-plugin-translator'` from the root | exit 0 | +| AC10 | — | No new type errors, no new analyzer findings | `bun run check-types` in the package and in `apps/dev`; `sp-lint-delta.sh -- bunx oxlint .` | `0` each; nothing introduced | +| AC11 | R8 | The other translation path is untouched | `git diff --name-only` | `src/server/features/translate-field/` absent from the list | + +## Pre-flight + +| AC | Result now | Kind | +|---|---|---| +| AC1 | `handler.ts` present, 1 class, **13** construction sites | must change — fails now ✓ | +| AC2 | `shared/access/handler.ts` present, **4** files name `Handler` | must change — fails now ✓ | +| AC3 | `model.ts` present; `TaskHandlerInput` in the task-runner barrel: **0** | must change — fails now ✓ | +| AC4 | the entry is a method with `scope: RequestScope = {}` | must change — fails now ✓ | +| AC5 | all four blocks declared in `handler.ts` | must change — fails now ✓ | +| AC6 | entry body **22** lines | invariant — passes now ✓ | +| AC7 | **190 files / 2487 checks**; integration 225 / 8 | invariant — passes now ✓ | +| AC10 | `check-types` 0; oxlint 43 warnings, 0 errors | invariant — passes now ✓ | +| AC9, AC11 | build green at the last run; `translate-field` untouched by step 1 | invariant — re-run in Phase 5 | + +## Risk notes + +- **High risk, and it binds.** 13 construction sites and a deleted shared interface, across seven + test files of about 1,400 lines. The regression sweep is evidenced and Phase 5b runs several + review angles. +- **Step 1 is in the working tree, uncommitted**, and this task builds on it. Everything here is + measured against that state, not against `HEAD`. +- **The test rewrite is not all mechanical.** Mechanical: the 13 constructions and the import of the + input type. Not mechanical: `handler.test.ts` holds five inline constructions and its narrowing, + nothing-sent and provider-wrap cases now belong beside the files that own those concerns — what + moves and what stays an end-to-end order check is judgement. `handler.spend.test.ts` changes + meaning, proving at runtime what `ApprovedSource` proves at compile time; it stays, with the reason + written down. `handler.moduleFailure.test.ts` changes fixture shape in step 2. +- **The two steps are verified separately.** Step 2 must be green before step 3 starts, so a failure + in step 3 cannot be confused with a bad extraction. +- **Not in scope:** the module seam's unasked write path (design D6, step 4) · splitting lifecycle + reporting out of the wire file (step 5) · moving the provider-error wrap into `core` (declined, + it would change `translate-field`). + +### AC6 amended during Phase 3, before it was graded + +AC6 first said "the entry body is at most 22 lines, the number it is today". That measure fails for a +reason that has nothing to do with readability: the extracted functions take object arguments, which +is this package's idiom (`openTargetDocument`, `resolveTargetLayer`, `fetchSourceDocument`), and the +formatter breaks each of them across five lines. The body is 44 physical lines and **9 top-level +statements** — one per step, which is the property R2 actually asks for. + +Changed to count statements rather than lines, and recorded here rather than quietly re-graded. The +same mistake — a line-count target set from a guess — is what made AC4 of +`2026-10-25-one-run-one-context.task.md` unmeetable. + +## Human choices + +**2026-10-25, at the gate.** *"В своём файле run.ts"* — D1 stands: `Run` and `openRun` move to +`run.ts`, and the four siblings import the type from there. The two alternatives were offered and +declined: a type-only back-import from the entry (erased at build time, but a cycle a reader can +see and nothing in the package can catch), and no shared type at all (honest about what each +function touches, at the cost of four- and five-field signatures). + +## Result — every criterion walked with its declared check + +| AC | Evidence | Verdict | +|---|---|---| +| AC1 | `handler.ts` absent; `class TranslateDocumentHandler`: **0** occurrences (was 1 class, 13 construction sites) | met | +| AC2 | `shared/access/handler.ts` absent; the one remaining `Handler<` is `withErrorHandler`, an unrelated generic | met | +| AC3 | `model.ts` absent; `TaskHandlerInput` exported from the task-runner barrel and used by the entry | met | +| AC4 | `translateDocument(deps, payload, input, scope: RequestScope)` — no `?`, no `= {}` | met | +| AC5 | `run.ts`, `selectLeaves.ts`, `runModules.ts`, `translateOrWrap.ts`, `reportNothingSent.ts` all present; none of their symbols declared in the entry | met | +| AC6 | **8** top-level statements in the entry body, against the amended ceiling of 9 | met | +| AC7 | **190 files / 2490 checks**, all passing; integration **225 passed / 8 skipped** | met | +| AC8 | only `wireTranslateRunner.ts` and the seven test files import the entry | met | +| AC9 | `turbo run build` exit 0 — run again after the exports were narrowed, which is what proves `Owed` needs no export | met | +| AC10 | `check-types` 0 in the package and in `apps/dev`; `sp-lint-delta` `mode=two-run`: nothing introduced; 43 warnings / 0 errors, the baseline | met | +| AC11 | `translate-field/` absent from the changed-file list | met | + +The package suite went 2487 → 2490 with no test added by hand. The difference is +`core/__tests__/every-throw-is-a-translator-error.test.ts`, which runs one check per source file +under `src/server` and `src/core`: step 2 added five files, step 3 removed three and added one. + +`sp-diff-checks`: clean, 5 checks, no findings. + +## Review log + +### 2026-10-25 — comment audit (6 candidates, 18 entries including stranded comments) + +DELETE 5 · REWRITE-COMMENT 11 · MISSING 2. Every verdict applied except the two MISSING, one of +which was applied (a rename) and one of which is debt (below). + +**The finding that mattered: a new comment of mine claimed a protection the code does not give.** +The entry's docblock said `scope` is required "so a caller that forgets it would not downgrade the +run in silence". Checked and false: `RequestScope` has only optional fields, so `{}` type-checks and +downgrades exactly as `undefined` did, and the single production caller supplies that `{}` itself +(`wireTranslateRunner.ts:47`, `scope ?? {}`, because `TaskHandler`'s third parameter is optional). +Making the parameter required moved the default one call up; it did not remove it. Rewritten to the +one fact a caller needs: an empty scope is not neutral — with no requester, no rule is asked and both +the read and the write run with Payload's access checks off. + +This is the third comment in this session to overclaim in the same way: an absolute ("cannot", +"always", "in silence") that turned out to be "usually" or "yes, but visibly". + +Four docblocks were deleted as restatement. The clearest was the one on `Run`, which warned against +holding a run's state "per instance" — the instance it warned about no longer exists, because +removing the class removed that danger structurally. + +Ten pre-existing comments were stranded by the move and rewritten: a `{@link TranslateDocumentHandler}` +that no longer resolves, five `describe` titles naming the deleted class, and three test comments +saying "the handler's". + +### 2026-10-25 — three review angles (high risk) + +**Behaviour equivalence — no findings.** Step order, every moved body line by line, the two +constructor defaults now applied per call, `openRun`'s effect order and its four-field mapping, the +`scope` default, and the discarded return value were each compared against the pre-split file. + +One real difference it surfaced, below a defect: the wiring used to rebuild the job input as a +seven-field literal and now passes the runner's object straight through, so an extra property a +runner attached would travel inward. Nothing in the moved code reads beyond the seven fields, so +nothing observable changes. + +**The test rewrite — no weakened tests.** Each `{ success: true }` assertion became +`toBeUndefined()`, which still fails on a rejection, and every one sits beside a real behavioural +check. Scopes carrying a requester or a transaction id were preserved at every site; `{}` was added +only where the call previously passed none — and the deleted file's default was `scope: RequestScope = {}`, +so those sites are equivalent. No double became shared, no `await` was dropped, no rename half-landed. + +**Structure — two fixes applied, one declined, three recorded.** +Applied: `RunAddress` was exported with no consumer outside its file and, worse, its parameter name +`address` collides with this package's own meaning of "address" (a leaf's address in a document). +The type is gone and the parameter is now `taskInput`, which says where the value comes from. +`Owed` lost its surplus `export`, and the real `tsc` build was re-run to prove the declaration emit +does not need it. + +Declined: making `reportNothingSent` and `publishIfAsked` take object arguments to match their three +peers. Both take two parameters and read fine positionally; an object argument for two values is +ceremony, and `publishIfAsked(run, input.publishOnTranslation)` already names its boolean at the +call site. + +## Follow-ups — recorded, not done here + +1. **`TaskHandler.scope` should be required.** Both in-repo runners already always pass one + (`SyncTaskRunner.ts:76`, `PayloadJobsRunnerProvider.ts:139`), but the type is part of the + published `TaskRunnerProvider` contract, so a third-party runner calling `handler(payload, input)` + would stop compiling. It needs its own commit as a breaking change. Until then the silent + `scope ?? {}` survives at the wiring, which is the one place the new signature could not reach. +2. **The seven test files are still named `handler.*.test.ts`** for a module called + `translateDocument.ts`. Their `describe` titles were corrected; the filenames were left, because + seven renames would add diff noise to an already large change. +3. **`reportNothingSent(run, reason: EmptyBecause | null)` hides a fourth state behind `null`** — + `null` means "narrowing kept leaves but the collector sent none". Pre-existing shape, carried + across unchanged; making it a named fourth member is its own small change. +4. **`data as ApprovedWrite`** remains the file's one type assertion, while `ApprovedSource` beside + it needs none. The asymmetry has a stated cause (a runtime symbol key would be copied into the + object handed to Payload as `data`), but whether Payload actually ignores symbol keys was never + checked. +5. **`Provenance.module.ts:6` imports `features/staleness`** — a `modules` → `features` edge against + the declared direction. Older than this work. +6. **The layering rule is not written down.** A reviewer looked for "features → modules/shared → + core" in the package `CLAUDE.md` and did not find it; it exists as prose in the root guide and in + the briefs. Worth stating in the package guide, which is the owner's file to edit. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-25-two-routes-start-asking.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-25-two-routes-start-asking.task.md new file mode 100644 index 000000000..c8e7bb72c --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-25-two-routes-start-asking.task.md @@ -0,0 +1,185 @@ +Build step: 2026-10-25-asking-payload-everywhere.design.md #3 + +## Task restatement + +Two HTTP routes act on a document after asking only whether the caller may **read** it. Both start +asking whether the caller could **change** it, through one new function in the shared layer. + +- **`POST /translate/field`** reads the source under the caller's read rule and pays the translation + provider. It never asks about update rights. Gap 5 of the design, and its trace 1. +- **`POST /translate/stale/dismiss`** checks read access and then **writes**: `ProvenanceService.dismiss` + calls `store.rebaseSourceFingerprint`, which re-bases the receipt so the out-of-date indicator goes + quiet for everyone. The seventh gap, found at adjudication and recorded in the design's + `## Open questions`. + +Covers **R2** and **R5**. Nothing from steps 4–10 is in scope; the worker and the auto-translate hook +are not touched at all. + +## What already exists — platform · installed · project · verdict + +| Rung | Found | Verdict | +|---|---|---| +| project | `mayWrite` (`shared/access/permission.ts`) — asks the collection's `update` rule through Payload's own `docAccessOperation`, the evaluator behind `/api//access` | **the mechanism.** `mayInitiate` is it with empty `data` | +| project | `identityOf` (`shared/payload/identityOf.ts`) — turns a request into a `Requester` (id + auth collection) | **not what `mayWrite` needs.** It wants a `RebuiltRequester`: the user *document* plus its collection key, because Payload's evaluator needs a user, not an id | +| project | `req.user` on the HTTP path | **is** a `RebuiltRequester` when it carries `collection`, which `TypedUser` does. No rebuild, no round trip | +| project | `ServerResponse.forbidden()` / `.notFound()` | reused unchanged | +| project | `provenance-access.int.test.ts` | the project already named this exact threat — *"overwrite the dismissal field to silence the out-of-date indicator on someone else's document"* — and closed the **direct** path by locking the sidecar collection. The route is the other door | + +Nothing new is installed. + +## Decisions + +### D1 — one new function, `mayInitiate`, in `shared/access/` + +**Chosen:** `mayInitiate` beside `mayWrite`, asking the same rule with `data: {}`. + +**Rejected — call `mayWrite` directly from both handlers with an empty object literal.** It would put +the same undocumented `{}` at two call sites and leave nothing to name why it is empty. The empty +payload is the whole point: the question is "could this person save this document at all", not "may +this exact write go through", and a named function is where that distinction is stated once. + +**Constraint cited:** `mayWrite` carries a stated precondition — "`data` must be the payload that +write will send" (`permission.ts`) — and both of these calls deliberately break it. A named function +is where that exception is stated once rather than re-explained at each site. Two callers exist today, +both in this task; later steps may add more, but a caller that is not in the codebase does not count +towards the two. + +### D2 — what the dismiss route asks about + +**Chosen:** the **source document** named in the request — the same document whose staleness is being +dismissed — not the provenance sidecar row. + +**Rejected — ask about the sidecar collection.** The sidecar is deliberately closed to everyone +(`provenance-access.int.test.ts`), so asking there would refuse every caller, including legitimate +ones. The decision the route actually makes is "this person says this document's translation is +fine", which is a claim about the document. + +**Constraint cited:** `ProvenanceService.dismiss` already takes `req.user` and reads the source +document under that user's read rule; the ask lands beside a check that is already about the source. + +**Noted, not a defect:** `mayInitiate` asks with empty data, so a host rule that branches on `data` +answers a slightly different question than it would at a real write. For the field route this cannot +matter — it writes nothing. For dismiss, the write goes to the sidecar, not to the document, so no +`data`-dependent rule on the document would be the right thing to ask anyway. + +### D3 — whether an actor-less request is refused + +**This is the gate question.** Both routes can be reached with no user: `AnyAccessGuard` is a +supported configuration, and the integration helper `callEndpoint` defaults to `user: null`. + +Today, everywhere in the plugin, "no requester" means the question is not asked +(`checkTranslationPermission`, `enforcedAtTheRead`, `enforcedAtTheWrite`). The design's step 8 is +where that policy is re-decided, through a three-valued identity. Doing it here would decide step 8's +question early — and three integration suites (`per-field-receipt`, `field-surface-noop`, +`field-surface-errors`) call the field route with no user, so it would turn them red. + +Options and their costs are in the gate message. + +## Acceptance Criteria + +| AC | Covers | Criterion | How it is checked | Passes when | +|---|---|---|---|---| +| AC1 | R2 | `mayInitiate` exists in the shared layer and both handlers call it | `grep` for its declaration and its two call sites | 1 declaration, 2 callers | +| AC2 | R5 | A named caller the `update` rule refuses gets 403 from `POST /translate/field` | a new case in `src/server/features/translate-field/__tests__/` | exit 0, the case runs | +| AC3 | R5 | …and **the provider is never called** for that caller | the same case asserts the provider double got no call; **mutation**: move the ask after `translateContent` and the case must go red | red on the mutation | +| AC4 | R5 | A named caller the rule refuses gets 403 from `POST /translate/stale/dismiss`, and the receipt is unchanged | a new case in `src/server/features/staleness/__tests__/` asserting the store's write was not called | exit 0, the case runs | +| AC5 | R5 | Neither change is a blanket refusal: a caller the rule allows still gets a translation and still dismisses | the existing `read-access-field.int.test.ts` case *"still translates for a caller the rule allows"* stays green, plus the allowed half of the new cases | exit 0 | +| AC6 | R2 | The ask happens before the source is read | read the handler: `mayInitiate` precedes `fetchSourceDocument` | true, and AC3's mutation proves the order matters | +| AC7 | — | Behaviour is otherwise unchanged | `bunx vitest run`; count changed assertion lines in pre-existing test files | **0** changed assertion lines; any count change explained by the per-file suite | +| AC8 | — | End to end, with a real Payload | rebuild, then from `apps/dev`: `bun run test:integration` | **225 passed / 8 skipped**, or more with every addition named | +| AC9 | — | No new type errors, no new analyzer findings, the real build passes | `check-types` in the package and `apps/dev`; `sp-lint-delta.sh`; `turbo run build` | `0`, nothing introduced, exit 0 | + +## Pre-flight + +| AC | Result now | Kind | +|---|---|---| +| AC1 | `mayInitiate` appears **0** times in `src/` | must change — fails now ✓ | +| AC2, AC3 | the field handler names no update check; `fetchSourceDocument` at `:76` and `translateContent` at `:131` run for any caller the read rule allows | must change — fails now ✓ | +| AC4 | the dismiss handler names no update check | must change — fails now ✓ | +| AC6 | nothing precedes the read | must change — fails now ✓ | +| AC5, AC7 | 190 files / **2491** passing | invariant — passes now ✓ | +| AC9 | `check-types` 0; oxlint 43 warnings, 0 errors | invariant — passes now ✓ | +| AC8 | 225 / 8 at the last run on this tree | invariant — re-run in Phase 5 | + +## Risk notes + +- **High risk, and it binds.** This changes who may do what, on two routes, and one of them spends + money. A test is required before it is called done, the regression sweep is evidenced, and Phase 5b + runs several review angles. +- **A refusal must cost nothing.** The whole point of AC3 is that the ask precedes the provider call. + An implementation that asks after reading but before writing would pass AC2 and still leak the + spend — which is why AC3 is mutation-proven rather than asserted. +- **Three integration suites call the field route with no user.** They are the reason D3 is a gate + question rather than a decision I take alone. +- **`callEndpoint` builds the request in-process**, so "no user" in those suites means "a request that + carried no user", which in a deployment means the host's guard let an anonymous caller through. +- Not in scope: the action union (step 4), the enqueue ask (5), jobs resolving to documents (6), the + brand at the queue sink (7), the three-valued identity (8), the module seam (9). + +## Human choices + +**2026-10-25, at the gate.** *"Не спрашивать, как везде"* — D3 resolves to asking only when the +request carries a nameable user. An actor-less request is treated exactly as every other path in the +plugin treats it today, and the policy for such requests is re-decided at step 8, where the design +introduces a three-valued identity. + +The alternative — refuse an actor-less caller here — was offered and declined. It would have closed +more, but it would have decided step 8's question early, piecemeal, and without the reasoning step 8 +is going to do; and it would have turned three integration suites red for a reason unrelated to what +they test. + +## Review log + +**2026-10-25, Phase 5b — three independent angles**, run because the Risk notes classify this change +high risk (it changes who may do what, and one of the two routes spends money). + +| Angle | Asked to break | Outcome | +|---|---|---| +| the guarantee | every path to the effect that skips the ask, on both routes and both `route.ts` files | no way round it; one real defect found (below) | +| the new tests | whether either file is green-but-toothless — mock interception, revert-sensitivity, fixture honesty | no toothless case; both mocks resolve to the module the handler imports | +| the shape | whether `mayInitiate` earns its existence, its signature, and its placement | keep it; two wording findings, both applied | + +### R1 — a document that is not there failed the ask instead of answering it (found, fixed here) + +`mayInitiate` asks with empty `data`. Payload's `docAccessOperation` sets `fetchData: id ? true : false`, +and `getEntityPermissions` treats `{}` as "no data supplied", so it loads the document itself with +`findByID` — **without** `disableErrors`. A nonexistent id therefore threw `NotFound`, which `evaluate` +wrapped as `PermissionCheckFailed`, which is a `TranslatorError` rather than an `APIError`, so +`withErrorHandler` answered **500** carrying the "that save was rolled back" sentence. + +Verified by probe rather than by reading: the real evaluator, driven with a `findByID` that rejects +with `NotFound`, returned `{"kind":"threw","name":"PermissionCheckFailed"}` instead of a boolean. + +Before this task the field route answered `404` for a missing id, because `fetchSourceDocument` sets +`disableErrors: true` on purpose and `sourceDocument.ts` states that absent and refused are +deliberately the same answer. The ask broke that, so the defect belongs to this task and is fixed in +it: `absenceIsNotARefusal` lets an absent document through the ask and leaves the answer to the +caller's own read. Every other failure is still raised. + +Three checks in `shared/access/__tests__/mayInitiate.absentDocument.test.ts`, written red (1 of 3 red, +for the right reason) and each proven by its own mutation — answering `false` for absence reddens only +the first; swallowing every failure rather than only absence reddens only the third. + +### R2 — refused and absent are now distinguishable (recorded, deferred to step 8) + +With R1 fixed, an existing document the caller may not update answers `403` and a document that is not +there answers `404`, so a caller who can reach the route can tell that a document exists. Before this +task both answered `404`. The leak is small — the route already requires whatever the host's own guard +demands, and ids are usually guessable — but it is a real change and it is the same question step 8 +settles when identity becomes three-valued. Not closed here, because closing it means choosing between +`403` and `404` for both cases, which is a contract decision for the owner rather than a defect. + +### Applied from the shape angle + +- D1's justification above no longer rests on callers that do not exist; it rests on the precondition + `mayWrite` states and these two calls deliberately break. +- The `scope` sentence in `mayInitiate`'s docblock was dropped: omitting the parameter already says it. +- `FieldTranslation`'s contract now says a `403` is something only a named caller can be, so a reader + does not take "refused (`403`)" as covering an actor-less request. + +### Verification after the fix + +`check-types` 0 · oxlint 43 warnings / 0 errors (back to baseline; the one the fix introduced, +`no-use-before-define`, was removed by moving the helper above its caller) · package suite 193 files / +2502 checks, up from 192 / 2499 by exactly the three new ones · real `turbo build` green · integration +225 passed / 8 skipped, unchanged. diff --git a/packages/payload-plugin-translator/package.json b/packages/payload-plugin-translator/package.json index 2ff8a61d0..d65419310 100644 --- a/packages/payload-plugin-translator/package.json +++ b/packages/payload-plugin-translator/package.json @@ -34,6 +34,10 @@ "./client/*": { "import": "./dist/client/*.js", "types": "./dist/client/*.d.ts" + }, + "./testing": { + "import": "./dist/testing/index.js", + "types": "./dist/testing/index.d.ts" } }, "files": [ @@ -63,7 +67,8 @@ "@payloadcms/richtext-lexical": "^3.76.0", "@payloadcms/ui": "^3.76.0", "payload": "^3.76.0", - "react": "^18.0.0 || ^19.0.0" + "react": "^18.0.0 || ^19.0.0", + "vitest": "^3.0.0" }, "dependencies": { "@hookform/resolvers": "^3.9.0", @@ -93,5 +98,10 @@ "react": "19.0.0", "typescript": "5.5.2", "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } } } diff --git a/packages/payload-plugin-translator/src/__tests__/plugin.test.ts b/packages/payload-plugin-translator/src/__tests__/plugin.test.ts index 7dfffe19d..2e08e9b2a 100644 --- a/packages/payload-plugin-translator/src/__tests__/plugin.test.ts +++ b/packages/payload-plugin-translator/src/__tests__/plugin.test.ts @@ -402,7 +402,6 @@ describe("translatorPlugin — lifecycle callbacks", () => { collectionId: "doc-1", sourceLng: "en", targetLng: "de", - strategy: "overwrite", publishOnTranslation: false, ...overrides, }); @@ -410,7 +409,7 @@ describe("translatorPlugin — lifecycle callbacks", () => { const mockPayload = () => ({ findByID: vi.fn().mockResolvedValue({ id: "doc-1" }), - logger: { error: vi.fn() }, + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, collections: { posts: { config: { versions: undefined } } }, }) as unknown as Payload; @@ -461,7 +460,6 @@ describe("translatorPlugin — lifecycle callbacks", () => { collectionId: "doc-1", sourceLng: "en", targetLng: "de", - strategy: "overwrite", handle: "run-1", }; @@ -483,7 +481,9 @@ describe("translatorPlugin — lifecycle callbacks", () => { return captured as TaskRunnerContext; }; - const payload = { logger: { error: vi.fn() } } as unknown as Payload; + const payload = { + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, + } as unknown as Payload; const mappings: Array<[string, keyof TranslationLifecycleCallbacks, TaskEvent]> = [ ["queued", "onQueued", { state: "queued" }], diff --git a/packages/payload-plugin-translator/src/auto-translate-config.ts b/packages/payload-plugin-translator/src/auto-translate-config.ts index 8ccfa2ab7..3e1954969 100644 --- a/packages/payload-plugin-translator/src/auto-translate-config.ts +++ b/packages/payload-plugin-translator/src/auto-translate-config.ts @@ -4,7 +4,6 @@ import type { AutoTranslateConfig } from "./core/domain/auto-translate/index.js" import { AUTO_TRANSLATE_CUSTOM_KEY } from "./core/domain/auto-translate/index.js"; export type { AutoTranslateConfig }; -export type { AutoTranslateStrategy } from "./core/domain/auto-translate/index.js"; /** * Enable opt-in **auto-translate** for a collection: when a document's source-locale content changes @@ -16,7 +15,8 @@ export type { AutoTranslateStrategy } from "./core/domain/auto-translate/index.j * Behaviour: fires only on a **published** source save (draft/autosave saves are ignored; a collection * without drafts treats every save as published); skips when no translatable content actually changed * (drift-gate); coalesces rapid edits via `debounceMs`; never re-triggers on its own translation - * writes; and never fails the editor's save (best-effort). + * writes; and never fails the editor's save (best-effort). An automatic run translates the whole + * document — there is no way to narrow it. * * **Requires a working job runner.** Auto-translate only ENQUEUES jobs — they run via the plugin's * task runner (`createPayloadJobsRunner`) and its autorun loop. On serverless platforms such as @@ -25,7 +25,7 @@ export type { AutoTranslateStrategy } from "./core/domain/auto-translate/index.j * self-hosted worker. Ensure your deployment actually executes queued jobs before relying on this. * * @param collection - The collection to opt in. - * @param config - Target locales (+ optional strategy, debounce, source-locale override). + * @param config - The rule to stamp; see {@link AutoTranslateConfig}. * @returns A new collection with the auto-translate rule applied (the input is not mutated). * * @example diff --git a/packages/payload-plugin-translator/src/client/entities/translation/api/mutations/useQueueDocumentTranslation.ts b/packages/payload-plugin-translator/src/client/entities/translation/api/mutations/useQueueDocumentTranslation.ts index cd9bb511d..13a9ac561 100644 --- a/packages/payload-plugin-translator/src/client/entities/translation/api/mutations/useQueueDocumentTranslation.ts +++ b/packages/payload-plugin-translator/src/client/entities/translation/api/mutations/useQueueDocumentTranslation.ts @@ -5,16 +5,12 @@ import { useTranslateKitConfig } from "../../../../app/config/index.js"; import { handleNextApiError } from "../../../../shared/lib/errors/handleApiError.js"; import { DOCUMENT_STALENESS_QUERY_KEY } from "../queries/useDocumentStaleness.js"; -type TranslationStrategy = "overwrite" | "skip_existing"; - type Variables = { source_lng: string; - // string (single mode) or string[] (multi mode) — the /enqueue endpoint accepts both and fans out. target_lng: string | string[]; collection_slug: string; collection_id: Array; select_all?: boolean; - strategy?: TranslationStrategy; publish_on_translation?: boolean; }; diff --git a/packages/payload-plugin-translator/src/client/entities/translation/model/types.ts b/packages/payload-plugin-translator/src/client/entities/translation/model/types.ts index 8646245bc..42418756b 100644 --- a/packages/payload-plugin-translator/src/client/entities/translation/model/types.ts +++ b/packages/payload-plugin-translator/src/client/entities/translation/model/types.ts @@ -77,6 +77,7 @@ export type StalenessLocale = { source_lng: string; is_stale: boolean; translated_at: string; + changed_leaves?: string[]; }; /** Per-locale staleness for a single document. */ diff --git a/packages/payload-plugin-translator/src/client/entities/translation/ui/TranslationStatusList/TranslationStatusList.tsx b/packages/payload-plugin-translator/src/client/entities/translation/ui/TranslationStatusList/TranslationStatusList.tsx index 2c264ab55..20f7b6392 100644 --- a/packages/payload-plugin-translator/src/client/entities/translation/ui/TranslationStatusList/TranslationStatusList.tsx +++ b/packages/payload-plugin-translator/src/client/entities/translation/ui/TranslationStatusList/TranslationStatusList.tsx @@ -8,7 +8,6 @@ import { CloseIcon } from "../../../../shared/lib/assets/icons/CloseIcon.js"; import { ReloadIcon } from "../../../../shared/lib/assets/icons/ReloadIcon.js"; import { useCancelDocumentTranslation } from "../../api/mutations/useCancelDocumentTranslation.js"; import { useDismissStaleness } from "../../api/mutations/useDismissStaleness.js"; -import { useQueueDocumentTranslation } from "../../api/mutations/useQueueDocumentTranslation.js"; import { useRunDocumentTranslation } from "../../api/mutations/useRunDocumentTranslation.js"; import type { TranslationStatusRow } from "../../model/statusRows.js"; import { ActionButton } from "../ActionButton/index.js"; @@ -52,7 +51,6 @@ export function TranslationStatusList({ rows, collection, id }: TranslationStatu const runApi = useRunDocumentTranslation(); const cancelApi = useCancelDocumentTranslation(); const dismissApi = useDismissStaleness(); - const queueApi = useQueueDocumentTranslation(); const [dismissing, setDismissing] = useState>(new Set()); const dismiss = async (targetLocale: string) => { @@ -68,19 +66,6 @@ export function TranslationStatusList({ rows, collection, id }: TranslationStatu }); }; - const refreshChangedLeaves = (row: TranslationStatusRow) => - withToast( - () => - queueApi.mutateAsync({ - source_lng: row.sourceLocale, - target_lng: row.targetLocale, - collection_slug: collection, - collection_id: [id], - strategy: "skip_existing", - }), - "Failed to queue translation" - ); - const retry = (row: TranslationStatusRow) => row.jobId && withToast(() => runApi.mutateAsync({ id: row.jobId! }), "Error retrying"); const runNow = (row: TranslationStatusRow) => @@ -132,12 +117,6 @@ export function TranslationStatusList({ rows, collection, id }: TranslationStatu case "stale": return ( - } - title={`Re-translate from ${row.sourceLocale}`} - onClick={() => refreshChangedLeaves(row)} - loading={queueApi.isPending} - /> } title="Hide this notice until the source changes again" diff --git a/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/constants.ts b/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/constants.ts index ff7d28bb1..e0cab0d35 100644 --- a/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/constants.ts +++ b/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/constants.ts @@ -1,10 +1,7 @@ -import type { TranslationStrategy } from "../../../shared/ui/form/FormSelectStrategy/index.js"; - export const FORM_FIELDS = { SOURCE_LNG: "source_lng", TARGET_LNG: "target_lng", HIDDEN_COLLECTION_SLUG: "collection_slug", - STRATEGY: "strategy", PUBLISH_ON_TRANSLATION: "publish_on_translation", } as const; @@ -12,6 +9,5 @@ export const defaultValues = { [FORM_FIELDS.SOURCE_LNG]: "", [FORM_FIELDS.TARGET_LNG]: "", [FORM_FIELDS.HIDDEN_COLLECTION_SLUG]: "", - [FORM_FIELDS.STRATEGY]: "overwrite" as TranslationStrategy, [FORM_FIELDS.PUBLISH_ON_TRANSLATION]: false, }; diff --git a/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/schema.test.ts b/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/schema.test.ts index 6f9f01dab..67f1ae6b3 100644 --- a/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/schema.test.ts +++ b/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/schema.test.ts @@ -6,7 +6,6 @@ import { validationSchema } from "./schema.js"; const base = { [FORM_FIELDS.SOURCE_LNG]: "en", [FORM_FIELDS.HIDDEN_COLLECTION_SLUG]: "posts", - [FORM_FIELDS.STRATEGY]: "overwrite" as const, [FORM_FIELDS.PUBLISH_ON_TRANSLATION]: false, }; diff --git a/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/schema.ts b/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/schema.ts index bcd37f3d8..b11482967 100644 --- a/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/schema.ts +++ b/packages/payload-plugin-translator/src/client/features/collection-translation-form/model/schema.ts @@ -10,7 +10,6 @@ export const validationSchema = z.object({ .union([z.string(), z.array(z.string())]) .refine((value) => value.length > 0, { message: "Select at least one language" }), [FORM_FIELDS.HIDDEN_COLLECTION_SLUG]: z.string().nonempty(), - [FORM_FIELDS.STRATEGY]: z.enum(["overwrite", "skip_existing"]), [FORM_FIELDS.PUBLISH_ON_TRANSLATION]: z.boolean().default(false), }); diff --git a/packages/payload-plugin-translator/src/client/features/collection-translation-form/ui/CollectionTranslationForm.tsx b/packages/payload-plugin-translator/src/client/features/collection-translation-form/ui/CollectionTranslationForm.tsx index 2a7ca905f..05b484d7f 100644 --- a/packages/payload-plugin-translator/src/client/features/collection-translation-form/ui/CollectionTranslationForm.tsx +++ b/packages/payload-plugin-translator/src/client/features/collection-translation-form/ui/CollectionTranslationForm.tsx @@ -9,7 +9,6 @@ import { useLocaleOptions } from "../../../shared/lib/payload/hooks/useLocaleOpt import Button from "../../../shared/ui/Button/index.js"; import FormSelect from "../../../shared/ui/form/FormSelect/index.js"; import FormMultiSelect from "../../../shared/ui/form/FormMultiSelect/index.js"; -import { FormSelectStrategy } from "../../../shared/ui/form/FormSelectStrategy/index.js"; import { FormCheckboxPublish } from "../../../shared/ui/form/FormCheckboxPublish/index.js"; import { FORM_FIELDS } from "../model/constants.js"; @@ -74,7 +73,6 @@ export function CollectionTranslationForm({ /> )} - {hasDrafts && }