Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
3d6ae1d
feat(i18n): gate Customer Master on published screen copy
Sep 5, 2026
fcceb7c
docs(gaps): record Customer Master translation cutover evidence
Sep 5, 2026
98de7c9
test(i18n): require auth-bound Customer Master copy admission
seonghobae Sep 5, 2026
90537bc
fix(i18n): bind Customer Master copy admission to auth token
seonghobae Sep 5, 2026
0c6c523
test(i18n): parse compound Customer Master admission guard
seonghobae Sep 5, 2026
fd3f032
test(i18n): reject false publication diagnosis
seonghobae Sep 5, 2026
cb09396
fix(i18n): keep translation failures cause-neutral
seonghobae Sep 5, 2026
28fbdee
docs(gaps): align Customer Master translation evidence
seonghobae Sep 5, 2026
170c230
docs(i18n): refresh exact-head visual evidence
Sep 5, 2026
51c7666
test(i18n): require localized translation bootstrap shell
seonghobae Sep 5, 2026
422064e
refactor(ui): allow localized bootstrap status semantics
seonghobae Sep 5, 2026
078f47d
fix(i18n): localize translation bootstrap gate across eight locales
seonghobae Sep 5, 2026
488b358
test(customer-master): reject stale auth data admission
seonghobae Sep 5, 2026
a0ebd1f
chore(i18n): converge Customer Master consumer onto parent test fixes
seonghobae Sep 5, 2026
c95736a
fix(customer-master): reject stale auth responses
Sep 5, 2026
aa601e3
docs(gaps): refresh Customer Master exact-head evidence
Sep 5, 2026
fe01453
test(customer-master): reject stale secondary auth projections
seonghobae Sep 5, 2026
846ec47
fix(customer-master): invalidate stale auth projections
seonghobae Sep 6, 2026
9b033e2
fix(i18n): give readers actionable screen recovery guidance
Sep 7, 2026
b98ebeb
fix(customer-master): retire requests across authorization lifecycles
Sep 7, 2026
afe7f8f
test(customer-master): intercept requests through the fetch boundary
Sep 7, 2026
c01e310
docs(evidence): record live login boundary and full-suite recheck
Sep 7, 2026
8c662a0
merge: preserve translation parent baseline and consumer evidence
Sep 8, 2026
f539295
chore(stack): converge #932 onto current translation parent
seonghobae Sep 8, 2026
727b23d
Merge remote-tracking branch 'origin/feat/i18n-versioned-translation-…
Sep 9, 2026
3f5f602
chore: converge #932 onto stable #929 baseline
seonghobae Sep 9, 2026
81bec97
chore: converge #932 onto executable #929 baseline
seonghobae Sep 9, 2026
578b048
chore: converge #932 onto scoped #929 snapshot
seonghobae Sep 9, 2026
07e130a
docs(i18n): keep stale screenshots out of acceptance
Sep 9, 2026
3af09e6
chore: converge #932 onto updated #929 head e1da2c26
Sep 9, 2026
d250248
chore: converge #932 onto rollback-safe translation parent
seonghobae Sep 9, 2026
dba4224
chore: converge #932 onto current #929 head
seonghobae Sep 9, 2026
6a13898
chore(i18n): converge #932 onto current translation-ledger owner
seonghobae Sep 10, 2026
798a930
test(i18n): require Customer Master ancestry screen keys
seonghobae Sep 10, 2026
6986098
test(customer-master): reproduce same-auth loading supersession
seonghobae Sep 10, 2026
edfb71d
test(customer-master): distinguish related lookup failure from empty
seonghobae Sep 10, 2026
bdfcdd2
test(customer-master): make related failure RED executable
seonghobae Sep 11, 2026
89e727e
test(#932): execute Customer Master request-state repair
seonghobae Sep 11, 2026
8456b89
fix(#932): repair exact Customer Master automation anchors
seonghobae Sep 11, 2026
e02270a
fix(#932): switch repair harness to deterministic patch
seonghobae Sep 11, 2026
3fa18a2
fix(#932): execute repair script without YAML patch ambiguity
seonghobae Sep 11, 2026
857284c
fix(customer-master): preserve related request state
github-actions[bot] Sep 11, 2026
bb36413
test(customer-master): align auth lifecycle contract with related loader
seonghobae Sep 11, 2026
012c30a
test(i18n): expose global locale fallback gap
seonghobae Sep 11, 2026
05a93b4
fix(i18n): separate global locale admission from ledger coverage
seonghobae Sep 11, 2026
f898ae0
test(i18n): pin product-wide locale admission
seonghobae Sep 11, 2026
8eb6e60
test(i18n): align bootstrap shell with global admission
seonghobae Sep 11, 2026
c993e44
fix(i18n): align bootstrap copy with global locale admission
seonghobae Sep 11, 2026
bdede9b
test(customer-master): reproduce same-entity request generation race
seonghobae Sep 11, 2026
1834a3b
ci: stage bounded #932 request-generation repair
seonghobae Sep 11, 2026
6d2e624
test(customer-master): satisfy RelatedNode contract in overlap regres…
seonghobae Sep 11, 2026
c13f6c3
fix(customer-master): fence same-entity related requests
github-actions[bot] Sep 11, 2026
4fa09d9
merge(parent): adopt UTF-8 cache-bound repair from #929
seonghobae Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/adr/0362-versioned-ui-translation-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,39 @@ 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.

### 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.
Expand Down
317 changes: 317 additions & 0 deletions docs/product-technical-gap-baseline-history-2026-09-07-pr932.md

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +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, 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` |
Expand Down
100 changes: 98 additions & 2 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
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";
import { optionalKnowledgeCutoffIso } from "./api";
import { setLocale } from "./i18n";
import { CUSTOMER_MASTER_TRANSLATION_KEYS, clearCustomerMasterTranslations, setLocale } from "./i18n";
import { OIDC_RETURN_URL_STORAGE_KEY } from "./oidcReturnUrl";

const signinRedirect = vi.fn();
Expand All @@ -15,6 +15,7 @@ vi.mock("react-oidc-context", () => ({
}));

beforeEach(() => {
clearCustomerMasterTranslations();
setLocale("en");
signinRedirect.mockReset();
signoutRedirect.mockReset();
Expand All @@ -39,6 +40,7 @@ it("normalizes valid knowledge cutoffs and rejects invalid input", () => {
});

afterEach(() => {
clearCustomerMasterTranslations();
vi.unstubAllGlobals();
window.history.replaceState({}, "", "/");
window.sessionStorage.clear();
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -2250,6 +2265,51 @@ 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) => {
stubBackend();
const backend = fetch;
let releaseRelated!: (response: Response) => void;
let rejectRelated!: (error: Error) => void;
const oldRelated = new Promise<Response>((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(<App />);
fireEvent.click(screen.getByRole("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(<App />);
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
Expand Down Expand Up @@ -2298,6 +2358,42 @@ 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) => {
stubBackend({ admin: true, manyCustomerHints: 1 });
const backend = fetch;
let releaseHint!: (response: Response) => void;
let rejectHint!: (error: Error) => void;
const oldHint = new Promise<Response>((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(<App />);
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: {
access_token: accessToken, profile: { preferred_username: "demo.analyst" },
} };
rerender(<App />);
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
Expand Down
Loading