From 3d6ae1dd9fcd6b095e5fceb70ed1b476ecd9bd58 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 5 Sep 2026 09:35:48 +0900 Subject: [PATCH 01/21] feat(i18n): gate Customer Master on published screen copy Load the complete authenticated Customer Master translation resource before customer data and expose an explicit retry state when copy is unavailable. Signed-off-by: Codex --- .../0362-versioned-ui-translation-ledger.md | 11 +++++ ...stomer-master-translation-gate-desktop.png | Bin 0 -> 20809 bytes ...ustomer-master-translation-gate-mobile.png | Bin 0 -> 13999 bytes docs/storybook-inventory.md | 1 + frontend/src/App.test.tsx | 17 ++++++- frontend/src/App.tsx | 38 +++++++++++++++- frontend/src/api.test.ts | 14 ++++++ frontend/src/api.ts | 20 +++++++++ .../ScreenTranslationGate.stories.tsx | 18 ++++++++ .../components/ScreenTranslationGate.test.tsx | 17 +++++++ .../src/components/ScreenTranslationGate.tsx | 21 +++++++++ frontend/src/evidenceStatusI18n.ts | 7 ++- frontend/src/i18n.test.ts | 21 +++++++-- frontend/src/i18n.ts | 42 ++++++++++++++++-- frontend/src/occupationalConstructI18n.ts | 5 ++- frontend/src/ontologyExplorerI18n.ts | 7 ++- frontend/src/projectHistory.ts | 4 +- frontend/src/workerFunctionPsychologyI18n.ts | 9 ++-- 18 files changed, 233 insertions(+), 19 deletions(-) create mode 100644 docs/screenshots/customer-master-translation-gate-desktop.png create mode 100644 docs/screenshots/customer-master-translation-gate-mobile.png create mode 100644 frontend/src/components/ScreenTranslationGate.stories.tsx create mode 100644 frontend/src/components/ScreenTranslationGate.test.tsx create mode 100644 frontend/src/components/ScreenTranslationGate.tsx diff --git a/docs/adr/0362-versioned-ui-translation-ledger.md b/docs/adr/0362-versioned-ui-translation-ledger.md index e1f2b9b1c..c586c9c27 100644 --- a/docs/adr/0362-versioned-ui-translation-ledger.md +++ b/docs/adr/0362-versioned-ui-translation-ledger.md @@ -119,6 +119,17 @@ Child insert/update/delete obtains a `FOR UPDATE` lock on the parent resource. P The existing `user_account.preferred_locale` constraint expands to the same eight language tags. API request validation and frontend consumption must be cut over to the same contract before #922 can close; the database/read-model foundation alone is not buyer-visible completion. +The first consumer slice is the Customer Master destination. It requests the +latest published `customer-master` resource after authentication and after each +locale change, admits it only when every key used by that destination is +present, and does not request Customer Master data until that copy is ready. +Loading and retry states remain a small product shell outside the translated +screen resource so a missing resource can tell the reader what to do next. +Changing locale aborts the prior request logically; a late response for an old +locale cannot replace the active screen. Other destinations remain on their +existing bundle until they receive their own complete resource and executable +cutover evidence. + ## DDD mapping - Subdomain: product composition / presentation read model. diff --git a/docs/screenshots/customer-master-translation-gate-desktop.png b/docs/screenshots/customer-master-translation-gate-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..54f985841ef6ccf0a0468d9c333eee4ce5a38391 GIT binary patch literal 20809 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 I{lOpq155Z-=l}o! literal 0 HcmV?d00001 diff --git a/docs/screenshots/customer-master-translation-gate-mobile.png b/docs/screenshots/customer-master-translation-gate-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..cba6f0d131a239c1b3f6183cf5055febbf9d317b GIT binary patch literal 13999 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 ({ })); beforeEach(() => { + clearCustomerMasterTranslations(); setLocale("en"); signinRedirect.mockReset(); signoutRedirect.mockReset(); @@ -39,6 +40,7 @@ it("normalizes valid knowledge cutoffs and rejects invalid input", () => { }); afterEach(() => { + clearCustomerMasterTranslations(); vi.unstubAllGlobals(); window.history.replaceState({}, "", "/"); window.sessionStorage.clear(); @@ -1905,6 +1907,19 @@ describe("App, authenticated", () => { }), ); } + if (url.includes("/api/translations/customer-master?") && method === "GET") { + const translations = Object.fromEntries( + CUSTOMER_MASTER_TRANSLATION_KEYS.map((key) => [key, key]), + ); + return Promise.resolve( + jsonResponse({ + screen_key: "customer-master", + resource_version: 1, + locale: new URL(url).searchParams.get("locale") ?? "en", + translations, + }), + ); + } if (url.endsWith("/api/customer-master") && method === "GET") { return Promise.resolve( jsonResponse({ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3fb6c796..612101deb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -87,6 +87,7 @@ import { type VocEvidence, type SimilarVocItem, fetchTenantConfig, + fetchTranslationScreen, } from "./api"; import { CitationChip } from "./components/CitationChip"; import { PublicClaimVerification } from "./components/PublicClaimVerification"; @@ -115,10 +116,13 @@ import { LOCALE_LABELS, SUPPORTED_LOCALES, setLocale, + setCustomerMasterTranslations, + clearCustomerMasterTranslations, t, tf, useLocale, } from "./i18n"; +import { ScreenTranslationGate } from "./components/ScreenTranslationGate"; import "./App.css"; const AdminPanel = lazy(() => import("./components/AdminPanel").then((module) => ({ default: module.AdminPanel }))); @@ -4757,6 +4761,10 @@ function CustomerMasterPanel({ }: { accessToken: string; }) { + const locale = useLocale(); + const [copyState, setCopyState] = useState<"loading" | "ready" | "retry">("loading"); + const [copyLocale, setCopyLocale] = useState(null); + const [copyAttempt, setCopyAttempt] = useState(0); const [master, setMaster] = useState(null); const [error, setError] = useState(null); const [expandedEntityId, setExpandedEntityId] = useState(null); @@ -4796,9 +4804,31 @@ function CustomerMasterPanel({ }, [accessToken]); useEffect(() => { + let active = true; + setCopyState("loading"); + setCopyLocale(null); + clearCustomerMasterTranslations(); + fetchTranslationScreen(accessToken, "customer-master", locale) + .then((screen) => { + if (!active || screen.screen_key !== "customer-master" || screen.locale !== locale) return; + setCustomerMasterTranslations(screen.translations); + setCopyLocale(locale); + setCopyState("ready"); + }) + .catch(() => { + if (active) setCopyState("retry"); + }); + return () => { + active = false; + clearCustomerMasterTranslations(); + }; + }, [accessToken, locale, copyAttempt]); + + useEffect(() => { + if (copyState !== "ready") return; setMaster(null); void loadMaster(); - }, [loadMaster]); + }, [copyState, loadMaster]); useEffect(() => { if (!selectedPostId) { @@ -4854,6 +4884,12 @@ function CustomerMasterPanel({ } } + if (copyState === "loading" || copyLocale !== locale) { + return ; + } + if (copyState === "retry") { + return setCopyAttempt((attempt) => attempt + 1)} />; + } return (

{t("Authorized customer scope")}

diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts index 9495dc241..3e559c24d 100644 --- a/frontend/src/api.test.ts +++ b/frontend/src/api.test.ts @@ -5,6 +5,7 @@ import { fetchOccupationRatingSources, fetchOccupationRatings, fetchOperationsDashboard, + fetchTranslationScreen, fetchRatingSourceOccupations, updateTenantConfig, } from "./api"; @@ -14,6 +15,19 @@ afterEach(() => { }); describe("backendFetch provider-error boundary", () => { + it("requests one authenticated screen and locale without a bundled corpus", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ screen_key: "customer-master", resource_version: 3, locale: "de", translations: { title: "Kundenstamm" } }), { + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await fetchTranslationScreen("access-token", "customer-master", "de"); + + expect(fetchMock.mock.calls[0][0]).toContain("/api/translations/customer-master?locale=de"); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer access-token"); + }); it("binds the selected Dashboard period as inclusive API dates", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ cases: [] }), { headers: { "Content-Type": "application/json" } }), diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5db021a05..ed0a2b869 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -51,6 +51,26 @@ export interface PostPage { visibility_options?: PostFilterOption[]; } +export interface TranslationScreenResponse { + screen_key: string; + resource_version: number; + locale: string; + translations: Record; +} + +/** Fetch the latest complete published copy for one authenticated screen. */ +export function fetchTranslationScreen( + accessToken: string, + screenKey: string, + locale: string, +): Promise { + const query = new URLSearchParams({ locale }); + return backendFetch( + `/api/translations/${encodeURIComponent(screenKey)}?${query.toString()}`, + accessToken, + ); +} + export interface OperationsDashboardFact { fact_type_code: string; fact_type_label: string; diff --git a/frontend/src/components/ScreenTranslationGate.stories.tsx b/frontend/src/components/ScreenTranslationGate.stories.tsx new file mode 100644 index 000000000..b531d5fb9 --- /dev/null +++ b/frontend/src/components/ScreenTranslationGate.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ScreenTranslationGate } from "./ScreenTranslationGate"; + +const meta = { + title: "Chrome/Screen translation gate", + component: ScreenTranslationGate, + args: { onRetry: () => undefined }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Loading: Story = { args: { state: "loading" } }; +export const Retry: Story = { args: { state: "retry" } }; +export const RetryMobile: Story = { + args: { state: "retry" }, + parameters: { viewport: { defaultViewport: "mobile1" } }, +}; diff --git a/frontend/src/components/ScreenTranslationGate.test.tsx b/frontend/src/components/ScreenTranslationGate.test.tsx new file mode 100644 index 000000000..cf659ab8b --- /dev/null +++ b/frontend/src/components/ScreenTranslationGate.test.tsx @@ -0,0 +1,17 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ScreenTranslationGate } from "./ScreenTranslationGate"; + +describe("ScreenTranslationGate", () => { + it("announces loading without exposing untranslated screen content", () => { + render(); + expect(screen.getByRole("status")).toHaveTextContent("Loading this screen"); + }); + + it("offers one retry action when published copy is unavailable", () => { + const onRetry = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(onRetry).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/components/ScreenTranslationGate.tsx b/frontend/src/components/ScreenTranslationGate.tsx new file mode 100644 index 000000000..0ac6ab4e8 --- /dev/null +++ b/frontend/src/components/ScreenTranslationGate.tsx @@ -0,0 +1,21 @@ +import { StatusNotice } from "./StatusNotice"; + +export type ScreenTranslationGateProps = { + state: "loading" | "retry"; + onRetry?: () => void; +}; + +/** Keep an untranslated screen hidden while offering one concrete recovery action. */ +export function ScreenTranslationGate({ state, onRetry }: ScreenTranslationGateProps) { + if (state === "loading") { + return

Loading this screen in your selected language...

; + } + return ( + + ); +} diff --git a/frontend/src/evidenceStatusI18n.ts b/frontend/src/evidenceStatusI18n.ts index 7c9e86bf1..8ea6522da 100644 --- a/frontend/src/evidenceStatusI18n.ts +++ b/frontend/src/evidenceStatusI18n.ts @@ -51,11 +51,14 @@ const EVIDENCE_STATUS_COPY = { "A forecast. Treat as unconfirmed until later evidence arrives.": "Đây là một dự đoán. Hãy coi là chưa xác nhận cho đến khi có bằng chứng sau này.", }, -} as const satisfies Record>; +} as const; export type EvidenceStatusCopyKey = keyof (typeof EVIDENCE_STATUS_COPY)["en"]; /** Return reader-facing evidence/inference/prediction copy in the active product locale. */ export function evidenceStatusText(key: EvidenceStatusCopyKey): string { - return EVIDENCE_STATUS_COPY[getLocale()][key]; + const copy = EVIDENCE_STATUS_COPY as Partial< + Record> + >; + return copy[getLocale()]?.[key] ?? EVIDENCE_STATUS_COPY.en[key]; } diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index b4b8d2fb6..1981fc0f9 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -6,13 +6,17 @@ import { import { LOCALE_LABELS, SUPPORTED_LOCALES, + CUSTOMER_MASTER_TRANSLATION_KEYS, + clearCustomerMasterTranslations, getLocale, + setCustomerMasterTranslations, setLocale, t, tf, } from "./i18n"; afterEach(() => { + clearCustomerMasterTranslations(); setLocale("en"); }); @@ -101,9 +105,20 @@ describe("i18n", () => { "Compare these cutoff-grounded citations with live evidence next.", ] as const; - it("supports the five product locales", () => { - expect(SUPPORTED_LOCALES).toEqual(["en", "ko", "zh", "ja", "vi"]); - expect(Object.keys(LOCALE_LABELS)).toHaveLength(5); + it("supports the governed eight product locales", () => { + expect(SUPPORTED_LOCALES).toEqual(["ko", "en", "ja", "zh", "vi", "es", "de", "fr"]); + expect(Object.keys(LOCALE_LABELS)).toHaveLength(8); + }); + + it("admits only a complete Customer Master screen projection", () => { + const complete = Object.fromEntries( + CUSTOMER_MASTER_TRANSLATION_KEYS.map((key) => [key, `published:${key}`]), + ); + setCustomerMasterTranslations(complete); + expect(t("Customer master")).toBe("published:Customer master"); + expect(() => setCustomerMasterTranslations({ "Customer master": "Kundenstamm" })).toThrow( + "Incomplete Customer Master translation", + ); }); it.each([ diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index bfe170ef8..b9bf54bcc 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -1,6 +1,6 @@ import { useSyncExternalStore } from "react"; -export const SUPPORTED_LOCALES = ["en", "ko", "zh", "ja", "vi"] as const; +export const SUPPORTED_LOCALES = ["ko", "en", "ja", "zh", "vi", "es", "de", "fr"] as const; export type Locale = (typeof SUPPORTED_LOCALES)[number]; export const LOCALE_LABELS: Record = { @@ -9,8 +9,44 @@ export const LOCALE_LABELS: Record = { zh: "中文", ja: "日本語", vi: "Tiếng Việt", + es: "Español", + de: "Deutsch", + fr: "Français", }; +export const CUSTOMER_MASTER_TRANSLATION_KEYS = [ + "A counterparty can hold more than one role over time -- a customer in one post can be a competitor, supplier, or partner in another. Every role observed for a name is listed, not just the most frequent.", + "Affiliates of {name}", "Author context", "Authorization context", "Authorized customer scope", + "Customer entities available to this account.", "Customer master could not be loaded.", "Customer master", + "Hint only", "Keymen", "Loading customer master...", "Loading related posts...", "Multiple roles observed", + "No customer entities are connected to this account.", "No linked posts yet.", "No post body.", + "Observed customer evidence", "Open record", "Open related post: {label}", "Our-side Keymen hints", + "Post body preview", "Related posts", "Relationship network", "Resolve", "Resolving...", + "Showing the first {shown} of {total} observed customer identifiers, ranked by post count.", + "Showing the first {shown} of {total} observed source authors, ranked by post count.", + "Source identifiers are hints only; ontology and semantic evidence must resolve them before binding a customer.", + "This hint could not be resolved to a corroborated organization name.", "Unresolved source identifier", "posts", +] as const; + +let activeScreenTranslations: Readonly> | null = null; + +/** Admit a complete Customer Master projection and notify subscribed readers. */ +export function setCustomerMasterTranslations(translations: Record): void { + for (const key of CUSTOMER_MASTER_TRANSLATION_KEYS) { + if (typeof translations[key] !== "string" || translations[key].trim() === "") { + throw new Error(`Incomplete Customer Master translation: ${key}`); + } + } + activeScreenTranslations = Object.freeze({ ...translations }); + listeners.forEach((listener) => listener()); +} + +/** Remove the active Customer Master projection when its destination unmounts. */ +export function clearCustomerMasterTranslations(): void { + activeScreenTranslations = null; + listeners.forEach((listener) => listener()); +} + export function isSupportedLocale(value: unknown): value is Locale { return typeof value === "string" && (SUPPORTED_LOCALES as readonly string[]).includes(value); } @@ -2358,7 +2394,7 @@ const TRANSLATIONS: Partial>> = { }; /** Customer-facing labels keep implementation vocabulary out of the reader UI. */ -const CUSTOMER_COPY: Record> = { +const CUSTOMER_COPY: Partial>> = { en: { "View related information": "View related information", "Related information": "Related information", @@ -2455,7 +2491,7 @@ export function useLocale(): Locale { } export function t(key: string): string { - return CUSTOMER_COPY[currentLocale][key] ?? TRANSLATIONS[currentLocale]?.[key] ?? key; + return activeScreenTranslations?.[key] ?? CUSTOMER_COPY[currentLocale]?.[key] ?? TRANSLATIONS[currentLocale]?.[key] ?? key; } export function tf(key: string, values: Record): string { diff --git a/frontend/src/occupationalConstructI18n.ts b/frontend/src/occupationalConstructI18n.ts index 1b191130f..da5f291ec 100644 --- a/frontend/src/occupationalConstructI18n.ts +++ b/frontend/src/occupationalConstructI18n.ts @@ -200,13 +200,14 @@ const COPY = { "Finding work evidence...": "Đang tìm bằng chứng công việc...", "Show more matching records": "Hiển thị thêm bản ghi phù hợp", }, -} as const satisfies Record>; +} as const; export type OccupationalConstructCopyKey = keyof (typeof COPY)["en"]; /** Return occupational-construct evidence copy in the active product locale. */ export function occupationalConstructText(key: OccupationalConstructCopyKey): string { - return COPY[getLocale()][key]; + const copy = COPY as Partial>>; + return copy[getLocale()]?.[key] ?? COPY.en[key]; } /** Substitute named placeholders in occupational-construct copy. */ diff --git a/frontend/src/ontologyExplorerI18n.ts b/frontend/src/ontologyExplorerI18n.ts index 961dbb338..7552839a1 100644 --- a/frontend/src/ontologyExplorerI18n.ts +++ b/frontend/src/ontologyExplorerI18n.ts @@ -46,11 +46,14 @@ const ONTOLOGY_EXPLORER_COPY = { "No direct evidence post is attached. Review the provenance reference above.": "Không có bài đăng bằng chứng trực tiếp được đính kèm. Hãy xem tham chiếu nguồn gốc ở trên.", }, -} as const satisfies Record>; +} as const; export type OntologyExplorerCopyKey = keyof (typeof ONTOLOGY_EXPLORER_COPY)["en"]; /** Return ontology-explorer stabilization copy in the active product locale. */ export function ontologyExplorerText(key: OntologyExplorerCopyKey): string { - return ONTOLOGY_EXPLORER_COPY[getLocale()][key]; + const copy = ONTOLOGY_EXPLORER_COPY as Partial< + Record> + >; + return copy[getLocale()]?.[key] ?? ONTOLOGY_EXPLORER_COPY.en[key]; } diff --git a/frontend/src/projectHistory.ts b/frontend/src/projectHistory.ts index 4dceb6fbd..174722fb8 100644 --- a/frontend/src/projectHistory.ts +++ b/frontend/src/projectHistory.ts @@ -189,7 +189,7 @@ const EN: Record = { sourceRecorded: "Source record", }; -const MESSAGES: Record> = { +const MESSAGES: Partial>> = { en: EN, ko: { heading: "프로젝트 이벤트 타임라인", @@ -378,7 +378,7 @@ export function projectHistoryText( key: ProjectHistoryMessageKey, params: MessageParams = {}, ): string { - let value = MESSAGES[locale][key]; + let value = MESSAGES[locale]?.[key] ?? EN[key]; for (const [name, replacement] of Object.entries(params)) { value = value.replaceAll(`{${name}}`, String(replacement)); } diff --git a/frontend/src/workerFunctionPsychologyI18n.ts b/frontend/src/workerFunctionPsychologyI18n.ts index 48c41180c..7e5711208 100644 --- a/frontend/src/workerFunctionPsychologyI18n.ts +++ b/frontend/src/workerFunctionPsychologyI18n.ts @@ -56,11 +56,14 @@ const WORKER_FUNCTION_PSYCHOLOGY_COPY = { "Select a worker function to review its I/O psychology demand profile.": "Chọn một chức năng công việc để xem hồ sơ nhu cầu Tâm lý I/O.", }, -} as const satisfies Record>; +} as const; export type WorkerFunctionPsychologyCopyKey = keyof (typeof WORKER_FUNCTION_PSYCHOLOGY_COPY)["en"]; /** Return worker-function I/O psychology copy for the active product locale. */ export function workerFunctionPsychologyText(key: WorkerFunctionPsychologyCopyKey): string { - return WORKER_FUNCTION_PSYCHOLOGY_COPY[getLocale()][key]; -} \ No newline at end of file + const copy = WORKER_FUNCTION_PSYCHOLOGY_COPY as Partial< + Record> + >; + return copy[getLocale()]?.[key] ?? WORKER_FUNCTION_PSYCHOLOGY_COPY.en[key]; +} From fcceb7c4d638a4c4e019bd5bf8776aa5d1691658 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 5 Sep 2026 09:38:51 +0900 Subject: [PATCH 02/21] docs(gaps): record Customer Master translation cutover evidence Signed-off-by: Codex --- docs/product-technical-gap-baseline.md | 32 +++++++++++++++++++++----- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 59b0484e0..a7a3593ec 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -10,6 +10,14 @@ > readiness. The authenticated `GET /api/translations/{screen_key}` API is > implemented on the candidate branch. That is candidate implementation > evidence, not protected-main, deployed, or release evidence. +> A stacked Customer Master consumer candidate now exists at implementation +> head `3d6ae1dd9fcd6b095e5fceb70ed1b476ecd9bd58` on top of PR #929's +> exact head `f07a755972e38b4b2a961ab11acd9d3abb229967`. It admits all eight +> locale tags, fetches the authenticated `customer-master` resource before +> customer data, rejects incomplete screen projections, ignores late responses +> from a previous locale, and shows an actionable retry state instead of +> rendering bundled Customer Master copy. This stacked branch is not +> protected-main, hosted-check, authenticated PostgreSQL, or deployed evidence. > > Two adjacent candidates remain outside protected `main`: PR #911 at > `5d40eed35a0b6e0d182397f8d02b29c38e9bdd17` replaces the synchronous @@ -31,8 +39,12 @@ > The buyer-visible gap in #922 remains open. Protected `main` still ships the > production frontend translation source in `frontend/src/i18n.ts` with only > `en/ko/zh/ja/vi`; `es/de/fr` are not first-class frontend locales. No material -> SPA screen has yet been cut over to a published eight-locale ledger resource, -> and there is no exact-head desktop/mobile evidence covering normal, loading, +> SPA screen has yet been released on a published eight-locale ledger resource. +> The stacked Customer Master candidate covers API admission plus loading and +> retry rendering, including inspected 1440×900 and 390×844 Storybook captures. +> It does not contain reviewed eight-locale product copy or authenticated +> PostgreSQL normal/empty/permission evidence. There is no release evidence for +> normal, loading, > empty, error, permission, responsive, keyboard/focus/screen-reader, CJK text > expansion, or font fallback states. > @@ -100,15 +112,19 @@ - None of the above is release evidence until the unchanged exact PR head has terminal required/security checks and qualifying independent approval, then reaches protected `main` normally. +- The stacked Customer Master consumer has no new ADR number, migration, API + route, schema object, or release number. It extends ADR 0362 and consumes the + route owned by #929, avoiding collisions with ADRs 0364–0366 and the + serialized report-release stack. ## Next buyer cut 1. Use reviewed product copy to create and publish one complete screen resource for all eight locales. Do not invent copy to satisfy coverage. -2. Cut one material SPA screen off bundled `TRANSLATIONS` and onto the versioned - API. Customer Master is the natural first slice because #922 gates its open - material-UI work, but the screen identity must follow the actual product - composition contract rather than creating a second domain owner. +2. Finish the stacked Customer Master cutover by publishing reviewed product + copy for its declared keys in all eight locales and proving the authenticated + PostgreSQL/API normal path. The consumer and fail-closed loading/retry gate + exist only as branch evidence. 3. Prove normal/loading/empty/error/permission/responsive states plus keyboard/focus/screen-reader behavior, CJK rendering, text expansion, and font fallback on the same exact head with fresh desktop and mobile evidence. @@ -164,4 +180,8 @@ `tests/test_translation_api_driver_boundary.py`, `tests/test_translation_cache_timeout.py`, and `tests/test_translation_documentation_alignment.py`. +- Stacked Customer Master consumer: `frontend/src/api.ts`, + `frontend/src/i18n.ts`, `frontend/src/App.tsx`, and + `frontend/src/components/ScreenTranslationGate.tsx`; visual evidence is + `docs/screenshots/customer-master-translation-gate-{desktop,mobile}.png`. - Historical delivery/gap overlays: `docs/product-technical-gap-baseline-history-2026-09-04.md`. From 98de7c9fa0c496c673e0c1dcf91f59d65e34353f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:51:28 +0900 Subject: [PATCH 03/21] test(i18n): require auth-bound Customer Master copy admission --- ...r_master_translation_auth_gate_contract.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/test_customer_master_translation_auth_gate_contract.py diff --git a/tests/test_customer_master_translation_auth_gate_contract.py b/tests/test_customer_master_translation_auth_gate_contract.py new file mode 100644 index 000000000..3080d28a9 --- /dev/null +++ b/tests/test_customer_master_translation_auth_gate_contract.py @@ -0,0 +1,46 @@ +"""Executable guard for Customer Master's auth-bound translation admission.""" + +from pathlib import Path + + +APP_SOURCE = Path("frontend/src/App.tsx") + + +def _customer_master_panel_source() -> str: + """Return only the Customer Master component so unrelated UI cannot satisfy the contract.""" + source = APP_SOURCE.read_text(encoding="utf-8") + start = source.index("function CustomerMasterPanel(") + end = source.index("\nexport function AskAgentPanel(", start) + return source[start:end] + + +def test_customer_master_translation_ready_is_bound_to_current_access_token() -> None: + """Changing auth identity must invalidate translated-copy readiness before data admission.""" + panel = _customer_master_panel_source() + + assert "const [copyAccessToken, setCopyAccessToken] = useState(null);" in panel + assert "setCopyAccessToken(null);" in panel + assert "setCopyAccessToken(accessToken);" in panel + assert "copyAccessToken !== accessToken" in panel + + +def test_customer_master_data_effect_requires_auth_bound_copy() -> None: + """The Customer Master request effect must fail closed on stale-token copy.""" + panel = _customer_master_panel_source() + effect_start = panel.index('if (copyState !== "ready")') + effect_end = panel.index("\n useEffect(() => {", effect_start + 1) + data_effect = panel[effect_start:effect_end] + + assert "copyLocale !== locale" in data_effect + assert "copyAccessToken !== accessToken" in data_effect + assert "void loadMaster();" in data_effect + + +def test_customer_master_render_gate_rejects_stale_token_copy() -> None: + """Stale-token translations must not unlock the Customer Master surface.""" + panel = _customer_master_panel_source() + render_gate_start = panel.index('if (copyState === "loading"') + render_gate_end = panel.index("\n return (", render_gate_start) + render_gate = panel[render_gate_start:render_gate_end] + + assert "copyAccessToken !== accessToken" in render_gate From 90537bce2628df65c1f5c02cfe2fb48898f2b2d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 10:34:46 +0900 Subject: [PATCH 04/21] fix(i18n): bind Customer Master copy admission to auth token --- frontend/src/App.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 612101deb..b0ea0158c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4764,6 +4764,7 @@ function CustomerMasterPanel({ const locale = useLocale(); const [copyState, setCopyState] = useState<"loading" | "ready" | "retry">("loading"); const [copyLocale, setCopyLocale] = useState(null); + const [copyAccessToken, setCopyAccessToken] = useState(null); const [copyAttempt, setCopyAttempt] = useState(0); const [master, setMaster] = useState(null); const [error, setError] = useState(null); @@ -4807,12 +4808,14 @@ function CustomerMasterPanel({ let active = true; setCopyState("loading"); setCopyLocale(null); + setCopyAccessToken(null); clearCustomerMasterTranslations(); fetchTranslationScreen(accessToken, "customer-master", locale) .then((screen) => { if (!active || screen.screen_key !== "customer-master" || screen.locale !== locale) return; setCustomerMasterTranslations(screen.translations); setCopyLocale(locale); + setCopyAccessToken(accessToken); setCopyState("ready"); }) .catch(() => { @@ -4825,10 +4828,10 @@ function CustomerMasterPanel({ }, [accessToken, locale, copyAttempt]); useEffect(() => { - if (copyState !== "ready") return; + if (copyState !== "ready" || copyLocale !== locale || copyAccessToken !== accessToken) return; setMaster(null); void loadMaster(); - }, [copyState, loadMaster]); + }, [accessToken, copyAccessToken, copyLocale, copyState, loadMaster, locale]); useEffect(() => { if (!selectedPostId) { @@ -4884,12 +4887,12 @@ function CustomerMasterPanel({ } } - if (copyState === "loading" || copyLocale !== locale) { - return ; - } if (copyState === "retry") { return setCopyAttempt((attempt) => attempt + 1)} />; } + if (copyState === "loading" || copyLocale !== locale || copyAccessToken !== accessToken) { + return ; + } return (

{t("Authorized customer scope")}

From 0c6c523476c0b51e67b49c5fb774a255842704bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 10:35:55 +0900 Subject: [PATCH 05/21] test(i18n): parse compound Customer Master admission guard --- tests/test_customer_master_translation_auth_gate_contract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_customer_master_translation_auth_gate_contract.py b/tests/test_customer_master_translation_auth_gate_contract.py index 3080d28a9..0dbf4fcf7 100644 --- a/tests/test_customer_master_translation_auth_gate_contract.py +++ b/tests/test_customer_master_translation_auth_gate_contract.py @@ -27,7 +27,7 @@ def test_customer_master_translation_ready_is_bound_to_current_access_token() -> def test_customer_master_data_effect_requires_auth_bound_copy() -> None: """The Customer Master request effect must fail closed on stale-token copy.""" panel = _customer_master_panel_source() - effect_start = panel.index('if (copyState !== "ready")') + effect_start = panel.index('if (copyState !== "ready"') effect_end = panel.index("\n useEffect(() => {", effect_start + 1) data_effect = panel[effect_start:effect_end] From fd3f0326f539f23dfae75fc3511722ead4455d36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 10:45:01 +0900 Subject: [PATCH 06/21] test(i18n): reject false publication diagnosis --- frontend/src/components/ScreenTranslationGate.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/components/ScreenTranslationGate.test.tsx b/frontend/src/components/ScreenTranslationGate.test.tsx index cf659ab8b..587ca93c7 100644 --- a/frontend/src/components/ScreenTranslationGate.test.tsx +++ b/frontend/src/components/ScreenTranslationGate.test.tsx @@ -8,6 +8,14 @@ describe("ScreenTranslationGate", () => { expect(screen.getByRole("status")).toHaveTextContent("Loading this screen"); }); + it("keeps an unclassified projection failure cause-neutral", () => { + render( undefined} />); + expect(screen.getByText("We could not load this screen in your selected language.")).toBeInTheDocument(); + expect(screen.getByText("Retry the translation request. If it still fails, ask an administrator to check access and publication status.")).toBeInTheDocument(); + expect(screen.queryByText(/not available in your selected language yet/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/ask an administrator to publish this screen/i)).not.toBeInTheDocument(); + }); + it("offers one retry action when published copy is unavailable", () => { const onRetry = vi.fn(); render(); From cb093960d43e95cdfb1d9ed491e920e2106305db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 10:45:13 +0900 Subject: [PATCH 07/21] fix(i18n): keep translation failures cause-neutral --- frontend/src/components/ScreenTranslationGate.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ScreenTranslationGate.tsx b/frontend/src/components/ScreenTranslationGate.tsx index 0ac6ab4e8..c484e255b 100644 --- a/frontend/src/components/ScreenTranslationGate.tsx +++ b/frontend/src/components/ScreenTranslationGate.tsx @@ -13,8 +13,8 @@ export function ScreenTranslationGate({ state, onRetry }: ScreenTranslationGateP return ( ); From 28fbdee9eb67c50ab076e26d36cc28ba1753cbff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 10:48:24 +0900 Subject: [PATCH 08/21] docs(gaps): align Customer Master translation evidence --- docs/product-technical-gap-baseline.md | 52 ++++++++++++++++++-------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index a7a3593ec..932d43b40 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -11,13 +11,19 @@ > implemented on the candidate branch. That is candidate implementation > evidence, not protected-main, deployed, or release evidence. > A stacked Customer Master consumer candidate now exists at implementation -> head `3d6ae1dd9fcd6b095e5fceb70ed1b476ecd9bd58` on top of PR #929's +> head `cb093960d43e95cdfb1d9ed491e920e2106305db` on top of PR #929's > exact head `f07a755972e38b4b2a961ab11acd9d3abb229967`. It admits all eight > locale tags, fetches the authenticated `customer-master` resource before > customer data, rejects incomplete screen projections, ignores late responses > from a previous locale, and shows an actionable retry state instead of -> rendering bundled Customer Master copy. This stacked branch is not -> protected-main, hosted-check, authenticated PostgreSQL, or deployed evidence. +> rendering bundled Customer Master copy. Review `5119233938` found that the +> retry shell incorrectly diagnosed every transport/auth/permission/not-found/ +> service exception as an unpublished translation. RED +> `fd3f0326f539f23dfae75fc3511722ead4455d36` and causal repair +> `cb093960d43e95cdfb1d9ed491e920e2106305db` keep that unclassified failure +> cause-neutral while retaining one concrete retry action. This stacked branch +> is not protected-main, hosted-product-GREEN, authenticated PostgreSQL, or +> deployed evidence. > > Two adjacent candidates remain outside protected `main`: PR #911 at > `5d40eed35a0b6e0d182397f8d02b29c38e9bdd17` replaces the synchronous @@ -25,11 +31,13 @@ > PR #909 at `e82aed38c0997588529e21fe0e1bf4159f3c198c` keeps authorized Customer > Master records visible when imported hierarchy edges are malformed and adds > synthetic desktop/mobile Storybook evidence. #911 is Ready for exact-head -> validation after moving its colliding TLS ADR to Proposed ADR 0366. #909 is -> Draft because #922's eight-locale published-resource cutover and the required -> current-head material-UI/runtime evidence are still absent. Neither has -> qualifying independent current-head approval or terminal hosted checks, and -> neither is protected-main or deployed evidence. +> validation after moving its colliding TLS ADR to Proposed ADR 0366. Its +> repository-local Tests, PROV-O, and Ontology Pages runs are successful, while +> central Security/CodeQL/SAST remain queued. #909 is Draft because #922's +> eight-locale published-resource cutover and the required current-head +> material-UI/runtime evidence are still absent. Neither has qualifying +> independent current-head approval, and neither is protected-main or deployed +> evidence. > > Historical baseline overlays through the preceding snapshot are preserved as > dated evidence at @@ -41,12 +49,13 @@ > `en/ko/zh/ja/vi`; `es/de/fr` are not first-class frontend locales. No material > SPA screen has yet been released on a published eight-locale ledger resource. > The stacked Customer Master candidate covers API admission plus loading and -> retry rendering, including inspected 1440×900 and 390×844 Storybook captures. -> It does not contain reviewed eight-locale product copy or authenticated -> PostgreSQL normal/empty/permission evidence. There is no release evidence for -> normal, loading, -> empty, error, permission, responsive, keyboard/focus/screen-reader, CJK text -> expansion, or font fallback states. +> retry rendering. Its earlier 1440×900 and 390×844 Storybook captures predate +> the current auth-bound/cause-neutral repair head and are predecessor evidence, +> not current-head browser acceptance. It does not contain reviewed eight-locale +> product copy or authenticated PostgreSQL normal/empty/permission evidence. +> There is no release evidence for normal, loading, empty, error, permission, +> responsive, keyboard/focus/screen-reader, CJK text expansion, or font fallback +> states. > > Do not synthesize translations and do not count English fallback as translated > coverage. Ontology labels and concept names remain outside this presentation @@ -89,6 +98,12 @@ incomplete requested-locale copy maps to 409. Unsupported locale, malformed screen identity, and an unrepresentable resource version each map to a distinct 422 response that tells the caller which request value to correct. +- The Customer Master consumer does not reinterpret those backend failure + categories when the fetch promise is caught generically. Until a typed + frontend failure contract is introduced, its retry shell says only that the + selected-language screen could not be loaded, retries the request first, and + asks an administrator to check access and publication status only if the + failure persists. It does not assert that publication is missing. - Focused HTTP and asyncpg-boundary tests cover the route without adding a direct `psycopg2` caller. The documentation-alignment contract prevents this baseline from regressing to the obsolete claim that the API does not exist. @@ -128,6 +143,8 @@ 3. Prove normal/loading/empty/error/permission/responsive states plus keyboard/focus/screen-reader behavior, CJK rendering, text expansion, and font fallback on the same exact head with fresh desktop and mobile evidence. + Include the small loading/retry shell in locale and text-expansion review; + its English source copy is not evidence of eight-locale behavior. 4. Converge PRD/TRD/ARCHITECTURE/UX/OPERABILITY/TEST_STRATEGY/CHANGELOG and this baseline with the actual cutover. Keep ontology labels separate from product copy and consume only released owner contracts where another CWL product is @@ -181,7 +198,10 @@ `tests/test_translation_cache_timeout.py`, and `tests/test_translation_documentation_alignment.py`. - Stacked Customer Master consumer: `frontend/src/api.ts`, - `frontend/src/i18n.ts`, `frontend/src/App.tsx`, and - `frontend/src/components/ScreenTranslationGate.tsx`; visual evidence is + `frontend/src/i18n.ts`, `frontend/src/App.tsx`, + `frontend/src/components/ScreenTranslationGate.tsx`, + `frontend/src/components/ScreenTranslationGate.test.tsx`, and + `tests/test_customer_master_translation_auth_gate_contract.py`; predecessor + visual evidence is `docs/screenshots/customer-master-translation-gate-{desktop,mobile}.png`. - Historical delivery/gap overlays: `docs/product-technical-gap-baseline-history-2026-09-04.md`. From 170c230200569f3288347f43c295749b772bab86 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 5 Sep 2026 10:49:49 +0900 Subject: [PATCH 09/21] docs(i18n): refresh exact-head visual evidence --- docs/product-technical-gap-baseline.md | 22 +++++++++++------- ...stomer-master-translation-gate-desktop.png | Bin 20809 -> 22195 bytes ...ustomer-master-translation-gate-mobile.png | Bin 13999 -> 15057 bytes 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 932d43b40..6f8412437 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -10,12 +10,13 @@ > readiness. The authenticated `GET /api/translations/{screen_key}` API is > implemented on the candidate branch. That is candidate implementation > evidence, not protected-main, deployed, or release evidence. -> A stacked Customer Master consumer candidate now exists at implementation -> head `cb093960d43e95cdfb1d9ed491e920e2106305db` on top of PR #929's +> A stacked Customer Master consumer candidate now exists at exact head +> `cb093960d43e95cdfb1d9ed491e920e2106305db` on top of PR #929's > exact head `f07a755972e38b4b2a961ab11acd9d3abb229967`. It admits all eight > locale tags, fetches the authenticated `customer-master` resource before > customer data, rejects incomplete screen projections, ignores late responses -> from a previous locale, and shows an actionable retry state instead of +> from a previous locale or authorization identity, and shows an actionable +> retry state instead of > rendering bundled Customer Master copy. Review `5119233938` found that the > retry shell incorrectly diagnosed every transport/auth/permission/not-found/ > service exception as an unpublished translation. RED @@ -24,6 +25,10 @@ > cause-neutral while retaining one concrete retry action. This stacked branch > is not protected-main, hosted-product-GREEN, authenticated PostgreSQL, or > deployed evidence. +> Current-head local evidence is 59 frontend test files / 538 tests, lint, +> production build, Storybook build, nine focused Python contract tests, and +> freshly inspected 1440 x 900 plus 390 x 844 retry-state captures. These local +> results do not satisfy the protected delivery boundary. > > Two adjacent candidates remain outside protected `main`: PR #911 at > `5d40eed35a0b6e0d182397f8d02b29c38e9bdd17` replaces the synchronous @@ -49,9 +54,10 @@ > `en/ko/zh/ja/vi`; `es/de/fr` are not first-class frontend locales. No material > SPA screen has yet been released on a published eight-locale ledger resource. > The stacked Customer Master candidate covers API admission plus loading and -> retry rendering. Its earlier 1440×900 and 390×844 Storybook captures predate -> the current auth-bound/cause-neutral repair head and are predecessor evidence, -> not current-head browser acceptance. It does not contain reviewed eight-locale +> retry rendering. The 1440×900 and 390×844 Storybook captures were regenerated +> and inspected after the current auth-bound/cause-neutral repair. They prove +> only the synthetic retry shell, not authenticated browser acceptance. The +> candidate does not contain reviewed eight-locale > product copy or authenticated PostgreSQL normal/empty/permission evidence. > There is no release evidence for normal, loading, empty, error, permission, > responsive, keyboard/focus/screen-reader, CJK text expansion, or font fallback @@ -201,7 +207,7 @@ `frontend/src/i18n.ts`, `frontend/src/App.tsx`, `frontend/src/components/ScreenTranslationGate.tsx`, `frontend/src/components/ScreenTranslationGate.test.tsx`, and - `tests/test_customer_master_translation_auth_gate_contract.py`; predecessor - visual evidence is + `tests/test_customer_master_translation_auth_gate_contract.py`; current-head + synthetic visual evidence is `docs/screenshots/customer-master-translation-gate-{desktop,mobile}.png`. - Historical delivery/gap overlays: `docs/product-technical-gap-baseline-history-2026-09-04.md`. diff --git a/docs/screenshots/customer-master-translation-gate-desktop.png b/docs/screenshots/customer-master-translation-gate-desktop.png index 54f985841ef6ccf0a0468d9c333eee4ce5a38391..74a6478362bf2ea1b67c0de61958178cccaf7eba 100644 GIT binary patch literal 22195 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 I{lOpq155Z-=l}o! diff --git a/docs/screenshots/customer-master-translation-gate-mobile.png b/docs/screenshots/customer-master-translation-gate-mobile.png index cba6f0d131a239c1b3f6183cf5055febbf9d317b..2b0a4fdb745431f84896d4a7998df1da664ff07f 100644 GIT binary patch literal 15057 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 G-~Bh{shVa0 literal 13999 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 Date: Sat, 5 Sep 2026 11:49:23 +0900 Subject: [PATCH 10/21] test(i18n): require localized translation bootstrap shell --- .../components/ScreenTranslationGate.test.tsx | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ScreenTranslationGate.test.tsx b/frontend/src/components/ScreenTranslationGate.test.tsx index 587ca93c7..97162098f 100644 --- a/frontend/src/components/ScreenTranslationGate.test.tsx +++ b/frontend/src/components/ScreenTranslationGate.test.tsx @@ -1,7 +1,12 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { setLocale } from "../i18n"; import { ScreenTranslationGate } from "./ScreenTranslationGate"; +afterEach(() => { + setLocale("en"); +}); + describe("ScreenTranslationGate", () => { it("announces loading without exposing untranslated screen content", () => { render(); @@ -16,10 +21,30 @@ describe("ScreenTranslationGate", () => { expect(screen.queryByText(/ask an administrator to publish this screen/i)).not.toBeInTheDocument(); }); - it("offers one retry action when published copy is unavailable", () => { + it("offers one retry action when the selected-language screen cannot be loaded", () => { const onRetry = vi.fn(); render(); fireEvent.click(screen.getByRole("button", { name: "Retry" })); expect(onRetry).toHaveBeenCalledOnce(); }); + + it.each([ + ["ko", "선택한 언어로 이 화면을 불러오는 중입니다...", "선택한 언어로 이 화면을 불러오지 못했습니다.", "다시 시도"], + ["en", "Loading this screen in your selected language...", "We could not load this screen in your selected language.", "Retry"], + ["ja", "選択した言語でこの画面を読み込んでいます...", "選択した言語でこの画面を読み込めませんでした。", "再試行"], + ["zh", "正在以所选语言加载此页面...", "无法以所选语言加载此页面。", "重试"], + ["vi", "Đang tải màn hình này bằng ngôn ngữ đã chọn...", "Không thể tải màn hình này bằng ngôn ngữ đã chọn.", "Thử lại"], + ["es", "Cargando esta pantalla en el idioma seleccionado...", "No se pudo cargar esta pantalla en el idioma seleccionado.", "Reintentar"], + ["de", "Dieser Bildschirm wird in der ausgewählten Sprache geladen...", "Dieser Bildschirm konnte in der ausgewählten Sprache nicht geladen werden.", "Erneut versuchen"], + ["fr", "Chargement de cet écran dans la langue sélectionnée…", "Impossible de charger cet écran dans la langue sélectionnée.", "Réessayer"], + ] as const)("keeps the bootstrap loading and retry shell in %s", (locale, loading, failure, retry) => { + setLocale(locale); + const { rerender } = render(); + expect(screen.getByRole("status")).toHaveTextContent(loading); + + rerender( undefined} />); + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent(failure); + expect(screen.getByRole("button", { name: retry })).toBeInTheDocument(); + }); }); From 422064e7da5804c0d985c197c9cd52687acc57fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:49:40 +0900 Subject: [PATCH 11/21] refactor(ui): allow localized bootstrap status semantics --- frontend/src/components/StatusNotice.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/StatusNotice.tsx b/frontend/src/components/StatusNotice.tsx index fa0d99b79..b8ace8d1e 100644 --- a/frontend/src/components/StatusNotice.tsx +++ b/frontend/src/components/StatusNotice.tsx @@ -26,6 +26,8 @@ export type StatusNoticeProps = { message: string; nextAction?: string; retryLabel?: string; + kindLabel?: string; + kindDescription?: string; onRetry?: () => void; }; @@ -34,7 +36,10 @@ export type StatusNoticeProps = { * * Color is never the only channel: each kind keeps a distinct glyph and * visible label. Callers pass already-localized message text and must not - * interpolate provider payloads (ADR 0123). + * interpolate provider payloads (ADR 0123). Bootstrap surfaces that must render + * before the normal translation catalog is available may also pass an + * already-localized kind label/description; ordinary callers keep the shared + * `t()` defaults. * * Success and unavailable are a named region, not `role="status"`, so they * do not collide with App live-region uniqueness. Retry is `role="alert"`. @@ -44,10 +49,12 @@ export function StatusNotice({ message, nextAction, retryLabel, + kindLabel, + kindDescription, onRetry, }: StatusNoticeProps) { - const label = t(KIND_LABEL_KEY[kind]); - const description = t(KIND_DESCRIPTION_KEY[kind]); + const label = kindLabel ?? t(KIND_LABEL_KEY[kind]); + const description = kindDescription ?? t(KIND_DESCRIPTION_KEY[kind]); const showRetry = kind === "retry" && typeof onRetry === "function"; const isRetry = kind === "retry"; return ( From 078f47d51aa120c1f901b15cd3d8e63567581a47 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:49:58 +0900 Subject: [PATCH 12/21] fix(i18n): localize translation bootstrap gate across eight locales --- .../src/components/ScreenTranslationGate.tsx | 90 ++++++++++++++++++- 1 file changed, 87 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/ScreenTranslationGate.tsx b/frontend/src/components/ScreenTranslationGate.tsx index c484e255b..cb21a97b0 100644 --- a/frontend/src/components/ScreenTranslationGate.tsx +++ b/frontend/src/components/ScreenTranslationGate.tsx @@ -1,3 +1,4 @@ +import { type Locale, useLocale } from "../i18n"; import { StatusNotice } from "./StatusNotice"; export type ScreenTranslationGateProps = { @@ -5,16 +6,99 @@ export type ScreenTranslationGateProps = { onRetry?: () => void; }; +type BootstrapCopy = { + loading: string; + retryLabel: string; + retryDescription: string; + failure: string; + nextAction: string; + retryAction: string; +}; + +// This copy is deliberately local to the bootstrap gate: the published screen +// resource cannot translate the state shown while that resource itself is being +// fetched. Keep it small, cause-neutral, and separate from Customer Master copy. +const BOOTSTRAP_COPY: Record = { + ko: { + loading: "선택한 언어로 이 화면을 불러오는 중입니다...", + retryLabel: "다시 시도 필요", + retryDescription: "요청이 실패했습니다. 같은 작업을 다시 시도하세요.", + failure: "선택한 언어로 이 화면을 불러오지 못했습니다.", + nextAction: "번역 요청을 다시 시도하세요. 계속 실패하면 관리자에게 접근 권한과 게시 상태를 확인해 달라고 요청하세요.", + retryAction: "다시 시도", + }, + en: { + loading: "Loading this screen in your selected language...", + retryLabel: "Retry needed", + retryDescription: "This request failed. Retry the same action.", + failure: "We could not load this screen in your selected language.", + nextAction: "Retry the translation request. If it still fails, ask an administrator to check access and publication status.", + retryAction: "Retry", + }, + ja: { + loading: "選択した言語でこの画面を読み込んでいます...", + retryLabel: "再試行が必要です", + retryDescription: "リクエストに失敗しました。同じ操作をもう一度お試しください。", + failure: "選択した言語でこの画面を読み込めませんでした。", + nextAction: "翻訳の取得を再試行してください。引き続き失敗する場合は、管理者にアクセス権と公開状態の確認を依頼してください。", + retryAction: "再試行", + }, + zh: { + loading: "正在以所选语言加载此页面...", + retryLabel: "需要重试", + retryDescription: "请求失败。请重试同一操作。", + failure: "无法以所选语言加载此页面。", + nextAction: "请重试翻译请求。如果仍然失败,请让管理员检查访问权限和发布状态。", + retryAction: "重试", + }, + vi: { + loading: "Đang tải màn hình này bằng ngôn ngữ đã chọn...", + retryLabel: "Cần thử lại", + retryDescription: "Yêu cầu không thành công. Hãy thử lại cùng thao tác.", + failure: "Không thể tải màn hình này bằng ngôn ngữ đã chọn.", + nextAction: "Hãy thử lại yêu cầu bản dịch. Nếu vẫn thất bại, hãy nhờ quản trị viên kiểm tra quyền truy cập và trạng thái xuất bản.", + retryAction: "Thử lại", + }, + es: { + loading: "Cargando esta pantalla en el idioma seleccionado...", + retryLabel: "Es necesario reintentar", + retryDescription: "La solicitud ha fallado. Vuelva a intentar la misma acción.", + failure: "No se pudo cargar esta pantalla en el idioma seleccionado.", + nextAction: "Vuelva a intentar la solicitud de traducción. Si sigue fallando, pida a un administrador que compruebe el acceso y el estado de publicación.", + retryAction: "Reintentar", + }, + de: { + loading: "Dieser Bildschirm wird in der ausgewählten Sprache geladen...", + retryLabel: "Erneuter Versuch erforderlich", + retryDescription: "Die Anfrage ist fehlgeschlagen. Versuchen Sie dieselbe Aktion erneut.", + failure: "Dieser Bildschirm konnte in der ausgewählten Sprache nicht geladen werden.", + nextAction: "Versuchen Sie die Übersetzungsanfrage erneut. Wenn der Fehler weiterhin auftritt, lassen Sie Zugriffsrechte und Veröffentlichungsstatus durch die Administration prüfen.", + retryAction: "Erneut versuchen", + }, + fr: { + loading: "Chargement de cet écran dans la langue sélectionnée…", + retryLabel: "Nouvelle tentative requise", + retryDescription: "La demande a échoué. Réessayez la même action.", + failure: "Impossible de charger cet écran dans la langue sélectionnée.", + nextAction: "Réessayez la demande de traduction. Si l’échec persiste, demandez à un administrateur de vérifier l’accès et l’état de publication.", + retryAction: "Réessayer", + }, +}; + /** Keep an untranslated screen hidden while offering one concrete recovery action. */ export function ScreenTranslationGate({ state, onRetry }: ScreenTranslationGateProps) { + const copy = BOOTSTRAP_COPY[useLocale()]; if (state === "loading") { - return

Loading this screen in your selected language...

; + return

{copy.loading}

; } return ( ); From 488b358f316f34faefc7f2f832d948527f0be675 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:08:35 +0900 Subject: [PATCH 13/21] test(customer-master): reject stale auth data admission --- ...r_master_translation_auth_gate_contract.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_customer_master_translation_auth_gate_contract.py b/tests/test_customer_master_translation_auth_gate_contract.py index 0dbf4fcf7..50fde9e12 100644 --- a/tests/test_customer_master_translation_auth_gate_contract.py +++ b/tests/test_customer_master_translation_auth_gate_contract.py @@ -36,6 +36,27 @@ def test_customer_master_data_effect_requires_auth_bound_copy() -> None: assert "void loadMaster();" in data_effect +def test_customer_master_auth_transition_clears_data_before_copy_can_unlock() -> None: + """Token/locale transitions must invalidate customer data before replacement copy is fetched.""" + panel = _customer_master_panel_source() + translation_fetch = panel.index('fetchTranslationScreen(accessToken, "customer-master", locale)') + effect_start = panel.rfind(" useEffect(() => {", 0, translation_fetch) + transition_prefix = panel[effect_start:translation_fetch] + + assert "setMaster(null);" in transition_prefix + + +def test_customer_master_fetch_completion_is_not_admitted_by_unscoped_then_setter() -> None: + """A request started under an old auth identity must not publish after identity changes.""" + panel = _customer_master_panel_source() + callback_start = panel.index(" const loadMaster = useCallback(() => {") + callback_end = panel.index("\n useEffect(() => {", callback_start) + load_master = panel[callback_start:callback_end] + + assert ".then(setMaster)" not in load_master + assert "masterRequestGeneration" in panel + + def test_customer_master_render_gate_rejects_stale_token_copy() -> None: """Stale-token translations must not unlock the Customer Master surface.""" panel = _customer_master_panel_source() From c95736ab6627d646ba4455ae2749f84f9cf23d31 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 5 Sep 2026 14:49:25 +0900 Subject: [PATCH 14/21] fix(customer-master): reject stale auth responses Signed-off-by: Codex --- frontend/src/App.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b0ea0158c..3905f9d0a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4767,6 +4767,7 @@ function CustomerMasterPanel({ const [copyAccessToken, setCopyAccessToken] = useState(null); const [copyAttempt, setCopyAttempt] = useState(0); const [master, setMaster] = useState(null); + const masterRequestGeneration = useRef(0); const [error, setError] = useState(null); const [expandedEntityId, setExpandedEntityId] = useState(null); const [relatedByEntity, setRelatedByEntity] = useState>({}); @@ -4798,14 +4799,23 @@ function CustomerMasterPanel({ }, [accessToken]); const loadMaster = useCallback(() => { + const requestGeneration = ++masterRequestGeneration.current; setError(null); return fetchCustomerMaster(accessToken) - .then(setMaster) - .catch(() => setError(t("Customer master could not be loaded."))); + .then((nextMaster) => { + if (requestGeneration === masterRequestGeneration.current) setMaster(nextMaster); + }) + .catch(() => { + if (requestGeneration === masterRequestGeneration.current) { + setError(t("Customer master could not be loaded.")); + } + }); }, [accessToken]); useEffect(() => { let active = true; + masterRequestGeneration.current += 1; + setMaster(null); setCopyState("loading"); setCopyLocale(null); setCopyAccessToken(null); From aa601e310013d3b4d2c9de56f5e073ffa5821b54 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 5 Sep 2026 14:54:22 +0900 Subject: [PATCH 15/21] docs(gaps): refresh Customer Master exact-head evidence Signed-off-by: Codex --- docs/product-technical-gap-baseline.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 6f8412437..5dabca006 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -5,14 +5,14 @@ > ADR 0362 candidate for issue #922 and is open / Ready for exact-head > validation. Required current-head checks are not yet accepted as terminal GREEN > and the delivery boundary still requires qualifying independent review. The -> live non-identifying queue snapshot contains 120 open PRs and 16 open issues; +> live non-identifying queue snapshot contains 122 open PRs and 16 open issues; > those counts describe coordination load, not product maturity or release > readiness. The authenticated `GET /api/translations/{screen_key}` API is > implemented on the candidate branch. That is candidate implementation > evidence, not protected-main, deployed, or release evidence. > A stacked Customer Master consumer candidate now exists at exact head -> `cb093960d43e95cdfb1d9ed491e920e2106305db` on top of PR #929's -> exact head `f07a755972e38b4b2a961ab11acd9d3abb229967`. It admits all eight +> `c95736ab6627d646ba4455ae2749f84f9cf23d31` on top of PR #929's +> exact head `2a8ed5d02f4a3082b346d923d754c1ff37ebff52`. It admits all eight > locale tags, fetches the authenticated `customer-master` resource before > customer data, rejects incomplete screen projections, ignores late responses > from a previous locale or authorization identity, and shows an actionable @@ -25,8 +25,8 @@ > cause-neutral while retaining one concrete retry action. This stacked branch > is not protected-main, hosted-product-GREEN, authenticated PostgreSQL, or > deployed evidence. -> Current-head local evidence is 59 frontend test files / 538 tests, lint, -> production build, Storybook build, nine focused Python contract tests, and +> Current-head local evidence is 59 frontend test files / 546 tests, lint, +> production build, Storybook build, five focused Python contract tests, and > freshly inspected 1440 x 900 plus 390 x 844 retry-state captures. These local > results do not satisfy the protected delivery boundary. > From fe01453821105e62274a754797f6bf51a2b9ae7d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 15:19:12 +0900 Subject: [PATCH 16/21] test(customer-master): reject stale secondary auth projections --- ...r_master_translation_auth_gate_contract.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_customer_master_translation_auth_gate_contract.py b/tests/test_customer_master_translation_auth_gate_contract.py index 50fde9e12..6ae049194 100644 --- a/tests/test_customer_master_translation_auth_gate_contract.py +++ b/tests/test_customer_master_translation_auth_gate_contract.py @@ -65,3 +65,47 @@ def test_customer_master_render_gate_rejects_stale_token_copy() -> None: render_gate = panel[render_gate_start:render_gate_end] assert "copyAccessToken !== accessToken" in render_gate + + +def test_customer_master_auth_transition_invalidates_secondary_authorization_projections() -> None: + """Related data, post detail, and privileges from the old token must be discarded on transition.""" + panel = _customer_master_panel_source() + fetch_me = panel.index("fetchMe(accessToken)") + effect_start = panel.rfind(" useEffect(() => {", 0, fetch_me) + effect_end = panel.index("\n }, [accessToken]);", fetch_me) + auth_effect = panel[effect_start:effect_end] + + for statement in ( + "setCanResolveHints(false);", + "setRelatedByEntity({});", + "setExpandedEntityId(null);", + "setRelatedLoading(null);", + "setSelectedPostId(null);", + "setSelectedPostGraph(null);", + "setResolvingHint(null);", + "setResolveError(null);", + ): + assert statement in auth_effect + + +def test_customer_master_secondary_async_completions_are_bound_to_current_auth_identity() -> None: + """Old-token async continuations must not repopulate Customer Master secondary projections.""" + panel = _customer_master_panel_source() + + assert "const currentAccessTokenRef = useRef(accessToken);" in panel + assert "currentAccessTokenRef.current = accessToken;" in panel + + load_start = panel.index(" const loadMaster = useCallback(() => {") + load_end = panel.index("\n useEffect(() => {", load_start) + load_master = panel[load_start:load_end] + assert "requestAccessToken === currentAccessTokenRef.current" in load_master + + resolve_start = panel.index(" async function handleResolveHint(") + resolve_end = panel.index("\n async function toggleEntity(", resolve_start) + resolve_hint = panel[resolve_start:resolve_end] + assert "requestAccessToken === currentAccessTokenRef.current" in resolve_hint + + toggle_start = panel.index(" async function toggleEntity(") + toggle_end = panel.index('\n if (copyState === "retry")', toggle_start) + toggle_entity = panel[toggle_start:toggle_end] + assert "requestAccessToken === currentAccessTokenRef.current" in toggle_entity From 846ec4700666188a281940b99ac4edf776e904ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 10:03:16 +0900 Subject: [PATCH 17/21] fix(customer-master): invalidate stale auth projections Promote the verified clean staging tree from 559645063a7defaedbcc5bedb35f6b52a5b896fe onto unchanged #932 product head fe01453821105e62274a754797f6bf51a2b9ae7d. The temporary workflow is absent; the net product delta is App.tsx only. Governing secondary-auth contract and focused Customer Master suites were GREEN before promotion. --- frontend/src/App.tsx | 49 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3905f9d0a..d88748103 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4768,6 +4768,8 @@ function CustomerMasterPanel({ const [copyAttempt, setCopyAttempt] = useState(0); const [master, setMaster] = useState(null); const masterRequestGeneration = useRef(0); + const currentAccessTokenRef = useRef(accessToken); + currentAccessTokenRef.current = accessToken; const [error, setError] = useState(null); const [expandedEntityId, setExpandedEntityId] = useState(null); const [relatedByEntity, setRelatedByEntity] = useState>({}); @@ -4786,6 +4788,14 @@ function CustomerMasterPanel({ useEffect(() => { let active = true; + setCanResolveHints(false); + setRelatedByEntity({}); + setExpandedEntityId(null); + setRelatedLoading(null); + setSelectedPostId(null); + setSelectedPostGraph(null); + setResolvingHint(null); + setResolveError(null); fetchMe(accessToken) .then((member) => { if (active) setCanResolveHints(member.permission_codes.includes("post_admin")); @@ -4799,14 +4809,23 @@ function CustomerMasterPanel({ }, [accessToken]); const loadMaster = useCallback(() => { + const requestAccessToken = accessToken; const requestGeneration = ++masterRequestGeneration.current; setError(null); - return fetchCustomerMaster(accessToken) + return fetchCustomerMaster(requestAccessToken) .then((nextMaster) => { - if (requestGeneration === masterRequestGeneration.current) setMaster(nextMaster); + if ( + requestGeneration === masterRequestGeneration.current && + requestAccessToken === currentAccessTokenRef.current + ) { + setMaster(nextMaster); + } }) .catch(() => { - if (requestGeneration === masterRequestGeneration.current) { + if ( + requestGeneration === masterRequestGeneration.current && + requestAccessToken === currentAccessTokenRef.current + ) { setError(t("Customer master could not be loaded.")); } }); @@ -4867,19 +4886,23 @@ function CustomerMasterPanel({ } async function handleResolveHint(hintCode: string) { + const requestAccessToken = accessToken; setResolvingHint(hintCode); setResolveError(null); try { - await resolveCustomerHint(accessToken, hintCode); - await loadMaster(); + await resolveCustomerHint(requestAccessToken, hintCode); + if (requestAccessToken === currentAccessTokenRef.current) await loadMaster(); } catch { - setResolveError(t("This hint could not be resolved to a corroborated organization name.")); + if (requestAccessToken === currentAccessTokenRef.current) { + setResolveError(t("This hint could not be resolved to a corroborated organization name.")); + } } finally { - setResolvingHint(null); + if (requestAccessToken === currentAccessTokenRef.current) setResolvingHint(null); } } async function toggleEntity(entityId: string) { + const requestAccessToken = accessToken; if (expandedEntityId === entityId) { setExpandedEntityId(null); return; @@ -4888,12 +4911,16 @@ function CustomerMasterPanel({ if (relatedByEntity[entityId]) return; setRelatedLoading(entityId); try { - const response = await fetchRelatedEntity(accessToken, entityId); - setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); + const response = await fetchRelatedEntity(requestAccessToken, entityId); + if (requestAccessToken === currentAccessTokenRef.current) { + setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); + } } catch { - setRelatedByEntity((previous) => ({ ...previous, [entityId]: [] })); + if (requestAccessToken === currentAccessTokenRef.current) { + setRelatedByEntity((previous) => ({ ...previous, [entityId]: [] })); + } } finally { - setRelatedLoading(null); + if (requestAccessToken === currentAccessTokenRef.current) setRelatedLoading(null); } } From 9b033e2737e9a42e04c326a80b53142a365a923d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 7 Sep 2026 12:00:06 +0900 Subject: [PATCH 18/21] fix(i18n): give readers actionable screen recovery guidance --- docs/product-technical-gap-baseline.md | 20 +++++++++++++++++++ docs/storybook-inventory.md | 2 +- .../ScreenTranslationGate.stories.tsx | 13 ++++++++++++ .../components/ScreenTranslationGate.test.tsx | 2 +- .../src/components/ScreenTranslationGate.tsx | 16 +++++++-------- 5 files changed, 43 insertions(+), 10 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 5dabca006..fc191f601 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,25 @@ # Product & Technical Gap Baseline +## 2026-09-07 bootstrap recovery follow-up (PR #932) + +The candidate based on `846ec4700666188a281940b99ac4edf776e904ca` +removes access/publication troubleshooting from the eight-locale recovery +instruction. Readers can retry and contact their administrator if the problem +continues; an unclassified load failure still does not assert a cause. +Seven additional locale stories reuse the existing gate and status notice. +The earlier snapshot below remains historical evidence. + +The existing English recovery assertion failed before the copy change +(1 failed / 10 passed); the repaired gate and shared notice passed all 17 +focused tests. Lint, production build, and Storybook build also passed; the +existing production chunk-size warning remains. An actual browser displayed each of the eight retry stories at +320 × 640: document width and scroll width were both 320 pixels in all eight, +and Tab focused the correctly localized retry button in all eight. English, +German, and French screenshots were visually inspected. This verifies the +isolated bootstrap shell only: no authenticated source records were loaded, +and it does not establish PostgreSQL translation delivery, native-speaker +review, screen-reader acceptance, all-page latency, protected merge, or release. + > Snapshot refreshed 2026-09-05 KST. Protected `main` is > `83eba56149eb802cd63642c507c324c9976ec78e`. PR #929 is the active > ADR 0362 candidate for issue #922 and is open / Ready for exact-head diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 656a0491a..78ccfc769 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -20,7 +20,7 @@ operator-facing control you can click before changing product CSS. | `Admin/AdminPanel` | Change the tenant brand name, then verify the saved or failed state before leaving settings. | `--surface`, `--border`, `--space-panel-block`, `AdminPanel` | | `Lineage/LineageDag` | Open a reconstructed connection to read its inferred channel scores and Allen interval relation, or open the current branch node; compare empty, single-branch, grouped/forked, mobile-scroll, ungrouped, and long-title states before changing graph CSS. On narrow viewports, swipe the named viewport or focus it and use arrow keys to inspect the full lineage. | `--color-accent-background`, `--radius-control`, `--surface`, `--border`, `--color-focus-border`, `--size-control-min`, `LineageDag` | | `Chrome/StatusNotice` | Read success, unavailable, or retry copy, then take the named next action. Success and unavailable are a named region (not live `role=status`); Retry is `role=alert` and only on the retry kind. Calendar's missing Naruon projection uses unavailable. | `--badge-status-success-*`, `--badge-status-pending-*`, `--badge-status-danger-*`, `StatusNotice` | -| `Chrome/Screen translation gate` | Wait for the selected-language screen or retry after an administrator publishes its complete copy. The gated destination never flashes bundled screen copy. | `StatusNotice`, `ScreenTranslationGate` | +| `Chrome/Screen translation gate` | Wait for the selected-language screen, retry a failed load, or contact an administrator if the problem persists. Eight locale-specific retry stories cover the bootstrap recovery copy. The gated destination never flashes bundled screen copy. | `StatusNotice`, `ScreenTranslationGate` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | | `Workspace/WorkspaceCalendar` | Read observed Naruon events, or open a commitment to land on that post. Fail-closed copy stays `이 범위의 일정을 아직 받을 수 없습니다`. | `--color-chip-border`, `WorkspaceCalendar`, `EvidenceStatusMark` | | `Ask Agent/Public claim verification` | Compare supported, refuted, and not-enough-information states; open only the external evidence link, then review the separate internal citation before changing governed graph state. | `--space-panel-block`, `--space-control-gap`, `--color-border`, `--size-control-min`, `PublicClaimVerification` | diff --git a/frontend/src/components/ScreenTranslationGate.stories.tsx b/frontend/src/components/ScreenTranslationGate.stories.tsx index b531d5fb9..d6c20eae4 100644 --- a/frontend/src/components/ScreenTranslationGate.stories.tsx +++ b/frontend/src/components/ScreenTranslationGate.stories.tsx @@ -1,10 +1,15 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { setLocale } from "../i18n"; import { ScreenTranslationGate } from "./ScreenTranslationGate"; const meta = { title: "Chrome/Screen translation gate", component: ScreenTranslationGate, args: { onRetry: () => undefined }, + decorators: [(Story, context) => { + setLocale(context.parameters.locale ?? "en"); + return ; + }], } satisfies Meta; export default meta; @@ -16,3 +21,11 @@ export const RetryMobile: Story = { args: { state: "retry" }, parameters: { viewport: { defaultViewport: "mobile1" } }, }; + +export const RetryKorean: Story = { args: { state: "retry" }, parameters: { locale: "ko" } }; +export const RetryJapanese: Story = { args: { state: "retry" }, parameters: { locale: "ja" } }; +export const RetryChinese: Story = { args: { state: "retry" }, parameters: { locale: "zh" } }; +export const RetryVietnamese: Story = { args: { state: "retry" }, parameters: { locale: "vi" } }; +export const RetrySpanish: Story = { args: { state: "retry" }, parameters: { locale: "es" } }; +export const RetryGerman: Story = { args: { state: "retry" }, parameters: { locale: "de" } }; +export const RetryFrench: Story = { args: { state: "retry" }, parameters: { locale: "fr" } }; diff --git a/frontend/src/components/ScreenTranslationGate.test.tsx b/frontend/src/components/ScreenTranslationGate.test.tsx index 97162098f..c43d7f8d1 100644 --- a/frontend/src/components/ScreenTranslationGate.test.tsx +++ b/frontend/src/components/ScreenTranslationGate.test.tsx @@ -16,7 +16,7 @@ describe("ScreenTranslationGate", () => { it("keeps an unclassified projection failure cause-neutral", () => { render( undefined} />); expect(screen.getByText("We could not load this screen in your selected language.")).toBeInTheDocument(); - expect(screen.getByText("Retry the translation request. If it still fails, ask an administrator to check access and publication status.")).toBeInTheDocument(); + expect(screen.getByText("Try again. If the problem continues, contact your administrator.")).toBeInTheDocument(); expect(screen.queryByText(/not available in your selected language yet/i)).not.toBeInTheDocument(); expect(screen.queryByText(/ask an administrator to publish this screen/i)).not.toBeInTheDocument(); }); diff --git a/frontend/src/components/ScreenTranslationGate.tsx b/frontend/src/components/ScreenTranslationGate.tsx index cb21a97b0..61d53b7b3 100644 --- a/frontend/src/components/ScreenTranslationGate.tsx +++ b/frontend/src/components/ScreenTranslationGate.tsx @@ -24,7 +24,7 @@ const BOOTSTRAP_COPY: Record = { retryLabel: "다시 시도 필요", retryDescription: "요청이 실패했습니다. 같은 작업을 다시 시도하세요.", failure: "선택한 언어로 이 화면을 불러오지 못했습니다.", - nextAction: "번역 요청을 다시 시도하세요. 계속 실패하면 관리자에게 접근 권한과 게시 상태를 확인해 달라고 요청하세요.", + nextAction: "다시 시도하세요. 문제가 계속되면 관리자에게 문의하세요.", retryAction: "다시 시도", }, en: { @@ -32,7 +32,7 @@ const BOOTSTRAP_COPY: Record = { retryLabel: "Retry needed", retryDescription: "This request failed. Retry the same action.", failure: "We could not load this screen in your selected language.", - nextAction: "Retry the translation request. If it still fails, ask an administrator to check access and publication status.", + nextAction: "Try again. If the problem continues, contact your administrator.", retryAction: "Retry", }, ja: { @@ -40,7 +40,7 @@ const BOOTSTRAP_COPY: Record = { retryLabel: "再試行が必要です", retryDescription: "リクエストに失敗しました。同じ操作をもう一度お試しください。", failure: "選択した言語でこの画面を読み込めませんでした。", - nextAction: "翻訳の取得を再試行してください。引き続き失敗する場合は、管理者にアクセス権と公開状態の確認を依頼してください。", + nextAction: "もう一度お試しください。問題が続く場合は、管理者にお問い合わせください。", retryAction: "再試行", }, zh: { @@ -48,7 +48,7 @@ const BOOTSTRAP_COPY: Record = { retryLabel: "需要重试", retryDescription: "请求失败。请重试同一操作。", failure: "无法以所选语言加载此页面。", - nextAction: "请重试翻译请求。如果仍然失败,请让管理员检查访问权限和发布状态。", + nextAction: "请重试。如果问题仍然存在,请联系管理员。", retryAction: "重试", }, vi: { @@ -56,7 +56,7 @@ const BOOTSTRAP_COPY: Record = { retryLabel: "Cần thử lại", retryDescription: "Yêu cầu không thành công. Hãy thử lại cùng thao tác.", failure: "Không thể tải màn hình này bằng ngôn ngữ đã chọn.", - nextAction: "Hãy thử lại yêu cầu bản dịch. Nếu vẫn thất bại, hãy nhờ quản trị viên kiểm tra quyền truy cập và trạng thái xuất bản.", + nextAction: "Hãy thử lại. Nếu sự cố vẫn tiếp diễn, hãy liên hệ với quản trị viên.", retryAction: "Thử lại", }, es: { @@ -64,7 +64,7 @@ const BOOTSTRAP_COPY: Record = { retryLabel: "Es necesario reintentar", retryDescription: "La solicitud ha fallado. Vuelva a intentar la misma acción.", failure: "No se pudo cargar esta pantalla en el idioma seleccionado.", - nextAction: "Vuelva a intentar la solicitud de traducción. Si sigue fallando, pida a un administrador que compruebe el acceso y el estado de publicación.", + nextAction: "Vuelva a intentarlo. Si el problema persiste, póngase en contacto con su administrador.", retryAction: "Reintentar", }, de: { @@ -72,7 +72,7 @@ const BOOTSTRAP_COPY: Record = { retryLabel: "Erneuter Versuch erforderlich", retryDescription: "Die Anfrage ist fehlgeschlagen. Versuchen Sie dieselbe Aktion erneut.", failure: "Dieser Bildschirm konnte in der ausgewählten Sprache nicht geladen werden.", - nextAction: "Versuchen Sie die Übersetzungsanfrage erneut. Wenn der Fehler weiterhin auftritt, lassen Sie Zugriffsrechte und Veröffentlichungsstatus durch die Administration prüfen.", + nextAction: "Versuchen Sie es erneut. Wenn das Problem weiterhin besteht, wenden Sie sich an die Administration.", retryAction: "Erneut versuchen", }, fr: { @@ -80,7 +80,7 @@ const BOOTSTRAP_COPY: Record = { retryLabel: "Nouvelle tentative requise", retryDescription: "La demande a échoué. Réessayez la même action.", failure: "Impossible de charger cet écran dans la langue sélectionnée.", - nextAction: "Réessayez la demande de traduction. Si l’échec persiste, demandez à un administrateur de vérifier l’accès et l’état de publication.", + nextAction: "Réessayez. Si le problème persiste, contactez votre administrateur.", retryAction: "Réessayer", }, }; From b98ebeb74d926a429817e4b09514a55af95f2ff7 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 7 Sep 2026 12:20:41 +0900 Subject: [PATCH 19/21] fix(customer-master): retire requests across authorization lifecycles --- .../0362-versioned-ui-translation-ledger.md | 22 +++++ docs/product-technical-gap-baseline.md | 21 +++++ frontend/src/App.test.tsx | 83 ++++++++++++++++++- frontend/src/App.tsx | 21 +++-- 4 files changed, 137 insertions(+), 10 deletions(-) diff --git a/docs/adr/0362-versioned-ui-translation-ledger.md b/docs/adr/0362-versioned-ui-translation-ledger.md index c586c9c27..ac25c4529 100644 --- a/docs/adr/0362-versioned-ui-translation-ledger.md +++ b/docs/adr/0362-versioned-ui-translation-ledger.md @@ -130,6 +130,28 @@ locale cannot replace the active screen. Other destinations remain on their existing bundle until they receive their own complete resource and executable cutover evidence. +### Authorization-lifecycle admission clarification (2026-09-07) + +Translation readiness and Customer Master data belong to the current +authorization lifecycle. A token change clears both the primary projection and +secondary related-post, hint, detail, and permission state. An imperative +request may publish data, an error, a loading completion, or a follow-up read +only while its originating lifecycle remains current. Returning to the same +token value after A → B → A does not revive the first A lifecycle. Unmounting +also retires that lifecycle. Existing per-request ordering still applies within +one lifecycle, and server-side authorization remains authoritative. + +The existing token-equality guard was insufficient: a deferred related response +replaced the new list after A → B → A, while a deferred hint result could +trigger a stale refresh or display a retired error. Four behavioral App tests +(success/failure for each operation) fail on `9b033e2737e9a42e04c326a80b53142a365a923d`. +The repair reuses the existing authorization effect's cleanup to advance a +component-local generation and checks that generation at all imperative +completion paths. No shared session service, token storage, or new API is added. +Effect-local cancellation still governs translation, permission, and detail +fetches. This is candidate correctness evidence, not real-account or release +acceptance. + ## DDD mapping - Subdomain: product composition / presentation read model. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index fc191f601..ce737e6f0 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,26 @@ # Product & Technical Gap Baseline +## 2026-09-07 authorization lifecycle repair (PR #932) + +The current candidate extends the existing auth-bound Customer Master guard to +retire outstanding imperative requests when the authorization lifecycle ends. +Token equality alone admitted a first-A response after A → B → A: related +records could overwrite the current list, and hint completion could refresh it +or show an obsolete error. Four executable App regressions reproduce these +success/failure paths on `9b033e2737e9a42e04c326a80b53142a365a923d` +(4 failed); all four pass with lifecycle generation checks. The primary list +also retains its request-order guard. Existing effect cancellation continues +to govern translation, permissions, and post detail. The seven existing Python +auth contract functions also passed when invoked with their standard-library +assertions; they are supporting source checks, not behavioral proof. + +The full frontend run (`vitest run --maxWorkers=2`) included all 550 tests: +547 passed and three timed out across App and ontology exploration. No tests +were excluded or deadlines increased. All three timeout cases and the four new regressions passed together in a +focused single-worker recheck (7 passed). Full-suite GREEN is still unproven. +This repair uses synthetic unit-test responses only and does not prove real +account transitions, protected-main delivery, or authenticated p95 acceptance. + ## 2026-09-07 bootstrap recovery follow-up (PR #932) The candidate based on `846ec4700666188a281940b99ac4edf776e904ca` diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index bacb71db7..46f5d256d 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import App, { SurfaceBoundary } from "./App"; @@ -205,7 +205,7 @@ describe("App, authenticated", () => { }) : Promise.resolve(); - const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>((input, init) => { const url = String(input); const method = init?.method ?? "GET"; @@ -2265,6 +2265,50 @@ describe("App, authenticated", () => { expect(parentRow?.contains(subsidiaryRow)).toBe(true); }); + it.each(["success", "failure"])("rejects a related %s from before an A-B-A authorization transition", async (outcome) => { + const backend = stubBackend(); + let releaseRelated!: (response: Response) => void; + let rejectRelated!: (error: Error) => void; + const oldRelated = new Promise((resolve, reject) => { + releaseRelated = resolve; + rejectRelated = reject; + }); + let relatedRequests = 0; + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).endsWith("/api/corporate-entities/corp-demo/related")) { + relatedRequests += 1; + if (relatedRequests === 1) return oldRelated; + } + return backend(input, init); + })); + const { rerender } = render(); + await userEvent.click(await screen.findByRole("button", { name: "고객 마스터" })); + await userEvent.click((await screen.findByText("DEMO-CORP-01 · Company")).closest("button")!); + expect(relatedRequests).toBe(1); + + for (const accessToken of ["other-access-token", "test-access-token"]) { + mockAuth = { ...mockAuth, user: { + access_token: accessToken, profile: { preferred_username: "demo.analyst" }, + } }; + rerender(); + await screen.findByText("DEMO-CORP-01 · Company"); + } + await userEvent.click(screen.getByText("DEMO-CORP-01 · Company").closest("button")!); + await screen.findByRole("button", { name: "Open related post: Linked post" }); + expect(relatedRequests).toBe(2); + + await act(async () => { + if (outcome === "failure") rejectRelated(new Error("Superseded request failed")); + else releaseRelated(jsonResponse({ related: [{ + node_id: "superseded-post", node_type_code: "node_post", + label: "Superseded related post", relevance: 0.5, + }] })); + await oldRelated.catch(() => undefined); + }); + expect(screen.queryByRole("button", { name: "Open related post: Superseded related post" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open related post: Linked post" })).toBeInTheDocument(); + }); + it("opens a customer's related post in place instead of jumping to the Board", async () => { // Live bug (2026-08-19): opening a related post from Customer // Master swapped the whole workspace to the Board and opened the @@ -2313,6 +2357,41 @@ describe("App, authenticated", () => { expect(within(soloRow as HTMLElement).queryByText("Multiple roles observed")).not.toBeInTheDocument(); }); + it.each(["success", "failure"])("ignores hint %s from before an A-B-A authorization transition", async (outcome) => { + const backend = stubBackend({ admin: true, manyCustomerHints: 1 }); + let releaseHint!: (response: Response) => void; + let rejectHint!: (error: Error) => void; + const oldHint = new Promise((resolve, reject) => { + releaseHint = resolve; + rejectHint = reject; + }); + let masterRequests = 0; + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).endsWith("/api/customer-master/resolve-hint")) return oldHint; + if (String(input).endsWith("/api/customer-master")) masterRequests += 1; + return backend(input, init); + })); + const { rerender } = render(); + await userEvent.click(await screen.findByRole("button", { name: "고객 마스터" })); + await userEvent.click(await screen.findByRole("button", { name: "Resolve" })); + for (const accessToken of ["other-access-token", "test-access-token"]) { + mockAuth = { ...mockAuth, user: { + access_token: accessToken, profile: { preferred_username: "demo.analyst" }, + } }; + rerender(); + await screen.findByRole("button", { name: "Resolve" }); + } + const currentRequests = masterRequests; + await act(async () => { + if (outcome === "failure") rejectHint(new Error("Superseded hint failed")); + else releaseHint(jsonResponse({ corporate_entity_id: "corp-demo", linked_post_count: 1 })); + await oldHint.catch(() => undefined); + }); + expect(masterRequests).toBe(currentRequests); + expect(screen.queryByText("This hint could not be resolved to a corroborated organization name.")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Resolve" })).toBeEnabled(); + }); + it("lets a post_admin account resolve an unresolved customer hint into a real name", async () => { // Feature (2026-08-19): a Customer Master hint (an opaque customer // code with no name) previously had no action at all -- a dead end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d88748103..7d8213e15 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4768,6 +4768,7 @@ function CustomerMasterPanel({ const [copyAttempt, setCopyAttempt] = useState(0); const [master, setMaster] = useState(null); const masterRequestGeneration = useRef(0); + const authGeneration = useRef(0); const currentAccessTokenRef = useRef(accessToken); currentAccessTokenRef.current = accessToken; const [error, setError] = useState(null); @@ -4805,18 +4806,20 @@ function CustomerMasterPanel({ }); return () => { active = false; + authGeneration.current += 1; }; }, [accessToken]); const loadMaster = useCallback(() => { const requestAccessToken = accessToken; + const requestAuthGeneration = authGeneration.current; const requestGeneration = ++masterRequestGeneration.current; setError(null); return fetchCustomerMaster(requestAccessToken) .then((nextMaster) => { if ( requestGeneration === masterRequestGeneration.current && - requestAccessToken === currentAccessTokenRef.current + requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current ) { setMaster(nextMaster); } @@ -4824,7 +4827,7 @@ function CustomerMasterPanel({ .catch(() => { if ( requestGeneration === masterRequestGeneration.current && - requestAccessToken === currentAccessTokenRef.current + requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current ) { setError(t("Customer master could not be loaded.")); } @@ -4887,22 +4890,24 @@ function CustomerMasterPanel({ async function handleResolveHint(hintCode: string) { const requestAccessToken = accessToken; + const requestAuthGeneration = authGeneration.current; setResolvingHint(hintCode); setResolveError(null); try { await resolveCustomerHint(requestAccessToken, hintCode); - if (requestAccessToken === currentAccessTokenRef.current) await loadMaster(); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) await loadMaster(); } catch { - if (requestAccessToken === currentAccessTokenRef.current) { + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { setResolveError(t("This hint could not be resolved to a corroborated organization name.")); } } finally { - if (requestAccessToken === currentAccessTokenRef.current) setResolvingHint(null); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) setResolvingHint(null); } } async function toggleEntity(entityId: string) { const requestAccessToken = accessToken; + const requestAuthGeneration = authGeneration.current; if (expandedEntityId === entityId) { setExpandedEntityId(null); return; @@ -4912,15 +4917,15 @@ function CustomerMasterPanel({ setRelatedLoading(entityId); try { const response = await fetchRelatedEntity(requestAccessToken, entityId); - if (requestAccessToken === currentAccessTokenRef.current) { + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); } } catch { - if (requestAccessToken === currentAccessTokenRef.current) { + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { setRelatedByEntity((previous) => ({ ...previous, [entityId]: [] })); } } finally { - if (requestAccessToken === currentAccessTokenRef.current) setRelatedLoading(null); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) setRelatedLoading(null); } } From afe7f8f212c0219ee19069d7177a4a35d77c04c2 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 7 Sep 2026 12:34:45 +0900 Subject: [PATCH 20/21] test(customer-master): intercept requests through the fetch boundary --- docs/product-technical-gap-baseline.md | 11 ++++++++++- frontend/src/App.test.tsx | 12 +++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index ce737e6f0..3c331d9bb 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -17,7 +17,16 @@ assertions; they are supporting source checks, not behavioral proof. The full frontend run (`vitest run --maxWorkers=2`) included all 550 tests: 547 passed and three timed out across App and ontology exploration. No tests were excluded or deadlines increased. All three timeout cases and the four new regressions passed together in a -focused single-worker recheck (7 passed). Full-suite GREEN is still unproven. +focused single-worker recheck (7 passed). Production type checking and build +also passed after the regression tests reused the installed standard fetch +boundary. A later focused run also hit one 5-second timeout; the tests now +synchronously navigate to Customer Master before exercising the deferred +responses, avoiding unrelated initial Board work. All four then passed with +the original timeout, and lint passed. With the final test driver, a fresh +paired run again failed all four assertions on the pre-repair revision and +passed all four on the repair; the final production build passed. Host scheduling also varied, so no +product-performance improvement is inferred. The existing chunk-size warning remains. Full-suite GREEN is still +unproven. This repair uses synthetic unit-test responses only and does not prove real account transitions, protected-main delivery, or authenticated p95 acceptance. diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 46f5d256d..709006a54 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -205,7 +205,7 @@ describe("App, authenticated", () => { }) : Promise.resolve(); - const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>((input, init) => { + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const method = init?.method ?? "GET"; @@ -2266,7 +2266,8 @@ describe("App, authenticated", () => { }); it.each(["success", "failure"])("rejects a related %s from before an A-B-A authorization transition", async (outcome) => { - const backend = stubBackend(); + stubBackend(); + const backend = fetch; let releaseRelated!: (response: Response) => void; let rejectRelated!: (error: Error) => void; const oldRelated = new Promise((resolve, reject) => { @@ -2282,7 +2283,7 @@ describe("App, authenticated", () => { return backend(input, init); })); const { rerender } = render(); - await userEvent.click(await screen.findByRole("button", { name: "고객 마스터" })); + fireEvent.click(screen.getByRole("button", { name: "고객 마스터" })); await userEvent.click((await screen.findByText("DEMO-CORP-01 · Company")).closest("button")!); expect(relatedRequests).toBe(1); @@ -2358,7 +2359,8 @@ describe("App, authenticated", () => { }); it.each(["success", "failure"])("ignores hint %s from before an A-B-A authorization transition", async (outcome) => { - const backend = stubBackend({ admin: true, manyCustomerHints: 1 }); + stubBackend({ admin: true, manyCustomerHints: 1 }); + const backend = fetch; let releaseHint!: (response: Response) => void; let rejectHint!: (error: Error) => void; const oldHint = new Promise((resolve, reject) => { @@ -2372,7 +2374,7 @@ describe("App, authenticated", () => { return backend(input, init); })); const { rerender } = render(); - await userEvent.click(await screen.findByRole("button", { name: "고객 마스터" })); + fireEvent.click(screen.getByRole("button", { name: "고객 마스터" })); await userEvent.click(await screen.findByRole("button", { name: "Resolve" })); for (const accessToken of ["other-access-token", "test-access-token"]) { mockAuth = { ...mockAuth, user: { From c01e3109c987115c65bcbc74cd6f6247527387b8 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 7 Sep 2026 12:46:32 +0900 Subject: [PATCH 21/21] docs(evidence): record live login boundary and full-suite recheck --- docs/product-technical-gap-baseline.md | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 3c331d9bb..7e4771fb7 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,48 @@ # Product & Technical Gap Baseline +## 2026-09-07 full-suite recheck on the published candidate + +On clean `afe7f8f212c0219ee19069d7177a4a35d77c04c2`, the complete frontend +suite was rerun without a concurrent build and with one worker. All 59 files +and 550 tests remained included, with committed test deadlines unchanged. +The result was 57 files passing / 2 failing and 546 tests passing / 4 timing +out in 493.43 seconds. The failures covered governed Voice selection, Ask +cutoff conversion, Ask delivery localization, and ontology continuation-error +retention. These differ from the earlier three timeout cases; reducing test +parallelism did not establish local suite GREEN. No worker setting, deadline, +or assertion was changed in the repository to turn this result green. + +The local suite target remains all 550 passing. Host scheduling varied during +these experiments, but that observation alone does not prove the cause or +repair the failing gate. No further unchanged full-suite rerun is justified +without diagnosis or a changed execution condition. Hosted Draft-skip policy +is separately tracked by #933; this consumer change does not override it. + +## 2026-09-07 live entry-path verification + +A fresh browser inspected the existing Compose frontend without credentials or +source-record queries. The documented `http://localhost:15173/` address showed +one login button and no product-owned login, signup, or recovery form. Clicking +login reached the existing issuer at `http://localhost:18080/` and displayed a +password input. No credentials were entered, so authenticated success, claim +admission, account recovery, and page p95 remain unverified. This existing +runtime does not establish delivery of the open consumer or identity PRs. + +The frontend container reported image +`sha256:2728a7896e4ccf5770567b98c8c6c23903f58b9a0ed7157377bf80a15e43a06a` +and start time `2026-09-01T05:03:13.413298609Z`. Its issuer container reported +`sha256:ede94b77ed55af002280a39d5be4ba8fb0231b525a2e1f9740310b7777395ea0`. +The browser navigation is direct evidence that this issuer is still consumed; +it must not be stopped merely because another identity engine is running. +The three previously verified inactive duplicate-stack containers remain +stopped, with containers and data volumes retained. + +Using `127.0.0.1:15173` instead of the documented hostname reached a callback +validation error, whereas the documented localhost origin reached the issuer +form. That difference is not evidence that the documented login path is +broken, and no callback allowlist or issuer configuration was broadened. +The temporary browser tab was closed after the read-only check. + ## 2026-09-07 authorization lifecycle repair (PR #932) The current candidate extends the existing auth-bound Customer Master guard to