Marketing & Operational Lineage Intelligence
diff --git a/docs/adr/0109-oidc-deep-link-state-recovery.md b/docs/adr/0109-oidc-deep-link-state-recovery.md
index 808f12aff..f38b2b6c9 100644
--- a/docs/adr/0109-oidc-deep-link-state-recovery.md
+++ b/docs/adr/0109-oidc-deep-link-state-recovery.md
@@ -23,6 +23,14 @@ when the member's OIDC session is otherwise valid.
recovery fallback, not an authentication or authorization store.
- On callback, remove the key from both stores and use session storage before
local storage. Reject external and protocol-relative URLs.
+- Validate the browser-parsed origin as well as the leading slash: backslashes
+ and embedded whitespace must not turn a path into another authority. Invalid
+ URL syntax fails closed. Strip authorization success and error response
+ parameters before storing, restoring, or sharing a path (RFC 6749 sections
+ 4.1.2 and 4.1.2.1); retain application parameters and fragments.
+- A failed sign-in keeps an explicit retry action (ADR 0220). Before retrying,
+ consume the existing return-path fallback and remember its sanitized value
+ again so a failed callback does not replace the intended post destination.
- Keep member language preference account-scoped in
`user_account.preferred_locale`; this ADR does not move locale state into the
post URL, browser storage, or a `user_account + post_id` key.
@@ -33,3 +41,10 @@ Opening a shared post link survives a missing OIDC state payload or a changed
storage context without losing the post. A stale internal return path is
removed at callback, and authorization still comes only from the authenticated
OIDC token and backend ABAC checks.
+
+## References — APA 7th
+
+Hardt, D. (Ed.). (2012). *The OAuth 2.0 authorization framework* (RFC 6749).
+Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc6749.html
+
+WHATWG. (n.d.). *URL standard*. https://url.spec.whatwg.org/
diff --git a/docs/adr/0220-token-backed-status-notice.md b/docs/adr/0220-token-backed-status-notice.md
index 1ae329ee4..312086203 100644
--- a/docs/adr/0220-token-backed-status-notice.md
+++ b/docs/adr/0220-token-backed-status-notice.md
@@ -37,6 +37,31 @@ The first migrated product flow is the Calendar Naruon fail-closed path.
Do not copy closed-branch exception classes or Storybook inventories from
PR #490. Later unavailable flows migrate one at a time.
+### Sign-in recovery
+
+A failed sign-in or an incomplete authenticated session keeps the existing
+login card, with product heading, a bounded retry notice, and one **Log in**
+action. Never display the authentication library's error message or credential
+details. Reuse the existing translated failure message and button label; add
+no translation catalog or identity transport. The action retries the existing
+OIDC flow with the sanitized destination from ADR 0109. Loading retains the
+existing live region and offers no duplicate submission action; successful
+authentication continues into the authorized workspace.
+
+Reuse the login layout and `StatusNotice` tokens, semantic alert, native
+button, keyboard behavior, and minimum control size. Storybook records the
+sign-in retry variant, and browser checks cover desktop and narrow layouts.
+The login card includes padding and borders in its declared width, so the
+page's overflow rule cannot hide clipped card edges on narrow screens.
+The existing Figma file `1Su3lDRmiZdcUs47t1QwIX`, page `0:1`, was inspected on
+2026-09-07: its Event Lineage and Ask Agent frames do not define a sign-in
+screen. This repair therefore makes no sign-in Figma parity claim.
+
+This migration supplies a recovery action for the existing login journey.
+Product-owned login, enrollment, and account recovery forms still require the
+released Keyverse contracts specified by the product goal; a retry button
+does not establish those contracts.
+
## Consequences
- Calendar names the missing Naruon projection and the next action in one
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index b5d31877b..ed0338a4e 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -1,5 +1,75 @@
# Product & Technical Gap Baseline
+## 2026-09-07: Recover interrupted sign-in without losing the destination
+
+This dated observation supersedes earlier loop summaries for this scope only.
+Protected `main` was `83eba56149eb802cd63642c507c324c9976ec78e` at inspection.
+The repair is proposed on `codex/signin-recovery-20260907`; the original
+translation-ledger checkout and its uncommitted work were left intact.
+
+**Observed failure.** In an isolated browser session, an unavailable loopback
+issuer reduced the entire login screen to `Failed to fetch`, with no action
+to recover. The other incomplete-session branch exposed credential plumbing.
+The shared return-path helper also retained authorization error parameters
+and accepted malformed paths that browser URL parsing interpreted as another
+authority. This helper serves callback restoration, login, and shared post
+links, so the correction belongs at that common boundary (ADR 0109).
+
+**Repair and KPI.** Test revision `fb4d11435` reproduced six failing URL cases
+out of twelve and two failing App recovery cases out of four. The first
+combined baseline encountered a worker-start timeout; a separate App run with
+the installed thread pool reproduced both failures. Implementation
+`4f17693b9` passes all sixteen focused cases: failure count **8 → 0**.
+It reuses ADR 0220's retry notice, existing translated copy, and the real OIDC
+redirect. A retry consumes and re-saves the sanitized destination; it does
+not replace a remembered post with a failed callback. Loading retains its
+single live region. No new dependency, issuer, or translation catalog is added.
+
+```sh
+corepack pnpm --dir frontend exec vitest run src/App.test.tsx src/oidcReturnUrl.test.ts --pool=threads --maxWorkers=1 -t 'App, unauthenticated|OIDC return URL|stripOidcCallbackParams'
+```
+
+**Browser and design evidence.** The local frontend ran on port 15274 with
+`VITE_KEYVERSE_ISSUER=http://127.0.0.1:15999` intentionally unavailable. No
+credentials or source records were supplied. After the correction, the Korean
+retry notice remained available after repeated failures; Tab reached Log in
+and Enter retried. At 390px, geometry inspection found the existing login card
+clipped eight pixels on each side despite no document-level horizontal
+overflow. Revision `5970c4270` includes padding and borders in the card width.
+At 320px, its bounds are 21–299px, with readable wrapping and no horizontal
+overflow. Desktop and narrow screenshots were inspected in the actual browser.
+The existing Figma page has no sign-in frame (ADR 0220 records the file/page
+IDs); no sign-in design-parity claim is made. `Chrome/StatusNotice/SignInRetry`
+records the recovery action; its three interaction steps passed in the built
+Storybook browser. Lint, production build, Storybook build, and five
+documentation checks passed. The expanded recovery checks pass in all five
+current locales, including keyboard activation. The three-file regression run
+at `221df2281` completed with **53 passed, 73 failed (126 total)**: the failures
+are in existing authenticated journeys, predominantly test deadlines, with
+additional element-lookup failures. The run took 938 seconds; concurrent host
+load exceeded 60 and swap use exceeded 44 GB. Contention is a hypothesis, not
+proof that the failures are harmless. A four-case recheck passed three and
+retained one timeout. That remaining case also failed in a paired experiment
+using protected-main App and return-path source; temporary experiment files
+were removed. The repository skips Tests jobs for drafts and has no manual
+dispatch, so the ready-for-review event admits the normal checks. It does not
+establish merge readiness. Required checks must verify the authenticated
+journeys; do not raise their limits, skip them, or claim a full regression pass.
+
+**Remaining acceptance gaps.** This failure-path browser check does not prove
+successful authentication, deployed behavior, eight-locale database delivery,
+or the all-page p95 ≤20ms goal. None is claimed. Keyverse protected `main`
+`7d9151cd2da260e118020c938c7358e2ee75d541` had no published release or tag at
+inspection. It implements an authorization-code/PKCE flow and server-mediated
+signup enrollment, but no verified product-facing recovery contract. The
+adjacent [Keyverse #128](https://github.com/ContextualWisdomLab/keyverse/pull/128)
+remains a draft authentication migration; its fail-closed password endpoint
+does not fulfill product-owned forms.
+[Keyverse #100](https://github.com/ContextualWisdomLab/keyverse/pull/100) is the
+separate LineageWeave claims prerequisite. Complete and release those owner
+contracts before consumer adoption. The existing eight-locale translation
+ledger work remains separate from this recovery repair.
+
> Exact-head loop overlay: 2026-08-29 13:20 KST. Protected `main` is
> `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map
> explained leftover share, #775). Open ready PRs still lack independent
diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md
index f426285a6..d2baf415c 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -19,7 +19,7 @@ operator-facing control you can click before changing product CSS.
| `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` |
| `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/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. SignInRetry provides the Log in action after a failed sign-in. | `--badge-status-success-*`, `--badge-status-pending-*`, `--badge-status-danger-*`, `StatusNotice` |
| `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/App.css b/frontend/src/App.css
index 3e4c13599..cb15f6a6b 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -22,6 +22,7 @@
}
.login-card {
+ box-sizing: border-box;
width: 100%;
max-width: 420px;
padding: 2.5rem 2rem;
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 790c4da69..5414f3e07 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -3,7 +3,7 @@ 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 { setLocale, SUPPORTED_LOCALES, t } from "./i18n";
import { OIDC_RETURN_URL_STORAGE_KEY } from "./oidcReturnUrl";
const signinRedirect = vi.fn();
@@ -74,6 +74,36 @@ it("announces a lazy surface load failure with a recovery action", () => {
describe("App, unauthenticated", () => {
+ it.each(SUPPORTED_LOCALES)("offers a safe sign-in retry and retains the pre-callback destination (%s)", async (locale) => {
+ setLocale(locale);
+ const privateError = "synthetic-private-auth-response";
+ window.history.replaceState({}, "", `/?error=access_denied&error_description=${privateError}&state=stale`);
+ window.sessionStorage.setItem(OIDC_RETURN_URL_STORAGE_KEY, "/?post=remembered#evidence");
+ mockAuth = { ...mockAuth, error: new Error(privateError) };
+
+ render(
{t("Loading authentication state...")}
; } - if (auth.error) { - return{t(auth.error.message)}
; - } - - if (!auth.isAuthenticated) { + if (auth.error || !auth.isAuthenticated || !accessToken) { + const needsRetry = Boolean(auth.error || auth.isAuthenticated); + const signIn = () => { + const returnUrl = needsRetry ? restoreOidcReturnUrl(undefined) : returnUrlFromLocation(); + rememberOidcReturnUrl(returnUrl); + void auth.signinRedirect({ state: { returnUrl } }); + }; return (Marketing & Operational Lineage Intelligence
{t("Authenticated, but no access token was returned.")}
; - } - return (