From 3d6ae1dd9fcd6b095e5fceb70ed1b476ecd9bd58 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 5 Sep 2026 09:35:48 +0900 Subject: [PATCH 01/41] 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 zcmeIacU)6x*DmagW2KE1q>iJ)AXSkXLS{rjL_kD9YD5GCqy`9~#ThAs2q;ygDTpYL z&{Zf{kkhU$ zpEpcz{vLJbz43`3&Qu&Y{!}%+|9%iS{F(dtJK%7`dYv~Ob1cn z?A$`e=`$Ir>FIL&k0dUu{_3W=iAhiAH;>Ge({ z-POIuer_-@%cok;Ubq~6D0=xY@Xh)}hx{*{WjEh{C{*{_4U!aUYJZGCeEH+hKfe`d zKCcO<9uJ><-~9Up2-3oOBl!p5vR^NJyR7V#3Vr$b?cpPd{g=L&-Xfmdzf;0nV-Jsw z^F5>JP_CkKsV)2Eho&--2b@b_Hwm%xk(exD#uX$gQ}pZSo3U>@UMi zvz$4cIB8pD)X3(&6>`8tow;6@&yXjh zac31XBYYvINqwt1mQdycnyaP>gWh{E=F27#BN|7f*N0uqpVW>>TYgPc=)br4$w zDUo|D0Sq~K^owZecw<-qtBew{HhX1enDnp;E)$lopF{^q`rprR@{(H!2ZlUJ|AUGgnrgTwPy6PO9>VRL5Z_FWPrpLRsx7JnyAkx}uXV za{L8GCM0OQ`!_dm&_2IHQjUDnzf)j!R|;8 zv_)vHzB4*S)O)h|&D93oUoj(Hl&uBQZQ3eU-5_elG=;X2BARHm@FCm<}zL;vUB>6mfss_ zcP*TFb6&oJqpoea2lsrjJ<=#^n(8uFWFmN}zi_1As(9&A;j+m!V-wKSWEDnd{i)uB50m@n3F;+7(vEnZcmL3e$z|_9Liq%*uGl$PP5TelK*TJ5TSz7^t|zhjh8d zIHeF)>jlNWufvr-=@v;LB-uy>-ru~`LaCB7mxhGj2=SZa#af#Q!n*S`BDxaxE@cdS zGD@0#rk#?m$IXQ)-!$e{{1{z?QT#r)IPsz}sW zltJ`5j z{ltndc#+hWyNK=9>9WTxH}OK_paY&mRxoSZxyd$}Vx5YxHKu*FH*5-l{G?OiI#Da& z)^$s-I|mG6y=4|lwNQ#EVMY_mj+YlW^eUAWiu3%B4lSX|uR%o-!I*!}^2&wF2Jy%3 z;fw2A5Hxamd?iLiDxFkp09Gq{|NaT=+i#2y<}!L)sOlG6(h;G^^{uJ_ybQ#3wa(sL zkD1e!ca%~6WJIH4OXHmHd`}OmK8kt7yW(+ki>Oai5UESE1Xr9d?X^Aez`}<WwKmrLBZK6t-@52gs1rSciYFl1smIk+vhz?y$E`%%5$e5m8DztH@b-+cBvr99Q zHqmPwu=8@As}7lIIzq_4KtZg@7)gVuC1oI{!-Xo_DjRy8LTX}$Pb1iuj@uvFx=i1F z8^3C33ZD7il1S_b^}+Rhc7zM5mDX7B z)!E)!K(riCyZUBs_Fh=%rsd_7wnRr{d{@T8ob_5GkFK96v%OvbL`7(D{Mm+_!0Q9^ zWA-xDe9k#F>4xd$>HPd{5>`5dZtL7l6NWtc+Ip;v1cO$suI`7l(qC`=T<8%z?404W z+gYQ@nfd6F^}f)%wkH?7w|z`)Eo|6Bg&fXqTyPw9FZqo$Y>yd7{R#}|sbO+|uY|K% zo<@~vWMuw^lDwke?>`mTRu!L8=pcGE{#sD_em4Dl(OFT^oX8!i_6cvrA_IZ5ZkQ() zH8)Z-($Ahb>sGV-<&xZ&c+nzvx8d-m796hAt?SMEB0etW%o*qT&eGETnJd3OKQ|k= zjPrb!+yAHLIWDre=ER_#=7&Tn_=xR7%uWAq1`FyReSYY&uPN2Iq^^XrBONUcO24JP zAXxIw2l#={$kC3lLv5Q1mw(9yBLdiH$1FDm0pN=t8!1C)B#A143FwurzxGsqiTH~B zJ>aVYJeU7N2^Z?d?*$LxtkAHr0W7ph=TvsayJOKV+!Wk{uBxyDW;)~i|h|cmk zh7X;`N*b;EHU}9X+#YnvM z0|g*%ls^4>TyiOM3GjA7Ac)U z@n(Ytn$+fY{lc0yaY-CHpC zD)RB}fcpgM>Kj*C8Q-wc^J!|SX{ooRKt9dc^$ zIF|-d?j}N2!_2f8x-KnI2K54-X#IiJ)nF3Vn)e#ywJlQ6B;QFa(O#JRG|&tPsq-yBPbWMHc}>hJTs<5>G&vI`CU7l_ZBXSXzo|xSjI)lMrt*2f-j5x3Q0O zY%>NcMFXEh=c= z7Ourqw<7h)lL*bckXt`ravg4!IZVU%bR%^%ZsQm)szkIFN~I;Xg!Jh~%8mIEBWQtz zPVeD-mW7SOo1uJzIT+#2HjkkqEOK`sD{4+MxINl&n11fCuZUB?QtA~K`)Ut0*+guqAiJCD)S+U zwLY`n3Rv}AR~FtgiEiW+>~Fl0EXND>7Q={yty71F>Gc$7Z=wU_^~r(A>6rQ0nYT$h!d9Et&yYCou$!eQkEMF4fQu1DEwVs(1<`bHZ{%BjQIM7 ztEn~H!m!M`_9@)j)_>z*hQog1US(H%KRXY(Wt9>A+G-`8HRQe92kVIihx8s=kVS@X zZI%p^o}_f`kl4(IHST)7=tMAG7d~G7UBfDFBAF(&SGD3OC0QL(dU1j;p$9)%8 zjietBp}j|O5w+*8ak?s`B(E8&Cp{-`U$XFh*X>UQ2f3LMrsWK&D)uSZ8mE!8w-+@gM# zC?}d7gj3WjRwETOCBs_$H65~EWm>={h9_?Uwf57m8o+}GEm?L1*-Z8Tib>q>}t?*J9T4GL~n1C zscF{0PR!OQiAw2&=Wb-6S5N~!HAB+a*-hRv_~?A^vliVXD1vFCNqM6vKrLk-({!YG zbg2ipcQtMQ=~4^kywu164TY-!>43o6^kkd#t)5OncN|`NJGcvRwmTO#{A?%cNDs+x zz`YD7e)yw+ThFnAR(%y6UldS_+#(~Jf#PhhETTs%=o^ZU^UDD5fEiopmF;3|l%Ho~ zDUS1kz1HC~=J|DZLsJU5dG^R^BUN}JA$)=RXiALHS46W}8GpxKrXa8A3NnxIsozou z2Ssub^e(o{@!t#PFGqWWXzaBl>~f{g3i{OH^%2S7S$JMVJ^5`{OPOI4KT<3{*vYHQ z&yJU#!sj#(^auedeTN*6jb^t{7WB3sc-oB@sWfi(8f4_KdK=~C<>ST&@j`?>{>osL zNxI^(ET5%IVg+rI;m!5VQGAw{Z+2*p)On}bo}Nb6Z}L(jt9`z)w39*hsSWEbMKS^_ zmO&3uZN?}CDATq^TChgiG1e)(xpBd1w9$9X@2%{z9#*<(_W8@v!+h$ZYL5bjV$qYM z=Sli9rer7U35wK1&ed!{6SY=&VJ*aHm!;Yia{+sK3yfc?4|3MI-E5=xQbr_)6W<`D z-Gq)FWI7eo>t3xj)GZAfih3ykVGWvz4IYL;79 z=GDULM@vR(^QKvS@ColoG42E8$Xcb^bmr(W+ZwAZ_;)m0p1Emv9idkecgvcJ_gYU7$aV$36m827 z(R1*nnqd0s7&Ck~pXV*sy^dL`UasrW6G_4&_yiz@9>oQflt=9ORGEh0ogZr5ygvT= z&RY604RTPCXV3+UHvhZ)j*%eM!Jt4ZtChYQpq4`n6!{{il3ds*VYKf{=!&u#uXuh# zjk;2%IqEa<<4@^&6%h?WwG>&4vaap%3xf%`dYHb>F%SgT6?*pWO4uiCwcnJfG+2jK zQDCvwMDbR^~TNpl1!Ap2Y|M0_CqHhyChkfg|EdGL7TYUEH!sD zhUFHPs|#Qws~wbW3ZiZE#us>^FxOhC(MnQNMTWGzr!WnWiOP&P}< z_p-~*;1AY}m`iF`Mq~`VsonJtB;l-xHG5162CuCVLw?A~fYO4N^8mc!)JCh-$XUhv zy&p`94yn0cS}xyOe%(N4&!*Yf!53eWDqWjfqSNrj*h_?4^VYUEL#$sj8s+l#BQpf8 zzhZp|fv9nsNWP!wEfzyBAR~OaN*XdOG8?e;OSdzDy>I;B5f1Kq+PEnv^j^M`M`otF z+;C|2Aa+wJ<4dZmiX*Z4PUZVLbU{h6T@rfooSIry<>e&uYC5Xl%YNv+9o+wJ*Nj}L zeJq`H+5O`=hho+q4GwLhCH{Ddkd?DOGy*t?MbCB52o&7CeLn`$E$qYJC_-=T=5%a zy$DymHIw0l;BZXC8BvIiv7!YRSq;_1dkrDF)bZ?9 zqLRocWcAha%}rkjTNkK2RU3^%_wp~JJpG&d!;mo-7-K8^`Ze34=$EG>CHFoPfPE6> zejbQxo_j)M`i!WPutf1n=t}kD0omK1);{}%S?tGTE?h7FE}q?uUmVDAIwoRQ?Jd!L zS+k4vWo$`vEo{AB!soJryhr)~&Xui7=&yLZ=6yKY*&Zk`oE3N4<5eh;9hvI*N@oCU zkmAjk&wkl%R zZYF;*@jZ5Bd&ayt@=$wgSrcy$1#kHi{x#~Dj2|}>o>NmsG@7G~Y=Vi_0IIMvH^DpObSW_1X05ISot1WR znf#?@Jg^Kr-;>S!T&aYFwXXrY|M-Xipg@z9Xd_KkZhTthx<`jL1E^M`r9ysf{v#Tb8#tbP$$%QZ z`7XoZCFlCf;?O6(@C=AFV|ECLaNh(7ohV_w2TZaFK4JQ0e0Lbf7l~i~e4&GA0%G1T z7}t#86ayiWJ^w5Ttv&m(G&_56aPVLcs9`us_1>wNCL<^Rr7Eg7dFO%F3fX-{39hyL z=WJQ6jvxM=2>9e-MjM}j?O0IY0k0tBA4F*DQBqtlMUv?TGV&$ z0Ug3dGOYE&3@CQzchf9)As|%%xP#)LLplKb^H~|hW7MVetEml-JxZzB^WJ56(XMH| zNXXl3V=kRLcMwjhrwC8jL_7>(s@`s7)ur4rd0u5VyXxQZvO`1?^;!r%O0Q7hrQ=RK z=`Ao{Bbn(?m%Pe)@*t2R*OHt>uq>h|8E{a|)C@0SaIr`5_O_Aac1b*nx$p5AioAON zL7++q3KSYew7L7%EhU4u>lf?gR&=mHu0PqPj9>0JC~QXjHcH!tM6kz1gHV2s4RzK4 z7NNt7!6$|C35bCY(MXl5qT4K>uslVi`wYNKe#7L3d)kG5Jv`x^gZu8*P2YyRKu)|S z>FjvdWhu}$IiUB2E8Ojq*ZH8vjN+ExF+A&M*Xf^Dp+Qn&R+#=jY+-s~bCh>;msC7} zns-;I@ZPoc<&C8Qx7zj|^J`6C5G?2SqKEl4yxO{tzgSqj!sR#J4N(CA%QthJ4(`$8 zn`hnBdyI{_UxpN@f-n!`_Tx!IgnDj%OG`1-y&*gO^w~$(ysf=m|M2@BKOZKtru-r$ zL+gPVnib0E>LudX)7nl~&Y zzEQXXdx;0|1VcANNBHGWpU^Y!d1+F=)xsv`Y`I1QovEyo0WIbkYQ5*IkC$we#9jiRG+cJJS&of&ucbBlbMu;$SS+3~nnF$G`N&C+gq8euEPWz> z@(?C>YhkzqJ?@d9E}L|&>Tx8!&j#98YqG%aygqN1v)3+A7S5W_k|PDUtRzb96AXCu z-=DpX`nYWA@SwhjzK<0l?DKB_JGs-o2VV>fn4H?`y7xeS)oi{@alm((C^}^W z_nku87aU0h%mkhjL|-*dS;_O>?RCavhuoUR&;AoL&rtcb?hl$3G+r4kL60 zach76MaBGD>v7#VQF9d)5rLc665Y`a8)-bCcK34NaxZq5Q!<>)ILc4zxV2$|+V67D z)q|Zpuy$(m(&a^_09-^g_&~i#(IaAQ-neK6oN*bFqTi`SL$uzTc`N zgH(XI_dF;O z5CGvyiwAN={DbXm0C7BbN>;JM@y>`&)Ij|yymT4_f?00$>kk34jRo^Oi=b%$e!G>f zXbaFf@B3VCWt@(ccNRj5Y42AK0WlxO#%S0e)Iy<+ZH+#|%8pytV)DG`g_I^cw!K{h z1rTV^mks3rpt??Pp-^f2aNunb01g~<81ghXHWL(SdF%%ub3>Y9!>Ugz;-Cotd56lD zCOf{?Ry?oUxT%v^zW&Lj&1yo*3gaH4>cZJwPwiN8rcy6mMt0_CSACpXXgqb+rnnE7 ziht+=)DfMdhhX!=4iDz$Z~3g-o|22s5Vo&d)}<~vKkO=s)l7Dv0W@ig!mt{$O&Z*< zmL)4Aw_~G)Jz4X~OR7cVSXTAsz_l9s8ytesQei_2cwL_C>|rG`Guw33BDqX1#yN4INxP$jpz!Vw|*M0D|u3ptC@#G@aD%p6bP*o!P#}>2R`BFz)Qn#?b0lfc%Sy^FR|H3 zvmt|Mi)I3{@#hOAwqlO&KYWmeG1I&o0;R94(#j~2a@mA^^seAf^0UJ0!7tBAR}Ke%hGs|kpl-)lc)yJra9G7;5m22ma=smipW2`DKd_CEu=*78- zeA6tJnp>22B{g^gz%o(G=&|LeTc=akrot1Uvlj&UonnGQ?pfTZ3IG^@FRFI|*t{MTw>NHrp^O1L5|DntmxdVYCMw;J$E(o^ zs`G>-0-o#V@L#dvxk+LT+F0u$UVPiy*29CEZxEIn_^>BIv$+11M0L#hcVu|?Au3)i zZu5E5gg3X3p&|uio+)A4v144!cNtQc8fb(EDoZP1&;-L<>13}7=S=_3P_b2on;k_^ zr?eMwuw;H5duz@HilsrR_=E9`%@#N0LY$mR#8N$0SPa;eF#UDEyX;Enk4%IQsAexV z(1r;V$u}H4zul!RfxT4)lL`CfMLXfxa#?Jnqc*=E>)#dRToO_{-4PJgr*nXe#UuMXII217U5LSO)R#FKdpROi%SNzjE!fR>CT~IWkAgC ziI&i&@QEB5$j-x~l2$gKcp1{$y#-mcs*&L%Qa_rB<0E&t=dS@=@bB`U9cIr#W^-V2iF37INOQQQT=$wHGhV0}F2!~?15}T62pI}s zNLKeTKz+DdXzx=t_M%vv#bck(iJFT_nZ;yt7DRF*eR{uR4`r9B@(RSu(v%`Z8Q(9*K+_i(7$eEb?# z1pxOuoo87cQt(*Q)AOZBDW^sTdb;JyLacZ4s|~IwR4-)1)l1~Q&P_Dd%c+T(xV}@L z1QttdH;0DKy^B`{G2VqS=DP%3n0Jgrw`i8tAE#<9%>)iEJt`L1P?i)sFPI>((ul{aPA`Uh0(u?9d_9fg~%o&Axo2<#?17DRfBK`H+te{e8_5!Npz|=6cE2 zhc2@Q_a*t{HSk?X<}lA(7(;@Zk^dxcrPrW${~ z_j_W_=CKRV*wP8?O4Pl;E@z|2ow1A+q4Cn;gdcTUW^B0J-uP~NuV4A)h&B9FvZyb2 zq{%%Sn;lk3`y%mp}#&$6js%We)W2y^yJTcN>Y3LsX9EM+pRM zA*LDm^Q)^aoT%BYM6W=RxbwP?N$(;Sa=j8|^zxUhT6%r(YUaczZ_XG>6<#udG+gl= zf1%p{S~FrV{fP9-Z1<99A|Llsg=)uiI8reUIU^1*0HgzUn{c4qs*lY%J??Ibd9UyJT&IuTKA+|j%9>MO;yy^|m zx{jf!98g%WR-jx0=RtuJEI9eQyA>dr8283q7zfV7H+Ep+AR)Sxkz zJY~E!&xRaHpi?_9fI9 zk|4zo3H*4*Iql6gkA1kiwNhu`Q=51@a?QCU29udxpL-C{Fz%XOn?556q{ioB{_q8F z-QSeWpgx)(@oj8q&uyr&_pb3f%Ox!2A{Q#;0O^fuY=OvHahJ9fv&UP4g@kKWwU!|zMQq4X`O5D!qt%a@ zf7;xtWq3{Ae`f_?SUu&d1wW)=q(55Ku=e@ypjy8VSY3;aF_SV~Mv?^KF>EKr{C-W? zPHTTwAZHg5tNOa<@f%yGJq1d+J)eWzWmCuzoc1FsP1U7!k5a&J%J#Z`vG5%)w0`W6 zO;3(`$+G=T;!ShCkCW=o4S_!QZhjLm}c*>G-M1X?X)YDRnvRVH@FhZ-xZgdGZTJ1sjqdt zxQYJ97_6l)t<=P7Xp$I&GIo?GzWZ#r{IRjKU1;c@`N}C?Rqw?Mjk(dW`TvTaL!?-M z{w!15ibs6Om^ov}lmx)%&%XI54z^|Z3JN_NZANNcWzEF)G_JEs%A!A?klq=6>$_{| zWVg2gOl#zvI#|<8#gwECjDE@5-kXq;wbERYUq_AiWfN?hSLcPicsOPO9 zjfv`U9HZF52f)Ouhox+UW_na#I^A=9R9E-FVs1to$jwNejwge}${A+Vo@tJO3MqiC zHbM%SHr2eCZf@`pfuFB6T`+6EAopT+AJ0~947Fz8v*-$1>_ttKwx>jrWi5-Wr8siw z-WWeIUu5gM6`u;*p(Pe<0lwPDP2$(-ROF|_lJ1>(etXE*!AaHKC)6#Jq&5=p&-LEy z_>-XA^mY^W>(Fhf9omr|2y(9xeyQwKJ>v!b;<7Ev$I(bItYouo9qSE(qe~^2YjfL< zHYG^Y<^H$otD}u!-y}~NCEjvEUU6=lT6ScOCdD9F>K?)YY?jW2ytQ}|9LKR+6w zFB7iSnWqV-OYG>?(Lb2PHkF{s-kXb82JjLx(n#hosD1#+tBsF?MsS(-Yxhz!dH{Vc z!9CZU>{j^UpWyv*-(p)cMmmjf*p}?<8N%KN(QVEYdyawSV*u5Waz$V%`3m?#)p*Gu zR=5#!?sTXvfbF+?2e9Schr?0K!`^3WjW;F0UnBNxL(=?aOM8eY0Q&g{7C?3O8hXES!q{&RG*u{4_ZiW`D3%Pa$nuGIA>!O5x3q0?1s2U8~qYi`*o2Wo>6%B zU-*Hu_I0a^c1WU3(*AaY$I1qI^ooCQbzAk@I+5fQJOt<%*nlS>R$jF!l+lj!e~1C} zTv2@hT9wD$f)=GdZv`sl=arRy2XO3G%l0@bJ_yK0g8*70rU=4Kl8}v5zpCAcKIk?M zr{E)dA^-+^ijS&5Pwmb>6UkEVaRQni(k!pz@4FC$T7Gh#&=+C-uNXH7)e$s>vZ49) zvXJ%se0=a?Q|PxJ+vCnhuSWK7sE`Bg#i*tI6)phx*mZx~e+OFA*ZmC7U1bn=z*+^i z1KuK__nZ1ZAAyF@T{56SbKe~)a(%QK%*ku=juIh=jQi&8gJua~GB*|3r99aNmBzrr z5m^c3_Oh3aOn9raD0DPnOnwgPI8$VfAyHApJ#_GaQ?CJ<8+ zd&qtRm%>(IeK2z$*y3-_A;H9BMXBkhV+WYA&`AGy4e#PEO5@I+0qao4TG#_IZ*L0KfldJx|ore-+f~nxPBl zngTKvb1fPLKx`$}YE8f;qK7+Ie41F%gY@^0S2Q*>wMp@)NqfZ=RDN)%w)O= z?vLCCacMKYzr47pU;Z()EUL9~;?=#ZO~&P+iv2L>R)R1}3cVK*IywWuMK_~MLYvA>7M(qe5 zz%ZS)qbC+XOh{vl=ssvcj(PeB0U4}}rixsV<${&OR_a&oo6*py(%!e&SNDTG0}m}5 z-mwvQyr}ANXi(=k%Kfy>NU?R;t!K-IHAS%%D`ia^TjM^Hubp;Ck{PfF>s?9a=14+o zrR7}QVYL3JEOR7bR#$xm@R7uPKrbx2>0e`V)z2e^Iyiu5LK*Wcf!KFYdsLg?RDl5nQBUt6~+lAQ8eDF%E#m(H-OfOQXpLi z#G1Fo)!cx>jsHbZU84aD7z;j*KYm8CG|G*ystw;R_0*i7<*Q`R=5e|J)_&q*^DWAIsAVZAhYWnWX-d)%S<{9g@13K?gP zN-8JpcQF0G?c>TwfBc9MpD~-BuBfb_q@-fr`je7+jcrP`Re!&|`=h^le8jH#Oue>q zNCf~ZOSUr5g0{4@6epINntJ;5>CP;0QTfM@FvEerRM-7pt)EXU8yc=ym!N9xe!hIZ zwc5^YbGF@5bUw%!F16o=R=O&_A}tBr^Zg@t0~^iai$x%>6%)>Ge< z6dVgpvu@0eS9@vVt1kkhK2SO-4ygR&O-w4Qs!qKkc68V|*u4SHAF%cTB+s{qM3?%K z$Kb11uL6JW_h5bQKvn4F@Biew%ztgCQXd9uS^sKlI~FbKaF^v`Xc#(C>kX$wjQEtm zOZagEA57OK>n-=M+=bQz97H8TiEw{Ci~#M)zs`tbs<+^kG5B6LSZ!F~^1R^`@3uN@Z;qgVL;H4c(( zoL+u%HIQ7mW;j_mIXT_0+Z}Vy6|fZ6*?qdcljSQ2_V|{kIr?_(oO^C?n-qMlafIjc zHDMC5AVop2Nu{Nw`oJcNV61X*9BtS(> zT4CGS&Cf9Az`Pv5gMcCa^2;xEo6k#9k}#8=y%&A`lfZkspNet<`l6BEB=Yxy%t0z0rs9UD%yP9tWn<`T7c@5s+-+W`t?uHKzab*^dI|wlfODD4=nHfVwMI zDqyxHT7b~XlVA!U@th9EfE!0Ow9|(2LfU`HRa7pWR8@e(;g$!oG`!w~{}>sxOO5T$ z(&WDw=sC`i3|(yA@7bK%dF%>w&FP!zI1`mHuXPa}W9~Vkk&_-p&D1r#V^CMDOnCTY zo|yx(QOGD^Pqv~u=25h80I>@bjZZPZZOUbtN(JwpKM&JocQTahYbS#+Ie&Frt@U+C zXkCB3J0gW~b9Dv#@t>^$eRjYq!JDHOA~wFPb9X?Gr(0#vEVk9bBR-OD6a1%KfL-g> zVPwlh?!KG9Zf%buYE1Y@Nv~q~dYPG-|9ISA3O8j%i5`2^nckZWWKiu1fdvssE!7Qs z1r?P%b+6#I#1iYA{dJ%3s?tCHi77wft+q=Lfk^KSuVi`G8s}5>ShFcYcIf2&iRD@T z&)$s6O{Zpy5wu>aSZE0i6OjO-z;K<&AHk>*}D z%b~>KbD8o61}SppW;eY0-y452_=4(J`)8av0YUa@7c31%BWBu?e1Z63ZEbC6C|UTn zuCA`U+>f5E{mJAx=%2wf&1r`GAm1d^jXs@!4s!dlqLRFlf=TO7DC3mU>aG2#TUZ~w z&qh!8F2Z}C}7Zu)O<^2GpM(#SX^A z#sY1rTh-P5_eLW2FG=|MQS6U0Cxr&rKflVfmHlfENpSI)`qp{#+MQ>X24?Z|(WY7Z zG5>&g^YjN(nBD9}@sB{uE6`hKRdN>4BJZ2SQ_8)g!YF&pZI4|NM1qr<{*wZzB)?j* z42LtYu-KpFDa^}}QrlqS*{;erm&BC|^YeeMxPDetxx348wDjXID4Z_G*s*VJ|33UG z;E``xS`zPC3S?;jZa?>YD4~@v=l27(o`s0N^*lZGKl_4%#zbpBXm)ja^o1YN%e=e9 zKeOLcdcgW{)A||U_;1wN{C`FJ_1_Qq|L~&!mMICdzrOw-Sit|G{;~g-asEBF|GgTc z{Vnj{;k6Eb8`r-_T>0DI{`R+jn|HnZ9Tff!3jc2JANt!S|F+4$ZSvp#HDQ0d>ECYp z{}(qM*}_EwWn9bty956(uI0Egz##bi=JY}n;E%)mqd9m$U$+Knf~`FVoH<}@V0Ht2 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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 From 07e130ae2bea94f97c7d9949229115b0b295ce64 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 11:24:33 +0900 Subject: [PATCH 22/41] docs(i18n): keep stale screenshots out of acceptance --- ...uct-technical-gap-baseline-history-2026-09-07-pr932.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/product-technical-gap-baseline-history-2026-09-07-pr932.md b/docs/product-technical-gap-baseline-history-2026-09-07-pr932.md index 816fe8fc1..660308907 100644 --- a/docs/product-technical-gap-baseline-history-2026-09-07-pr932.md +++ b/docs/product-technical-gap-baseline-history-2026-09-07-pr932.md @@ -309,7 +309,9 @@ review, screen-reader acceptance, all-page latency, protected merge, or release. `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`; current-head - synthetic visual evidence is - `docs/screenshots/customer-master-translation-gate-{desktop,mobile}.png`. + `tests/test_customer_master_translation_auth_gate_contract.py`. The checked-in + desktop/mobile images predate the later cause-neutral recovery-copy repair and + are historical predecessor evidence, not current-head visual acceptance. + Fresh screenshots must be captured from the unchanged candidate head before + this UI condition can be accepted. - Historical delivery/gap overlays: `docs/product-technical-gap-baseline-history-2026-09-04.md`. From 798a930ad147189478fe3b6b772cd4ff37592cb1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 14:52:54 +0900 Subject: [PATCH 23/41] test(i18n): require Customer Master ancestry screen keys --- ...rMasterAncestryTranslationContract.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 frontend/src/customerMasterAncestryTranslationContract.test.ts diff --git a/frontend/src/customerMasterAncestryTranslationContract.test.ts b/frontend/src/customerMasterAncestryTranslationContract.test.ts new file mode 100644 index 000000000..66b452960 --- /dev/null +++ b/frontend/src/customerMasterAncestryTranslationContract.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { CUSTOMER_MASTER_TRANSLATION_KEYS, setCustomerMasterTranslations } from "./i18n"; + +const ANCESTRY_SCREEN_KEYS = [ + "Shown as top level: listed parent forms a cycle.", + "Shown as top level: entity lists itself as parent.", + "Shown as top level: listed parent is not visible.", +] as const; + +describe("Customer Master ancestry translation contract", () => { + it("requires every malformed-hierarchy message in the authenticated screen projection", () => { + for (const key of ANCESTRY_SCREEN_KEYS) { + expect(CUSTOMER_MASTER_TRANSLATION_KEYS).toContain(key); + } + }); + + it("rejects a nominally complete projection when one ancestry message is missing", () => { + const translations = Object.fromEntries( + [...CUSTOMER_MASTER_TRANSLATION_KEYS, ...ANCESTRY_SCREEN_KEYS].map((key) => [key, `published:${key}`]), + ); + delete translations[ANCESTRY_SCREEN_KEYS[0]]; + + expect(() => setCustomerMasterTranslations(translations)).toThrow( + "Incomplete Customer Master translation", + ); + }); +}); From 6986098867d220798d8d4076d1a5e1a89cbb0c73 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 16:54:26 +0900 Subject: [PATCH 24/41] test(customer-master): reproduce same-auth loading supersession --- ...omerMasterRelatedLoadingOwnership.test.tsx | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 frontend/src/customerMasterRelatedLoadingOwnership.test.tsx diff --git a/frontend/src/customerMasterRelatedLoadingOwnership.test.tsx b/frontend/src/customerMasterRelatedLoadingOwnership.test.tsx new file mode 100644 index 000000000..4870058f7 --- /dev/null +++ b/frontend/src/customerMasterRelatedLoadingOwnership.test.tsx @@ -0,0 +1,135 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, expect, it, vi } from "vitest"; +import App from "./App"; +import { fetchRelatedEntity } from "./api"; + +let mockAuth: Record; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => mockAuth, +})); + +vi.mock("./api", async (importOriginal) => { + const actual = await importOriginal(); + const { CUSTOMER_MASTER_TRANSLATION_KEYS } = await import("./i18n"); + return { + ...actual, + fetchTenantConfig: vi.fn(async () => ({ brandName: "LineageWeave" })), + fetchMe: vi.fn(async () => ({ + user_account_id: "acct-1", + display_name: "Demo Analyst", + permission_codes: ["post_read"], + corporate_entities: [ + { corporate_entity_id: "corp-a", entity_name: "Alpha Corp" }, + { corporate_entity_id: "corp-b", entity_name: "Beta Corp" }, + ], + preferred_locale: "en", + })), + fetchPosts: vi.fn(async () => ({ + posts: [], + total_count: 0, + limit: 50, + offset: 0, + voc_type_options: [], + voice_type_catalog: [], + visibility_options: [], + })), + fetchRankings: vi.fn(async () => ({ + port: "rankweave", + status: "unavailable", + status_reason: "rankweave_not_available", + rankings: [], + })), + fetchTranslationScreen: vi.fn(async (_token: string, screenKey: string, locale: string) => ({ + screen_key: screenKey, + resource_version: 1, + locale, + translations: Object.fromEntries(CUSTOMER_MASTER_TRANSLATION_KEYS.map((key) => [key, key])), + })), + fetchCustomerMaster: vi.fn(async () => ({ + corporate_entities: [ + { + corporate_entity_id: "corp-a", + corporate_entity_code: "ALPHA", + entity_name: "Alpha Corp", + entity_level_code: "company", + entity_level_label: "Company", + parent_entity_id: null, + }, + { + corporate_entity_id: "corp-b", + corporate_entity_code: "BETA", + entity_name: "Beta Corp", + entity_level_code: "company", + entity_level_label: "Company", + parent_entity_id: null, + }, + ], + keymen: [], + source_customer_hints: [], + source_author_hints: [], + relationship_network: [], + })), + fetchRelatedEntity: vi.fn(), + }; +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolver) => { + resolve = resolver; + }); + return { promise, resolve }; +} + +beforeEach(() => { + mockAuth = { + isLoading: false, + isAuthenticated: true, + error: undefined, + user: { + access_token: "test-access-token", + profile: { preferred_username: "demo.analyst" }, + }, + signinRedirect: vi.fn(), + signoutRedirect: vi.fn(), + }; + vi.mocked(fetchRelatedEntity).mockReset(); +}); + +it("keeps entity B loading when an older same-auth entity A lookup settles first", async () => { + const user = userEvent.setup(); + const alpha = deferred>>(); + const beta = deferred>>(); + vi.mocked(fetchRelatedEntity).mockImplementation((_token, entityId) => { + if (entityId === "corp-a") return alpha.promise; + if (entityId === "corp-b") return beta.promise; + throw new Error(`unexpected entity ${entityId}`); + }); + + render(); + await user.click( + await screen.findByRole("button", { name: /^(?:Customer master|고객 마스터)$/ }), + ); + await user.click(await screen.findByRole("button", { name: /Alpha Corp/ })); + await waitFor(() => expect(fetchRelatedEntity).toHaveBeenCalledWith("test-access-token", "corp-a")); + + await user.click(await screen.findByRole("button", { name: /Beta Corp/ })); + await waitFor(() => expect(fetchRelatedEntity).toHaveBeenCalledWith("test-access-token", "corp-b")); + expect(screen.getByRole("status")).toHaveTextContent("Loading related posts..."); + + await act(async () => { + alpha.resolve({ corporate_entity_id: "corp-a", entity_name: "Alpha Corp", related: [] }); + await alpha.promise; + }); + + expect(screen.getByRole("status")).toHaveTextContent("Loading related posts..."); + + await act(async () => { + beta.resolve({ corporate_entity_id: "corp-b", entity_name: "Beta Corp", related: [] }); + await beta.promise; + }); + + await waitFor(() => expect(screen.queryByRole("status")).not.toBeInTheDocument()); +}); From edfb71dd3dccd5af094a4aee03ce9a4fd68575e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 17:51:25 +0900 Subject: [PATCH 25/41] test(customer-master): distinguish related lookup failure from empty --- ...customerMasterRelatedFailureState.test.tsx | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 frontend/src/customerMasterRelatedFailureState.test.tsx diff --git a/frontend/src/customerMasterRelatedFailureState.test.tsx b/frontend/src/customerMasterRelatedFailureState.test.tsx new file mode 100644 index 000000000..c9594eb93 --- /dev/null +++ b/frontend/src/customerMasterRelatedFailureState.test.tsx @@ -0,0 +1,116 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, expect, it, vi } from "vitest"; +import App from "./App"; +import { fetchRelatedEntity } from "./api"; +import { CUSTOMER_MASTER_TRANSLATION_KEYS } from "./i18n"; + +const RELATED_FAILURE_COPY = "This request failed. Retry the same action."; + +let mockAuth: Record; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => mockAuth, +})); + +vi.mock("./api", async (importOriginal) => { + const actual = await importOriginal(); + const { CUSTOMER_MASTER_TRANSLATION_KEYS: customerMasterKeys } = await import("./i18n"); + const translations = Object.fromEntries(customerMasterKeys.map((key) => [key, key])); + translations[RELATED_FAILURE_COPY] = RELATED_FAILURE_COPY; + translations.Retry = "Retry"; + return { + ...actual, + fetchTenantConfig: vi.fn(async () => ({ brandName: "LineageWeave" })), + fetchMe: vi.fn(async () => ({ + user_account_id: "acct-1", + display_name: "Demo Analyst", + permission_codes: ["post_read"], + corporate_entities: [{ corporate_entity_id: "corp-a", entity_name: "Alpha Corp" }], + preferred_locale: "en", + })), + fetchPosts: vi.fn(async () => ({ + posts: [], + total_count: 0, + limit: 50, + offset: 0, + voc_type_options: [], + voice_type_catalog: [], + visibility_options: [], + })), + fetchRankings: vi.fn(async () => ({ + port: "rankweave", + status: "unavailable", + status_reason: "rankweave_not_available", + rankings: [], + })), + fetchTranslationScreen: vi.fn(async (_token: string, screenKey: string, locale: string) => ({ + screen_key: screenKey, + resource_version: 1, + locale, + translations, + })), + fetchCustomerMaster: vi.fn(async () => ({ + corporate_entities: [ + { + corporate_entity_id: "corp-a", + corporate_entity_code: "ALPHA", + entity_name: "Alpha Corp", + entity_level_code: "company", + entity_level_label: "Company", + parent_entity_id: null, + }, + ], + keymen: [], + source_customer_hints: [], + source_author_hints: [], + relationship_network: [], + })), + fetchRelatedEntity: vi.fn(), + }; +}); + +beforeEach(() => { + mockAuth = { + isLoading: false, + isAuthenticated: true, + error: undefined, + user: { + access_token: "test-access-token", + profile: { preferred_username: "demo.analyst" }, + }, + signinRedirect: vi.fn(), + signoutRedirect: vi.fn(), + }; + vi.mocked(fetchRelatedEntity).mockReset(); +}); + +it("requires retryable related-request failure copy in the governed Customer Master screen", () => { + expect(CUSTOMER_MASTER_TRANSLATION_KEYS).toContain(RELATED_FAILURE_COPY); +}); + +it("shows a retryable error instead of successful empty evidence when a related lookup fails", async () => { + const user = userEvent.setup(); + vi.mocked(fetchRelatedEntity) + .mockRejectedValueOnce(new Error("related lookup unavailable")) + .mockResolvedValueOnce({ + corporate_entity_id: "corp-a", + entity_name: "Alpha Corp", + related: [], + }); + + render(); + await user.click( + await screen.findByRole("button", { name: /^(?:Customer master|고객 마스터)$/ }), + ); + await user.click(await screen.findByRole("button", { name: /Alpha Corp/ })); + + await waitFor(() => expect(fetchRelatedEntity).toHaveBeenCalledTimes(1)); + expect(await screen.findByText(RELATED_FAILURE_COPY)).toBeInTheDocument(); + expect(screen.queryByText("No linked posts yet.")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /^Retry$/ })); + await waitFor(() => expect(fetchRelatedEntity).toHaveBeenCalledTimes(2)); + expect(await screen.findByText("No linked posts yet.")).toBeInTheDocument(); + expect(screen.queryByText(RELATED_FAILURE_COPY)).not.toBeInTheDocument(); +}); From bdfcdd2c23df9d691f539edbfc85602610607ad4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 02:07:56 +0900 Subject: [PATCH 26/41] test(customer-master): make related failure RED executable --- frontend/src/customerMasterRelatedFailureState.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/customerMasterRelatedFailureState.test.tsx b/frontend/src/customerMasterRelatedFailureState.test.tsx index c9594eb93..ff07339a2 100644 --- a/frontend/src/customerMasterRelatedFailureState.test.tsx +++ b/frontend/src/customerMasterRelatedFailureState.test.tsx @@ -17,7 +17,7 @@ vi.mock("./api", async (importOriginal) => { const actual = await importOriginal(); const { CUSTOMER_MASTER_TRANSLATION_KEYS: customerMasterKeys } = await import("./i18n"); const translations = Object.fromEntries(customerMasterKeys.map((key) => [key, key])); - translations[RELATED_FAILURE_COPY] = RELATED_FAILURE_COPY; + translations["This request failed. Retry the same action."] = "This request failed. Retry the same action."; translations.Retry = "Retry"; return { ...actual, @@ -113,4 +113,4 @@ it("shows a retryable error instead of successful empty evidence when a related await waitFor(() => expect(fetchRelatedEntity).toHaveBeenCalledTimes(2)); expect(await screen.findByText("No linked posts yet.")).toBeInTheDocument(); expect(screen.queryByText(RELATED_FAILURE_COPY)).not.toBeInTheDocument(); -}); +}); \ No newline at end of file From 89e727ec948cc7c66b54bf70f02f9ff232ed7b40 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 02:49:10 +0900 Subject: [PATCH 27/41] test(#932): execute Customer Master request-state repair --- .../automation-932-customer-master-repair.yml | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 .github/workflows/automation-932-customer-master-repair.yml diff --git a/.github/workflows/automation-932-customer-master-repair.yml b/.github/workflows/automation-932-customer-master-repair.yml new file mode 100644 index 000000000..48edfd319 --- /dev/null +++ b/.github/workflows/automation-932-customer-master-repair.yml @@ -0,0 +1,287 @@ +name: automation-932-customer-master-repair + +on: + push: + branches: + - feat/i18n-ledger-frontend-consumer + paths: + - .github/workflows/automation-932-customer-master-repair.yml + +permissions: + contents: write + +concurrency: + group: automation-932-customer-master-repair + cancel-in-progress: false + +jobs: + repair: + runs-on: ubuntu-latest + steps: + - name: Checkout exact automation head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + fetch-depth: 2 + + - name: Verify single-writer parent + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD^)" = "bdfcdd2c23df9d691f539edbfc85602610607ad4" + test "$(git diff --name-only HEAD^ HEAD)" = ".github/workflows/automation-932-customer-master-repair.yml" + + - name: Apply minimal Customer Master causal repair + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + app_path = Path("frontend/src/App.tsx") + app = app_path.read_text() + + replacements = [] + + replacements.append(( + ''' const [relatedByEntity, setRelatedByEntity] = useState>({}); + const [relatedLoading, setRelatedLoading] = useState(null); + ''', + ''' const [relatedByEntity, setRelatedByEntity] = useState>({}); + const [relatedErrorByEntity, setRelatedErrorByEntity] = useState>({}); + const [relatedLoading, setRelatedLoading] = useState(null); + ''')) + + replacements.append(( + ''' setRelatedByEntity({}); + setExpandedEntityId(null); + ''', + ''' setRelatedByEntity({}); + setRelatedErrorByEntity({}); + setExpandedEntityId(null); + ''')) + + replacements.append(( + ''' async function toggleEntity(entityId: string) { + const requestAccessToken = accessToken; + const requestAuthGeneration = authGeneration.current; + if (expandedEntityId === entityId) { + setExpandedEntityId(null); + return; + } + setExpandedEntityId(entityId); + if (relatedByEntity[entityId]) return; + setRelatedLoading(entityId); + try { + const response = await fetchRelatedEntity(requestAccessToken, entityId); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); + } + } catch { + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + setRelatedByEntity((previous) => ({ ...previous, [entityId]: [] })); + } + } finally { + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) setRelatedLoading(null); + } + } + ''', + ''' async function loadRelatedEntity(entityId: string) { + const requestAccessToken = accessToken; + const requestAuthGeneration = authGeneration.current; + setRelatedLoading(entityId); + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); + try { + const response = await fetchRelatedEntity(requestAccessToken, entityId); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); + } + } catch { + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: true })); + } + } finally { + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + setRelatedLoading((owner) => (owner === entityId ? null : owner)); + } + } + } + + function toggleEntity(entityId: string) { + if (expandedEntityId === entityId) { + setExpandedEntityId(null); + return; + } + setExpandedEntityId(entityId); + if (Object.prototype.hasOwnProperty.call(relatedByEntity, entityId)) return; + void loadRelatedEntity(entityId); + } + + function retryRelatedEntity(entityId: string) { + void loadRelatedEntity(entityId); + } + ''')) + + replacements.append(( + ''' relatedByEntity={relatedByEntity} + relatedLoading={relatedLoading} + onToggle={toggleEntity} + onOpenPost={openPost} + ''', + ''' relatedByEntity={relatedByEntity} + relatedErrorByEntity={relatedErrorByEntity} + relatedLoading={relatedLoading} + onToggle={toggleEntity} + onRetry={retryRelatedEntity} + onOpenPost={openPost} + ''')) + + replacements.append(( + ''' relatedByEntity, + relatedLoading, + onToggle, + onOpenPost, + ''', + ''' relatedByEntity, + relatedErrorByEntity, + relatedLoading, + onToggle, + onRetry, + onOpenPost, + ''')) + + replacements.append(( + ''' relatedByEntity: Record; + relatedLoading: string | null; + onToggle: (entityId: string) => void; + onOpenPost: (postId: string) => void; + ''', + ''' relatedByEntity: Record; + relatedErrorByEntity: Record; + relatedLoading: string | null; + onToggle: (entityId: string) => void; + onRetry: (entityId: string) => void; + onOpenPost: (postId: string) => void; + ''')) + + replacements.append(( + ''' const relatedPosts = (relatedByEntity[entity.corporate_entity_id] ?? []).filter( + (related) => related.node_type_code === NODE_POST, + ); + ''', + ''' const hasRelatedResult = Object.prototype.hasOwnProperty.call(relatedByEntity, entity.corporate_entity_id); + const relatedError = relatedErrorByEntity[entity.corporate_entity_id] === true; + const relatedPosts = (relatedByEntity[entity.corporate_entity_id] ?? []).filter( + (related) => related.node_type_code === NODE_POST, + ); + ''')) + + replacements.append(( + ''' {relatedLoading === entity.corporate_entity_id ?

{t("Loading related posts...")}

: null} + {relatedLoading !== entity.corporate_entity_id && relatedPosts.length === 0 ? ( +

{t("No linked posts yet.")}

+ ) : null} + {relatedPosts.length > 0 ? ( + ''', + ''' {relatedError ? ( + onRetry(entity.corporate_entity_id)} + /> + ) : null} + {!relatedError && relatedLoading === entity.corporate_entity_id ? ( +

{t("Loading related posts...")}

+ ) : null} + {!relatedError && relatedLoading !== entity.corporate_entity_id && hasRelatedResult && relatedPosts.length === 0 ? ( +

{t("No linked posts yet.")}

+ ) : null} + {!relatedError && relatedPosts.length > 0 ? ( + ''')) + + replacements.append(( + ''' relatedByEntity={relatedByEntity} + relatedLoading={relatedLoading} + onToggle={onToggle} + onOpenPost={onOpenPost} + ''', + ''' relatedByEntity={relatedByEntity} + relatedErrorByEntity={relatedErrorByEntity} + relatedLoading={relatedLoading} + onToggle={onToggle} + onRetry={onRetry} + onOpenPost={onOpenPost} + ''')) + + for old, new in replacements: + count = app.count(old) + if count != 1: + raise SystemExit(f"App.tsx expected exactly one anchor, found {count}: {old[:80]!r}") + app = app.replace(old, new, 1) + app_path.write_text(app) + + i18n_path = Path("frontend/src/i18n.ts") + i18n = i18n_path.read_text() + old = ''' "Post body preview", "Related posts", "Relationship network", "Resolve", "Resolving...", + "Showing the first {shown} of {total} observed customer identifiers, ranked by post count.", + ''' + new = ''' "Post body preview", "Related posts", "Relationship network", "Resolve", "Resolving...", "Retry", "Retry needed", + "Shown as top level: listed parent forms a cycle.", + "Shown as top level: entity lists itself as parent.", + "Shown as top level: listed parent is not visible.", + "This request failed. Retry the same action.", + "Showing the first {shown} of {total} observed customer identifiers, ranked by post count.", + ''' + count = i18n.count(old) + if count != 1: + raise SystemExit(f"i18n.ts expected exactly one anchor, found {count}") + i18n_path.write_text(i18n.replace(old, new, 1)) + PY + git diff --check + git diff -- frontend/src/App.tsx frontend/src/i18n.ts + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 + with: + node-version: "24" + + - name: Enable Corepack + run: corepack enable + + - name: Install frontend dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + + - name: Run Customer Master RED-to-GREEN regressions + working-directory: frontend + run: >- + pnpm exec vitest run + src/customerMasterAncestryTranslationContract.test.ts + src/customerMasterRelatedFailureState.test.tsx + src/customerMasterRelatedLoadingOwnership.test.tsx + + - name: Run frontend lint and full test suite + working-directory: frontend + run: | + pnpm run lint + pnpm run test + + - name: Run production build and Storybook build + working-directory: frontend + run: | + pnpm run build + pnpm run build-storybook + + - name: Publish workflow-free product tree without force + shell: bash + run: | + set -euo pipefail + remote_head="$(git ls-remote origin refs/heads/feat/i18n-ledger-frontend-consumer | awk '{print $1}')" + test "$remote_head" = "$(git rev-parse HEAD)" + rm .github/workflows/automation-932-customer-master-repair.yml + git add frontend/src/App.tsx frontend/src/i18n.ts .github/workflows/automation-932-customer-master-repair.yml + git diff --cached --check + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "fix(customer-master): preserve related request state" + git push origin HEAD:feat/i18n-ledger-frontend-consumer From 8456b89ced16710cc8330f91bea6a7c936cfc194 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 02:50:22 +0900 Subject: [PATCH 28/41] fix(#932): repair exact Customer Master automation anchors --- .../automation-932-customer-master-repair.yml | 400 +++++++++--------- 1 file changed, 209 insertions(+), 191 deletions(-) diff --git a/.github/workflows/automation-932-customer-master-repair.yml b/.github/workflows/automation-932-customer-master-repair.yml index 48edfd319..e7c6614dd 100644 --- a/.github/workflows/automation-932-customer-master-repair.yml +++ b/.github/workflows/automation-932-customer-master-repair.yml @@ -27,7 +27,7 @@ jobs: shell: bash run: | set -euo pipefail - test "$(git rev-parse HEAD^)" = "bdfcdd2c23df9d691f539edbfc85602610607ad4" + test "$(git rev-parse HEAD^)" = "89e727ec948cc7c66b54bf70f02f9ff232ed7b40" test "$(git diff --name-only HEAD^ HEAD)" = ".github/workflows/automation-932-customer-master-repair.yml" - name: Apply minimal Customer Master causal repair @@ -37,205 +37,223 @@ jobs: python - <<'PY' from pathlib import Path + def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one anchor, found {count}") + return text.replace(old, new, 1) + app_path = Path("frontend/src/App.tsx") app = app_path.read_text() - replacements = [] - - replacements.append(( - ''' const [relatedByEntity, setRelatedByEntity] = useState>({}); - const [relatedLoading, setRelatedLoading] = useState(null); - ''', - ''' const [relatedByEntity, setRelatedByEntity] = useState>({}); - const [relatedErrorByEntity, setRelatedErrorByEntity] = useState>({}); - const [relatedLoading, setRelatedLoading] = useState(null); - ''')) - - replacements.append(( - ''' setRelatedByEntity({}); - setExpandedEntityId(null); - ''', - ''' setRelatedByEntity({}); - setRelatedErrorByEntity({}); - setExpandedEntityId(null); - ''')) - - replacements.append(( - ''' async function toggleEntity(entityId: string) { - const requestAccessToken = accessToken; - const requestAuthGeneration = authGeneration.current; - if (expandedEntityId === entityId) { - setExpandedEntityId(null); - return; - } - setExpandedEntityId(entityId); - if (relatedByEntity[entityId]) return; - setRelatedLoading(entityId); - try { - const response = await fetchRelatedEntity(requestAccessToken, entityId); - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { - setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); - } - } catch { - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { - setRelatedByEntity((previous) => ({ ...previous, [entityId]: [] })); - } - } finally { - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) setRelatedLoading(null); - } - } - ''', - ''' async function loadRelatedEntity(entityId: string) { - const requestAccessToken = accessToken; - const requestAuthGeneration = authGeneration.current; - setRelatedLoading(entityId); - setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); - try { - const response = await fetchRelatedEntity(requestAccessToken, entityId); - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { - setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); - setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); - } - } catch { - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { - setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: true })); - } - } finally { - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { - setRelatedLoading((owner) => (owner === entityId ? null : owner)); - } - } - } - - function toggleEntity(entityId: string) { - if (expandedEntityId === entityId) { - setExpandedEntityId(null); - return; - } - setExpandedEntityId(entityId); - if (Object.prototype.hasOwnProperty.call(relatedByEntity, entityId)) return; - void loadRelatedEntity(entityId); - } - - function retryRelatedEntity(entityId: string) { - void loadRelatedEntity(entityId); - } - ''')) + app = replace_once( + app, + """ const [relatedByEntity, setRelatedByEntity] = useState>({}); + const [relatedLoading, setRelatedLoading] = useState(null); +""", + """ const [relatedByEntity, setRelatedByEntity] = useState>({}); + const [relatedErrorByEntity, setRelatedErrorByEntity] = useState>({}); + const [relatedLoading, setRelatedLoading] = useState(null); +""", + "related error state", + ) + app = replace_once( + app, + """ setRelatedByEntity({}); + setExpandedEntityId(null); +""", + """ setRelatedByEntity({}); + setRelatedErrorByEntity({}); + setExpandedEntityId(null); +""", + "auth reset", + ) + app = replace_once( + app, + """ async function toggleEntity(entityId: string) { + const requestAccessToken = accessToken; + const requestAuthGeneration = authGeneration.current; + if (expandedEntityId === entityId) { + setExpandedEntityId(null); + return; + } + setExpandedEntityId(entityId); + if (relatedByEntity[entityId]) return; + setRelatedLoading(entityId); + try { + const response = await fetchRelatedEntity(requestAccessToken, entityId); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); + } + } catch { + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + setRelatedByEntity((previous) => ({ ...previous, [entityId]: [] })); + } + } finally { + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) setRelatedLoading(null); + } + } +""", + """ async function loadRelatedEntity(entityId: string) { + const requestAccessToken = accessToken; + const requestAuthGeneration = authGeneration.current; + setRelatedLoading(entityId); + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); + try { + const response = await fetchRelatedEntity(requestAccessToken, entityId); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); + } + } catch { + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: true })); + } + } finally { + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + setRelatedLoading((owner) => (owner === entityId ? null : owner)); + } + } + } - replacements.append(( - ''' relatedByEntity={relatedByEntity} - relatedLoading={relatedLoading} - onToggle={toggleEntity} - onOpenPost={openPost} - ''', - ''' relatedByEntity={relatedByEntity} - relatedErrorByEntity={relatedErrorByEntity} - relatedLoading={relatedLoading} - onToggle={toggleEntity} - onRetry={retryRelatedEntity} - onOpenPost={openPost} - ''')) + function toggleEntity(entityId: string) { + if (expandedEntityId === entityId) { + setExpandedEntityId(null); + return; + } + setExpandedEntityId(entityId); + if (Object.prototype.hasOwnProperty.call(relatedByEntity, entityId)) return; + void loadRelatedEntity(entityId); + } - replacements.append(( - ''' relatedByEntity, - relatedLoading, - onToggle, - onOpenPost, - ''', - ''' relatedByEntity, - relatedErrorByEntity, - relatedLoading, - onToggle, - onRetry, - onOpenPost, - ''')) - - replacements.append(( - ''' relatedByEntity: Record; - relatedLoading: string | null; - onToggle: (entityId: string) => void; - onOpenPost: (postId: string) => void; - ''', - ''' relatedByEntity: Record; - relatedErrorByEntity: Record; - relatedLoading: string | null; - onToggle: (entityId: string) => void; - onRetry: (entityId: string) => void; - onOpenPost: (postId: string) => void; - ''')) - - replacements.append(( - ''' const relatedPosts = (relatedByEntity[entity.corporate_entity_id] ?? []).filter( - (related) => related.node_type_code === NODE_POST, - ); - ''', - ''' const hasRelatedResult = Object.prototype.hasOwnProperty.call(relatedByEntity, entity.corporate_entity_id); - const relatedError = relatedErrorByEntity[entity.corporate_entity_id] === true; - const relatedPosts = (relatedByEntity[entity.corporate_entity_id] ?? []).filter( - (related) => related.node_type_code === NODE_POST, - ); - ''')) - - replacements.append(( - ''' {relatedLoading === entity.corporate_entity_id ?

{t("Loading related posts...")}

: null} - {relatedLoading !== entity.corporate_entity_id && relatedPosts.length === 0 ? ( -

{t("No linked posts yet.")}

- ) : null} - {relatedPosts.length > 0 ? ( - ''', - ''' {relatedError ? ( - onRetry(entity.corporate_entity_id)} - /> - ) : null} - {!relatedError && relatedLoading === entity.corporate_entity_id ? ( -

{t("Loading related posts...")}

- ) : null} - {!relatedError && relatedLoading !== entity.corporate_entity_id && hasRelatedResult && relatedPosts.length === 0 ? ( -

{t("No linked posts yet.")}

- ) : null} - {!relatedError && relatedPosts.length > 0 ? ( - ''')) - - replacements.append(( - ''' relatedByEntity={relatedByEntity} - relatedLoading={relatedLoading} - onToggle={onToggle} - onOpenPost={onOpenPost} - ''', - ''' relatedByEntity={relatedByEntity} - relatedErrorByEntity={relatedErrorByEntity} - relatedLoading={relatedLoading} - onToggle={onToggle} - onRetry={onRetry} - onOpenPost={onOpenPost} - ''')) - - for old, new in replacements: - count = app.count(old) - if count != 1: - raise SystemExit(f"App.tsx expected exactly one anchor, found {count}: {old[:80]!r}") - app = app.replace(old, new, 1) + function retryRelatedEntity(entityId: string) { + void loadRelatedEntity(entityId); + } +""", + "related request lifecycle", + ) + app = replace_once( + app, + """ relatedByEntity={relatedByEntity} + relatedLoading={relatedLoading} + onToggle={toggleEntity} + onOpenPost={openPost} +""", + """ relatedByEntity={relatedByEntity} + relatedErrorByEntity={relatedErrorByEntity} + relatedLoading={relatedLoading} + onToggle={toggleEntity} + onRetry={retryRelatedEntity} + onOpenPost={openPost} +""", + "top-level tree props", + ) + app = replace_once( + app, + """ relatedByEntity, + relatedLoading, + onToggle, + onOpenPost, +""", + """ relatedByEntity, + relatedErrorByEntity, + relatedLoading, + onToggle, + onRetry, + onOpenPost, +""", + "tree row arguments", + ) + app = replace_once( + app, + """ relatedByEntity: Record; + relatedLoading: string | null; + onToggle: (entityId: string) => void; + onOpenPost: (postId: string) => void; +""", + """ relatedByEntity: Record; + relatedErrorByEntity: Record; + relatedLoading: string | null; + onToggle: (entityId: string) => void; + onRetry: (entityId: string) => void; + onOpenPost: (postId: string) => void; +""", + "tree row prop types", + ) + app = replace_once( + app, + """ const relatedPosts = (relatedByEntity[entity.corporate_entity_id] ?? []).filter( + (related) => related.node_type_code === NODE_POST, + ); +""", + """ const hasRelatedResult = Object.prototype.hasOwnProperty.call(relatedByEntity, entity.corporate_entity_id); + const relatedError = relatedErrorByEntity[entity.corporate_entity_id] === true; + const relatedPosts = (relatedByEntity[entity.corporate_entity_id] ?? []).filter( + (related) => related.node_type_code === NODE_POST, + ); +""", + "tree row derived state", + ) + app = replace_once( + app, + """ {relatedLoading === entity.corporate_entity_id ?

{t(\"Loading related posts...\")}

: null} + {relatedLoading !== entity.corporate_entity_id && relatedPosts.length === 0 ? ( +

{t(\"No linked posts yet.\")}

+ ) : null} + {relatedPosts.length > 0 ? ( +""", + """ {relatedError ? ( + onRetry(entity.corporate_entity_id)} + /> + ) : null} + {!relatedError && relatedLoading === entity.corporate_entity_id ? ( +

{t(\"Loading related posts...\")}

+ ) : null} + {!relatedError && relatedLoading !== entity.corporate_entity_id && hasRelatedResult && relatedPosts.length === 0 ? ( +

{t(\"No linked posts yet.\")}

+ ) : null} + {!relatedError && relatedPosts.length > 0 ? ( +""", + "related result rendering", + ) + app = replace_once( + app, + """ relatedByEntity={relatedByEntity} + relatedLoading={relatedLoading} + onToggle={onToggle} + onOpenPost={onOpenPost} +""", + """ relatedByEntity={relatedByEntity} + relatedErrorByEntity={relatedErrorByEntity} + relatedLoading={relatedLoading} + onToggle={onToggle} + onRetry={onRetry} + onOpenPost={onOpenPost} +""", + "recursive tree props", + ) app_path.write_text(app) i18n_path = Path("frontend/src/i18n.ts") i18n = i18n_path.read_text() - old = ''' "Post body preview", "Related posts", "Relationship network", "Resolve", "Resolving...", - "Showing the first {shown} of {total} observed customer identifiers, ranked by post count.", - ''' - new = ''' "Post body preview", "Related posts", "Relationship network", "Resolve", "Resolving...", "Retry", "Retry needed", - "Shown as top level: listed parent forms a cycle.", - "Shown as top level: entity lists itself as parent.", - "Shown as top level: listed parent is not visible.", - "This request failed. Retry the same action.", - "Showing the first {shown} of {total} observed customer identifiers, ranked by post count.", - ''' - count = i18n.count(old) - if count != 1: - raise SystemExit(f"i18n.ts expected exactly one anchor, found {count}") - i18n_path.write_text(i18n.replace(old, new, 1)) + i18n = replace_once( + i18n, + """ \"Post body preview\", \"Related posts\", \"Relationship network\", \"Resolve\", \"Resolving...\", + \"Showing the first {shown} of {total} observed customer identifiers, ranked by post count.\", +""", + """ \"Post body preview\", \"Related posts\", \"Relationship network\", \"Resolve\", \"Resolving...\", \"Retry\", \"Retry needed\", + \"Shown as top level: listed parent forms a cycle.\", + \"Shown as top level: entity lists itself as parent.\", + \"Shown as top level: listed parent is not visible.\", + \"This request failed. Retry the same action.\", + \"Showing the first {shown} of {total} observed customer identifiers, ranked by post count.\", +""", + "Customer Master translation contract", + ) + i18n_path.write_text(i18n) PY git diff --check git diff -- frontend/src/App.tsx frontend/src/i18n.ts From e02270a2deb5dce438544b883d70751f2452496d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 02:51:18 +0900 Subject: [PATCH 29/41] fix(#932): switch repair harness to deterministic patch --- .../automation-932-customer-master-repair.yml | 411 +++++++----------- 1 file changed, 165 insertions(+), 246 deletions(-) diff --git a/.github/workflows/automation-932-customer-master-repair.yml b/.github/workflows/automation-932-customer-master-repair.yml index e7c6614dd..53bea52f3 100644 --- a/.github/workflows/automation-932-customer-master-repair.yml +++ b/.github/workflows/automation-932-customer-master-repair.yml @@ -10,288 +10,207 @@ on: permissions: contents: write -concurrency: - group: automation-932-customer-master-repair - cancel-in-progress: false - jobs: repair: runs-on: ubuntu-latest steps: - name: Checkout exact automation head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with: fetch-depth: 2 - - name: Verify single-writer parent - shell: bash + - name: Verify branch lease run: | set -euo pipefail - test "$(git rev-parse HEAD^)" = "89e727ec948cc7c66b54bf70f02f9ff232ed7b40" + test "$(git rev-parse HEAD^)" = "8456b89ced16710cc8330f91bea6a7c936cfc194" test "$(git diff --name-only HEAD^ HEAD)" = ".github/workflows/automation-932-customer-master-repair.yml" - - name: Apply minimal Customer Master causal repair - shell: bash + - name: Apply Customer Master repair run: | set -euo pipefail - python - <<'PY' - from pathlib import Path - - def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected exactly one anchor, found {count}") - return text.replace(old, new, 1) - - app_path = Path("frontend/src/App.tsx") - app = app_path.read_text() - - app = replace_once( - app, - """ const [relatedByEntity, setRelatedByEntity] = useState>({}); - const [relatedLoading, setRelatedLoading] = useState(null); -""", - """ const [relatedByEntity, setRelatedByEntity] = useState>({}); - const [relatedErrorByEntity, setRelatedErrorByEntity] = useState>({}); - const [relatedLoading, setRelatedLoading] = useState(null); -""", - "related error state", - ) - app = replace_once( - app, - """ setRelatedByEntity({}); - setExpandedEntityId(null); -""", - """ setRelatedByEntity({}); - setRelatedErrorByEntity({}); - setExpandedEntityId(null); -""", - "auth reset", - ) - app = replace_once( - app, - """ async function toggleEntity(entityId: string) { - const requestAccessToken = accessToken; - const requestAuthGeneration = authGeneration.current; - if (expandedEntityId === entityId) { - setExpandedEntityId(null); - return; - } - setExpandedEntityId(entityId); - if (relatedByEntity[entityId]) return; - setRelatedLoading(entityId); - try { - const response = await fetchRelatedEntity(requestAccessToken, entityId); - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { - setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); - } - } catch { - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { - setRelatedByEntity((previous) => ({ ...previous, [entityId]: [] })); - } - } finally { - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) setRelatedLoading(null); - } - } -""", - """ async function loadRelatedEntity(entityId: string) { - const requestAccessToken = accessToken; - const requestAuthGeneration = authGeneration.current; - setRelatedLoading(entityId); - setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); - try { - const response = await fetchRelatedEntity(requestAccessToken, entityId); - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { - setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); - setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); - } - } catch { - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { - setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: true })); - } - } finally { - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { - setRelatedLoading((owner) => (owner === entityId ? null : owner)); - } - } - } - - function toggleEntity(entityId: string) { - if (expandedEntityId === entityId) { - setExpandedEntityId(null); - return; - } - setExpandedEntityId(entityId); - if (Object.prototype.hasOwnProperty.call(relatedByEntity, entityId)) return; - void loadRelatedEntity(entityId); - } - - function retryRelatedEntity(entityId: string) { - void loadRelatedEntity(entityId); - } -""", - "related request lifecycle", - ) - app = replace_once( - app, - """ relatedByEntity={relatedByEntity} - relatedLoading={relatedLoading} - onToggle={toggleEntity} - onOpenPost={openPost} -""", - """ relatedByEntity={relatedByEntity} - relatedErrorByEntity={relatedErrorByEntity} - relatedLoading={relatedLoading} - onToggle={toggleEntity} - onRetry={retryRelatedEntity} - onOpenPost={openPost} -""", - "top-level tree props", - ) - app = replace_once( - app, - """ relatedByEntity, - relatedLoading, - onToggle, - onOpenPost, -""", - """ relatedByEntity, - relatedErrorByEntity, - relatedLoading, - onToggle, - onRetry, - onOpenPost, -""", - "tree row arguments", - ) - app = replace_once( - app, - """ relatedByEntity: Record; - relatedLoading: string | null; - onToggle: (entityId: string) => void; - onOpenPost: (postId: string) => void; -""", - """ relatedByEntity: Record; - relatedErrorByEntity: Record; - relatedLoading: string | null; - onToggle: (entityId: string) => void; - onRetry: (entityId: string) => void; - onOpenPost: (postId: string) => void; -""", - "tree row prop types", - ) - app = replace_once( - app, - """ const relatedPosts = (relatedByEntity[entity.corporate_entity_id] ?? []).filter( - (related) => related.node_type_code === NODE_POST, - ); -""", - """ const hasRelatedResult = Object.prototype.hasOwnProperty.call(relatedByEntity, entity.corporate_entity_id); - const relatedError = relatedErrorByEntity[entity.corporate_entity_id] === true; - const relatedPosts = (relatedByEntity[entity.corporate_entity_id] ?? []).filter( - (related) => related.node_type_code === NODE_POST, - ); -""", - "tree row derived state", - ) - app = replace_once( - app, - """ {relatedLoading === entity.corporate_entity_id ?

{t(\"Loading related posts...\")}

: null} - {relatedLoading !== entity.corporate_entity_id && relatedPosts.length === 0 ? ( -

{t(\"No linked posts yet.\")}

- ) : null} - {relatedPosts.length > 0 ? ( -""", - """ {relatedError ? ( - onRetry(entity.corporate_entity_id)} - /> - ) : null} - {!relatedError && relatedLoading === entity.corporate_entity_id ? ( -

{t(\"Loading related posts...\")}

- ) : null} - {!relatedError && relatedLoading !== entity.corporate_entity_id && hasRelatedResult && relatedPosts.length === 0 ? ( -

{t(\"No linked posts yet.\")}

- ) : null} - {!relatedError && relatedPosts.length > 0 ? ( -""", - "related result rendering", - ) - app = replace_once( - app, - """ relatedByEntity={relatedByEntity} - relatedLoading={relatedLoading} - onToggle={onToggle} - onOpenPost={onOpenPost} -""", - """ relatedByEntity={relatedByEntity} - relatedErrorByEntity={relatedErrorByEntity} - relatedLoading={relatedLoading} - onToggle={onToggle} - onRetry={onRetry} - onOpenPost={onOpenPost} -""", - "recursive tree props", - ) - app_path.write_text(app) - - i18n_path = Path("frontend/src/i18n.ts") - i18n = i18n_path.read_text() - i18n = replace_once( - i18n, - """ \"Post body preview\", \"Related posts\", \"Relationship network\", \"Resolve\", \"Resolving...\", - \"Showing the first {shown} of {total} observed customer identifiers, ranked by post count.\", -""", - """ \"Post body preview\", \"Related posts\", \"Relationship network\", \"Resolve\", \"Resolving...\", \"Retry\", \"Retry needed\", - \"Shown as top level: listed parent forms a cycle.\", - \"Shown as top level: entity lists itself as parent.\", - \"Shown as top level: listed parent is not visible.\", - \"This request failed. Retry the same action.\", - \"Showing the first {shown} of {total} observed customer identifiers, ranked by post count.\", -""", - "Customer Master translation contract", - ) - i18n_path.write_text(i18n) - PY + cat > /tmp/repair.patch <<'PATCH' + diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts + --- a/frontend/src/i18n.ts + +++ b/frontend/src/i18n.ts + @@ -22,7 +22,12 @@ export const CUSTOMER_MASTER_TRANSLATION_KEYS = [ + "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...", + + "Post body preview", "Related posts", "Relationship network", "Resolve", "Resolving...", "Retry", "Retry needed", + + "Shown as top level: listed parent forms a cycle.", + + "Shown as top level: entity lists itself as parent.", + + "Shown as top level: listed parent is not visible.", + + "This request failed. Retry the same action.", + "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.", + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx + --- a/frontend/src/App.tsx + +++ b/frontend/src/App.tsx + @@ -4647,17 +4647,21 @@ function CustomerEntityTreeRow({ + depth, + expandedEntityId, + relatedByEntity, + + relatedErrorByEntity, + relatedLoading, + onToggle, + + onRetry, + onOpenPost, + }: { + node: CustomerEntityTreeNode; + depth: number; + expandedEntityId: string | null; + relatedByEntity: Record; + + relatedErrorByEntity: Record; + relatedLoading: string | null; + onToggle: (entityId: string) => void; + + onRetry: (entityId: string) => void; + onOpenPost: (postId: string) => void; + }) { + const { entity, children } = node; + + const hasRelatedResult = Object.prototype.hasOwnProperty.call(relatedByEntity, entity.corporate_entity_id); + + const relatedError = relatedErrorByEntity[entity.corporate_entity_id] === true; + const relatedPosts = (relatedByEntity[entity.corporate_entity_id] ?? []).filter( + (related) => related.node_type_code === NODE_POST, + ); + @@ -4676,11 +4680,18 @@ function CustomerEntityTreeRow({ + + {expandedEntityId === entity.corporate_entity_id ? ( +
+ - {relatedLoading === entity.corporate_entity_id ?

{t("Loading related posts...")}

: null} + - {relatedLoading !== entity.corporate_entity_id && relatedPosts.length === 0 ? ( + + {relatedError ? ( + + onRetry(entity.corporate_entity_id)} + + /> + + ) : null} + + {!relatedError && relatedLoading === entity.corporate_entity_id ?

{t("Loading related posts...")}

: null} + + {!relatedError && relatedLoading !== entity.corporate_entity_id && hasRelatedResult && relatedPosts.length === 0 ? ( +

{t("No linked posts yet.")}

+ ) : null} + - {relatedPosts.length > 0 ? ( + + {!relatedError && relatedPosts.length > 0 ? ( +
    + {relatedPosts.map((related) => ( +
  • + @@ -4707,8 +4718,10 @@ function CustomerEntityTreeRow({ + expandedEntityId={expandedEntityId} + relatedByEntity={relatedByEntity} + + relatedErrorByEntity={relatedErrorByEntity} + relatedLoading={relatedLoading} + onToggle={onToggle} + + onRetry={onRetry} + onOpenPost={onOpenPost} + /> + ))} + @@ -4771,6 +4784,7 @@ function CustomerMasterPanel({ + const [error, setError] = useState(null); + const [expandedEntityId, setExpandedEntityId] = useState(null); + const [relatedByEntity, setRelatedByEntity] = useState>({}); + + const [relatedErrorByEntity, setRelatedErrorByEntity] = useState>({}); + const [relatedLoading, setRelatedLoading] = useState(null); + // Opening a customer's related post stays IN this panel (the Board + // hand-off was the reported bug: clicking a customer's post jumped the + @@ -4790,6 +4804,7 @@ function CustomerMasterPanel({ + let active = true; + setCanResolveHints(false); + setRelatedByEntity({}); + + setRelatedErrorByEntity({}); + setExpandedEntityId(null); + setRelatedLoading(null); + setSelectedPostId(null); + @@ -4916,25 +4931,39 @@ function CustomerMasterPanel({ + } + } + + -async function toggleEntity(entityId: string) { + +async function loadRelatedEntity(entityId: string) { + const requestAccessToken = accessToken; + const requestAuthGeneration = authGeneration.current; + - if (expandedEntityId === entityId) { + - setExpandedEntityId(null); + - return; + - } + - setExpandedEntityId(entityId); + - if (relatedByEntity[entityId]) return; + setRelatedLoading(entityId); + + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); + try { + const response = await fetchRelatedEntity(requestAccessToken, entityId); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); + + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); + } + } catch { + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + - setRelatedByEntity((previous) => ({ ...previous, [entityId]: [] })); + + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: true })); + } + } finally { + - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) setRelatedLoading(null); + + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + + setRelatedLoading((owner) => (owner === entityId ? null : owner)); + + } + } + } + + + +function toggleEntity(entityId: string) { + + if (expandedEntityId === entityId) { + + setExpandedEntityId(null); + + return; + + } + + setExpandedEntityId(entityId); + + if (Object.prototype.hasOwnProperty.call(relatedByEntity, entityId)) return; + + void loadRelatedEntity(entityId); + +} + + + +function retryRelatedEntity(entityId: string) { + + void loadRelatedEntity(entityId); + +} + @@ -4969,8 +4998,10 @@ function CustomerMasterPanel({ + expandedEntityId={expandedEntityId} + relatedByEntity={relatedByEntity} + + relatedErrorByEntity={relatedErrorByEntity} + relatedLoading={relatedLoading} + onToggle={toggleEntity} + + onRetry={retryRelatedEntity} + onOpenPost={openPost} + /> + ))} + PATCH + sed -i 's/^ //' /tmp/repair.patch + git apply --check /tmp/repair.patch + git apply /tmp/repair.patch git diff --check git diff -- frontend/src/App.tsx frontend/src/i18n.ts - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 with: node-version: "24" - name: Enable Corepack run: corepack enable - - name: Install frontend dependencies + - name: Install dependencies working-directory: frontend run: pnpm install --frozen-lockfile - - name: Run Customer Master RED-to-GREEN regressions + - name: Verify focused Customer Master regressions working-directory: frontend - run: >- - pnpm exec vitest run - src/customerMasterAncestryTranslationContract.test.ts - src/customerMasterRelatedFailureState.test.tsx - src/customerMasterRelatedLoadingOwnership.test.tsx + run: pnpm exec vitest run src/customerMasterAncestryTranslationContract.test.ts src/customerMasterRelatedFailureState.test.tsx src/customerMasterRelatedLoadingOwnership.test.tsx - - name: Run frontend lint and full test suite + - name: Verify frontend working-directory: frontend run: | pnpm run lint pnpm run test - - - name: Run production build and Storybook build - working-directory: frontend - run: | pnpm run build pnpm run build-storybook - - name: Publish workflow-free product tree without force - shell: bash + - name: Publish workflow-free product tree run: | set -euo pipefail remote_head="$(git ls-remote origin refs/heads/feat/i18n-ledger-frontend-consumer | awk '{print $1}')" From 3fa18a282d74d7127b028b6ebc9c05342241de14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 02:53:52 +0900 Subject: [PATCH 30/41] fix(#932): execute repair script without YAML patch ambiguity --- .../automation-932-customer-master-repair.yml | 159 +----------------- 1 file changed, 3 insertions(+), 156 deletions(-) diff --git a/.github/workflows/automation-932-customer-master-repair.yml b/.github/workflows/automation-932-customer-master-repair.yml index 53bea52f3..77c5ee21a 100644 --- a/.github/workflows/automation-932-customer-master-repair.yml +++ b/.github/workflows/automation-932-customer-master-repair.yml @@ -22,167 +22,14 @@ jobs: - name: Verify branch lease run: | set -euo pipefail - test "$(git rev-parse HEAD^)" = "8456b89ced16710cc8330f91bea6a7c936cfc194" + test "$(git rev-parse HEAD^)" = "e02270a2deb5dce438544b883d70751f2452496d" test "$(git diff --name-only HEAD^ HEAD)" = ".github/workflows/automation-932-customer-master-repair.yml" - name: Apply Customer Master repair run: | set -euo pipefail - cat > /tmp/repair.patch <<'PATCH' - diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts - --- a/frontend/src/i18n.ts - +++ b/frontend/src/i18n.ts - @@ -22,7 +22,12 @@ export const CUSTOMER_MASTER_TRANSLATION_KEYS = [ - "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...", - + "Post body preview", "Related posts", "Relationship network", "Resolve", "Resolving...", "Retry", "Retry needed", - + "Shown as top level: listed parent forms a cycle.", - + "Shown as top level: entity lists itself as parent.", - + "Shown as top level: listed parent is not visible.", - + "This request failed. Retry the same action.", - "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.", - diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx - --- a/frontend/src/App.tsx - +++ b/frontend/src/App.tsx - @@ -4647,17 +4647,21 @@ function CustomerEntityTreeRow({ - depth, - expandedEntityId, - relatedByEntity, - + relatedErrorByEntity, - relatedLoading, - onToggle, - + onRetry, - onOpenPost, - }: { - node: CustomerEntityTreeNode; - depth: number; - expandedEntityId: string | null; - relatedByEntity: Record; - + relatedErrorByEntity: Record; - relatedLoading: string | null; - onToggle: (entityId: string) => void; - + onRetry: (entityId: string) => void; - onOpenPost: (postId: string) => void; - }) { - const { entity, children } = node; - + const hasRelatedResult = Object.prototype.hasOwnProperty.call(relatedByEntity, entity.corporate_entity_id); - + const relatedError = relatedErrorByEntity[entity.corporate_entity_id] === true; - const relatedPosts = (relatedByEntity[entity.corporate_entity_id] ?? []).filter( - (related) => related.node_type_code === NODE_POST, - ); - @@ -4676,11 +4680,18 @@ function CustomerEntityTreeRow({ - - {expandedEntityId === entity.corporate_entity_id ? ( -
    - - {relatedLoading === entity.corporate_entity_id ?

    {t("Loading related posts...")}

    : null} - - {relatedLoading !== entity.corporate_entity_id && relatedPosts.length === 0 ? ( - + {relatedError ? ( - + onRetry(entity.corporate_entity_id)} - + /> - + ) : null} - + {!relatedError && relatedLoading === entity.corporate_entity_id ?

    {t("Loading related posts...")}

    : null} - + {!relatedError && relatedLoading !== entity.corporate_entity_id && hasRelatedResult && relatedPosts.length === 0 ? ( -

    {t("No linked posts yet.")}

    - ) : null} - - {relatedPosts.length > 0 ? ( - + {!relatedError && relatedPosts.length > 0 ? ( -
      - {relatedPosts.map((related) => ( -
    • - @@ -4707,8 +4718,10 @@ function CustomerEntityTreeRow({ - expandedEntityId={expandedEntityId} - relatedByEntity={relatedByEntity} - + relatedErrorByEntity={relatedErrorByEntity} - relatedLoading={relatedLoading} - onToggle={onToggle} - + onRetry={onRetry} - onOpenPost={onOpenPost} - /> - ))} - @@ -4771,6 +4784,7 @@ function CustomerMasterPanel({ - const [error, setError] = useState(null); - const [expandedEntityId, setExpandedEntityId] = useState(null); - const [relatedByEntity, setRelatedByEntity] = useState>({}); - + const [relatedErrorByEntity, setRelatedErrorByEntity] = useState>({}); - const [relatedLoading, setRelatedLoading] = useState(null); - // Opening a customer's related post stays IN this panel (the Board - // hand-off was the reported bug: clicking a customer's post jumped the - @@ -4790,6 +4804,7 @@ function CustomerMasterPanel({ - let active = true; - setCanResolveHints(false); - setRelatedByEntity({}); - + setRelatedErrorByEntity({}); - setExpandedEntityId(null); - setRelatedLoading(null); - setSelectedPostId(null); - @@ -4916,25 +4931,39 @@ function CustomerMasterPanel({ - } - } - - -async function toggleEntity(entityId: string) { - +async function loadRelatedEntity(entityId: string) { - const requestAccessToken = accessToken; - const requestAuthGeneration = authGeneration.current; - - if (expandedEntityId === entityId) { - - setExpandedEntityId(null); - - return; - - } - - setExpandedEntityId(entityId); - - if (relatedByEntity[entityId]) return; - setRelatedLoading(entityId); - + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); - try { - const response = await fetchRelatedEntity(requestAccessToken, entityId); - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { - setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); - + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); - } - } catch { - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { - - setRelatedByEntity((previous) => ({ ...previous, [entityId]: [] })); - + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: true })); - } - } finally { - - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) setRelatedLoading(null); - + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { - + setRelatedLoading((owner) => (owner === entityId ? null : owner)); - + } - } - } - + - +function toggleEntity(entityId: string) { - + if (expandedEntityId === entityId) { - + setExpandedEntityId(null); - + return; - + } - + setExpandedEntityId(entityId); - + if (Object.prototype.hasOwnProperty.call(relatedByEntity, entityId)) return; - + void loadRelatedEntity(entityId); - +} - + - +function retryRelatedEntity(entityId: string) { - + void loadRelatedEntity(entityId); - +} - @@ -4969,8 +4998,10 @@ function CustomerMasterPanel({ - expandedEntityId={expandedEntityId} - relatedByEntity={relatedByEntity} - + relatedErrorByEntity={relatedErrorByEntity} - relatedLoading={relatedLoading} - onToggle={toggleEntity} - + onRetry={retryRelatedEntity} - onOpenPost={openPost} - /> - ))} - PATCH - sed -i 's/^ //' /tmp/repair.patch - git apply --check /tmp/repair.patch - git apply /tmp/repair.patch + printf '%s' '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' | base64 -d | gzip -d > /tmp/repair.py + python /tmp/repair.py git diff --check git diff -- frontend/src/App.tsx frontend/src/i18n.ts From 857284ca822dea2e76b1a00890f19b126c3d77b8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:55:02 +0000 Subject: [PATCH 31/41] fix(customer-master): preserve related request state --- .../automation-932-customer-master-repair.yml | 71 ------------------- frontend/src/App.tsx | 58 +++++++++++---- frontend/src/i18n.ts | 6 +- 3 files changed, 51 insertions(+), 84 deletions(-) delete mode 100644 .github/workflows/automation-932-customer-master-repair.yml diff --git a/.github/workflows/automation-932-customer-master-repair.yml b/.github/workflows/automation-932-customer-master-repair.yml deleted file mode 100644 index 77c5ee21a..000000000 --- a/.github/workflows/automation-932-customer-master-repair.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: automation-932-customer-master-repair - -on: - push: - branches: - - feat/i18n-ledger-frontend-consumer - paths: - - .github/workflows/automation-932-customer-master-repair.yml - -permissions: - contents: write - -jobs: - repair: - runs-on: ubuntu-latest - steps: - - name: Checkout exact automation head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - fetch-depth: 2 - - - name: Verify branch lease - run: | - set -euo pipefail - test "$(git rev-parse HEAD^)" = "e02270a2deb5dce438544b883d70751f2452496d" - test "$(git diff --name-only HEAD^ HEAD)" = ".github/workflows/automation-932-customer-master-repair.yml" - - - name: Apply Customer Master repair - run: | - set -euo pipefail - printf '%s' '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' | base64 -d | gzip -d > /tmp/repair.py - python /tmp/repair.py - git diff --check - git diff -- frontend/src/App.tsx frontend/src/i18n.ts - - - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 - with: - node-version: "24" - - - name: Enable Corepack - run: corepack enable - - - name: Install dependencies - working-directory: frontend - run: pnpm install --frozen-lockfile - - - name: Verify focused Customer Master regressions - working-directory: frontend - run: pnpm exec vitest run src/customerMasterAncestryTranslationContract.test.ts src/customerMasterRelatedFailureState.test.tsx src/customerMasterRelatedLoadingOwnership.test.tsx - - - name: Verify frontend - working-directory: frontend - run: | - pnpm run lint - pnpm run test - pnpm run build - pnpm run build-storybook - - - name: Publish workflow-free product tree - run: | - set -euo pipefail - remote_head="$(git ls-remote origin refs/heads/feat/i18n-ledger-frontend-consumer | awk '{print $1}')" - test "$remote_head" = "$(git rev-parse HEAD)" - rm .github/workflows/automation-932-customer-master-repair.yml - git add frontend/src/App.tsx frontend/src/i18n.ts .github/workflows/automation-932-customer-master-repair.yml - git diff --cached --check - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "fix(customer-master): preserve related request state" - git push origin HEAD:feat/i18n-ledger-frontend-consumer diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7d8213e15..9c1fc0c09 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -123,6 +123,7 @@ import { useLocale, } from "./i18n"; import { ScreenTranslationGate } from "./components/ScreenTranslationGate"; +import { StatusNotice } from "./components/StatusNotice"; import "./App.css"; const AdminPanel = lazy(() => import("./components/AdminPanel").then((module) => ({ default: module.AdminPanel }))); @@ -4654,19 +4655,25 @@ function CustomerEntityTreeRow({ depth, expandedEntityId, relatedByEntity, + relatedErrorByEntity, relatedLoading, onToggle, + onRetry, onOpenPost, }: { node: CustomerEntityTreeNode; depth: number; expandedEntityId: string | null; relatedByEntity: Record; + relatedErrorByEntity: Record; relatedLoading: string | null; onToggle: (entityId: string) => void; + onRetry: (entityId: string) => void; onOpenPost: (postId: string) => void; }) { const { entity, children } = node; + const hasRelatedResult = Object.prototype.hasOwnProperty.call(relatedByEntity, entity.corporate_entity_id); + const relatedError = relatedErrorByEntity[entity.corporate_entity_id] === true; const relatedPosts = (relatedByEntity[entity.corporate_entity_id] ?? []).filter( (related) => related.node_type_code === NODE_POST, ); @@ -4683,11 +4690,20 @@ function CustomerEntityTreeRow({ {expandedEntityId === entity.corporate_entity_id ? (
      - {relatedLoading === entity.corporate_entity_id ?

      {t("Loading related posts...")}

      : null} - {relatedLoading !== entity.corporate_entity_id && relatedPosts.length === 0 ? ( + {relatedError ? ( + onRetry(entity.corporate_entity_id)} + /> + ) : null} + {!relatedError && relatedLoading === entity.corporate_entity_id ? ( +

      {t("Loading related posts...")}

      + ) : null} + {!relatedError && relatedLoading !== entity.corporate_entity_id && hasRelatedResult && relatedPosts.length === 0 ? (

      {t("No linked posts yet.")}

      ) : null} - {relatedPosts.length > 0 ? ( + {!relatedError && relatedPosts.length > 0 ? (
        {relatedPosts.map((related) => (
      • @@ -4713,8 +4729,10 @@ function CustomerEntityTreeRow({ depth={depth + 1} expandedEntityId={expandedEntityId} relatedByEntity={relatedByEntity} + relatedErrorByEntity={relatedErrorByEntity} relatedLoading={relatedLoading} onToggle={onToggle} + onRetry={onRetry} onOpenPost={onOpenPost} /> ))} @@ -4774,6 +4792,7 @@ function CustomerMasterPanel({ const [error, setError] = useState(null); const [expandedEntityId, setExpandedEntityId] = useState(null); const [relatedByEntity, setRelatedByEntity] = useState>({}); + const [relatedErrorByEntity, setRelatedErrorByEntity] = useState>({}); const [relatedLoading, setRelatedLoading] = useState(null); // Opening a customer's related post stays IN this panel (the Board // hand-off was the reported bug: clicking a customer's post jumped the @@ -4791,6 +4810,7 @@ function CustomerMasterPanel({ let active = true; setCanResolveHints(false); setRelatedByEntity({}); + setRelatedErrorByEntity({}); setExpandedEntityId(null); setRelatedLoading(null); setSelectedPostId(null); @@ -4905,30 +4925,42 @@ function CustomerMasterPanel({ } } - async function toggleEntity(entityId: string) { + async function loadRelatedEntity(entityId: string) { const requestAccessToken = accessToken; const requestAuthGeneration = authGeneration.current; - if (expandedEntityId === entityId) { - setExpandedEntityId(null); - return; - } - setExpandedEntityId(entityId); - if (relatedByEntity[entityId]) return; setRelatedLoading(entityId); + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); try { const response = await fetchRelatedEntity(requestAccessToken, entityId); if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); } } catch { if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { - setRelatedByEntity((previous) => ({ ...previous, [entityId]: [] })); + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: true })); } } finally { - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) setRelatedLoading(null); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + setRelatedLoading((owner) => (owner === entityId ? null : owner)); + } } } + function toggleEntity(entityId: string) { + if (expandedEntityId === entityId) { + setExpandedEntityId(null); + return; + } + setExpandedEntityId(entityId); + if (Object.prototype.hasOwnProperty.call(relatedByEntity, entityId)) return; + void loadRelatedEntity(entityId); + } + + function retryRelatedEntity(entityId: string) { + void loadRelatedEntity(entityId); + } + if (copyState === "retry") { return setCopyAttempt((attempt) => attempt + 1)} />; } @@ -4954,8 +4986,10 @@ function CustomerMasterPanel({ depth={0} expandedEntityId={expandedEntityId} relatedByEntity={relatedByEntity} + relatedErrorByEntity={relatedErrorByEntity} relatedLoading={relatedLoading} onToggle={toggleEntity} + onRetry={retryRelatedEntity} onOpenPost={openPost} /> ))} diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index b9bf54bcc..d3f1333b0 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -21,7 +21,11 @@ export const CUSTOMER_MASTER_TRANSLATION_KEYS = [ "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...", + "Post body preview", "Related posts", "Relationship network", "Resolve", "Resolving...", "Retry", "Retry needed", + "Shown as top level: listed parent forms a cycle.", + "Shown as top level: entity lists itself as parent.", + "Shown as top level: listed parent is not visible.", + "This request failed. Retry the same action.", "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.", From bb36413e855253b86b5bd19fb47e42bbfcab8e62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 03:13:45 +0900 Subject: [PATCH 32/41] test(customer-master): align auth lifecycle contract with related loader --- ...er_master_translation_auth_gate_contract.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/test_customer_master_translation_auth_gate_contract.py b/tests/test_customer_master_translation_auth_gate_contract.py index 6ae049194..6455ebcc1 100644 --- a/tests/test_customer_master_translation_auth_gate_contract.py +++ b/tests/test_customer_master_translation_auth_gate_contract.py @@ -68,7 +68,7 @@ def test_customer_master_render_gate_rejects_stale_token_copy() -> None: 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.""" + """Related data, errors, post detail, and privileges from the old token must be discarded.""" panel = _customer_master_panel_source() fetch_me = panel.index("fetchMe(accessToken)") effect_start = panel.rfind(" useEffect(() => {", 0, fetch_me) @@ -78,6 +78,7 @@ def test_customer_master_auth_transition_invalidates_secondary_authorization_pro for statement in ( "setCanResolveHints(false);", "setRelatedByEntity({});", + "setRelatedErrorByEntity({});", "setExpandedEntityId(null);", "setRelatedLoading(null);", "setSelectedPostId(null);", @@ -101,11 +102,14 @@ def test_customer_master_secondary_async_completions_are_bound_to_current_auth_i 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_end = panel.index("\n async function loadRelatedEntity(", 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 + assert "requestAuthGeneration === authGeneration.current" in resolve_hint + + related_start = panel.index(" async function loadRelatedEntity(") + related_end = panel.index("\n function toggleEntity(", related_start) + load_related = panel[related_start:related_end] + assert "requestAccessToken === currentAccessTokenRef.current" in load_related + assert "requestAuthGeneration === authGeneration.current" in load_related + assert "setRelatedLoading((owner) => (owner === entityId ? null : owner));" in load_related From 012c30a8449e532d47a6f6e2afda9d5eac0157bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 03:52:18 +0900 Subject: [PATCH 33/41] test(i18n): expose global locale fallback gap --- frontend/src/globalLocaleAdmission.test.ts | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 frontend/src/globalLocaleAdmission.test.ts diff --git a/frontend/src/globalLocaleAdmission.test.ts b/frontend/src/globalLocaleAdmission.test.ts new file mode 100644 index 000000000..7628b32d9 --- /dev/null +++ b/frontend/src/globalLocaleAdmission.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { getLocale, setLocale, SUPPORTED_LOCALES } from "./i18n"; +import { ontologyExplorerText } from "./ontologyExplorerI18n"; + +afterEach(() => { + setLocale("en"); +}); + +describe("global locale admission", () => { + it("does not admit a globally selectable locale that silently falls back to English", () => { + const englishCopy = "Load next relation page"; + + for (const locale of SUPPORTED_LOCALES) { + setLocale(locale); + expect(getLocale()).toBe(locale); + + if (locale !== "en") { + expect( + ontologyExplorerText("Load next relation page"), + `${locale} is globally selectable but Ontology Explorer falls back to English`, + ).not.toBe(englishCopy); + } + } + }); +}); From 05a93b452177c0db8f991e1467155a65c2baec23 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 04:06:55 +0900 Subject: [PATCH 34/41] fix(i18n): separate global locale admission from ledger coverage --- frontend/src/i18n.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index d3f1333b0..edd3b3b30 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -1,6 +1,6 @@ import { useSyncExternalStore } from "react"; -export const SUPPORTED_LOCALES = ["ko", "en", "ja", "zh", "vi", "es", "de", "fr"] as const; +export const SUPPORTED_LOCALES = ["ko", "en", "ja", "zh", "vi"] as const; export type Locale = (typeof SUPPORTED_LOCALES)[number]; export const LOCALE_LABELS: Record = { @@ -9,9 +9,6 @@ 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 = [ @@ -183,7 +180,7 @@ const TRANSLATIONS: Partial>> = { "Source author code": "원천 작성자 코드", "Source author name": "원천 작성자 이름", "Source company code": "원천 법인 코드", - "Source company name": "원천 법인 이름", + "Source company name": "원천 법인명", "Source business unit": "원천 사업부(PU)", "Source process unit name": "원천 사업부(PU) 이름", "Source sales pool": "원천 수주풀", @@ -404,7 +401,7 @@ const TRANSLATIONS: Partial>> = { "Verify against web search": "웹 검색 검증", "Deriving...": "찾는 중...", "Derive commitment": "Commitment 도출", - "Creating...": "생성하는 중...", + "Creating...": "생성 중...", "Embedded image": "삽입 이미지", "Text detected in image": "이미지에서 인식된 텍스트", "Image regions": "이미지 영역", @@ -645,7 +642,7 @@ const TRANSLATIONS: Partial>> = { "Read leftover map rank {rank}, observed Y {observed}, and expected E {expected} after IRT main effects, then open this post.": "IRT 주효과 이후 잔여 맵 랭크 {rank}, 관측 Y {observed}, 기대 E {expected}를 읽은 다음, 이 글을 여세요.", "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.": - "IRT 주효과 이후 잔여 맵 랭크 0은 잔여 구조가 없음을 뜻합니다. 관측 Y {observed}와 기대 E {expected}를 읽은 다음, 이 글을 여세요.", + "잔여 맵 랭크 0은 IRT 주효과 이후 잔여 구조가 없음을 뜻합니다. 관측 Y {observed}와 기대 E {expected}를 읽은 다음, 이 글을 여세요.", }, zh: { "Connect another perspective": "关联另一个观点", @@ -728,7 +725,7 @@ const TRANSLATIONS: Partial>> = { "Semantic extraction": "语义提取", "Recorded extraction": "已记录的提取", "Stored semantic evidence": "已存储的语义证据", - "Recorded evidence": "已记录的证据", + "Recorded evidence": "记录的证据", "Lineage maintenance": "谱系维护", "Verification is unavailable because public search is not configured yet. Ask an administrator to enable it, then retry.": "验证功能尚未配置公开搜索。请请求管理员启用后再试。", "No customer commitment found in this post.": "未在此文章中找到客户承诺。", @@ -950,7 +947,7 @@ const TRANSLATIONS: Partial>> = { "Loading lineage...": "正在加载谱系...", "Loading VOC evidence...": "正在加载客户之声证据...", "No extractive excerpt -- no named organization appears in this post.": - "没有提取出的摘录。此文章中没有出现组织名称。", + "没有提取出的摘录。此文章中没有组织名称。", "No Keymen extracted yet.": "尚未提取关键联系人。", "Loading evaluation...": "正在加载评估...", "Not yet evaluated.": "尚未评估。", @@ -1164,7 +1161,7 @@ const TRANSLATIONS: Partial>> = { "Exact values": "精确值", Source: "起点", Property: "属性", - Target: "终点", + Target: "目标", "Truth status": "真值状态", "Recorded at": "记录时间", "Node evidence": "节点证据", From f898ae098ea1f86fc4aebd4ab439bded2688803c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 04:09:18 +0900 Subject: [PATCH 35/41] test(i18n): pin product-wide locale admission --- frontend/src/i18n.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 1981fc0f9..06adc2106 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -105,9 +105,9 @@ describe("i18n", () => { "Compare these cutoff-grounded citations with live evidence next.", ] as const; - 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 product-wide complete locales globally", () => { + expect(SUPPORTED_LOCALES).toEqual(["ko", "en", "ja", "zh", "vi"]); + expect(Object.keys(LOCALE_LABELS)).toHaveLength(5); }); it("admits only a complete Customer Master screen projection", () => { From 8eb6e607230c7aeacf5fa80a9c42a0768f532d41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 04:13:09 +0900 Subject: [PATCH 36/41] test(i18n): align bootstrap shell with global admission --- frontend/src/components/ScreenTranslationGate.test.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/src/components/ScreenTranslationGate.test.tsx b/frontend/src/components/ScreenTranslationGate.test.tsx index c43d7f8d1..35c76d113 100644 --- a/frontend/src/components/ScreenTranslationGate.test.tsx +++ b/frontend/src/components/ScreenTranslationGate.test.tsx @@ -34,10 +34,7 @@ describe("ScreenTranslationGate", () => { ["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) => { + ] as const)("keeps the bootstrap loading and retry shell in every globally admitted locale: %s", (locale, loading, failure, retry) => { setLocale(locale); const { rerender } = render(); expect(screen.getByRole("status")).toHaveTextContent(loading); From c993e445858f3006ef50b0df505b18447635c282 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 04:18:20 +0900 Subject: [PATCH 37/41] fix(i18n): align bootstrap copy with global locale admission --- .../src/components/ScreenTranslationGate.tsx | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/frontend/src/components/ScreenTranslationGate.tsx b/frontend/src/components/ScreenTranslationGate.tsx index 61d53b7b3..11743fc05 100644 --- a/frontend/src/components/ScreenTranslationGate.tsx +++ b/frontend/src/components/ScreenTranslationGate.tsx @@ -59,30 +59,6 @@ const BOOTSTRAP_COPY: Record = { 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: { - 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 intentarlo. Si el problema persiste, póngase en contacto con su administrador.", - 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 es erneut. Wenn das Problem weiterhin besteht, wenden Sie sich an die Administration.", - 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. Si le problème persiste, contactez votre administrateur.", - retryAction: "Réessayer", - }, }; /** Keep an untranslated screen hidden while offering one concrete recovery action. */ From bdede9bc22e0ae5ce7f1bf8141e009b2cfa0fb35 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 04:21:24 +0900 Subject: [PATCH 38/41] test(customer-master): reproduce same-entity request generation race --- ...omerMasterRelatedLoadingOwnership.test.tsx | 88 ++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/frontend/src/customerMasterRelatedLoadingOwnership.test.tsx b/frontend/src/customerMasterRelatedLoadingOwnership.test.tsx index 4870058f7..60a020c13 100644 --- a/frontend/src/customerMasterRelatedLoadingOwnership.test.tsx +++ b/frontend/src/customerMasterRelatedLoadingOwnership.test.tsx @@ -77,10 +77,12 @@ vi.mock("./api", async (importOriginal) => { function deferred() { let resolve!: (value: T) => void; - const promise = new Promise((resolver) => { + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolver, rejecter) => { resolve = resolver; + reject = rejecter; }); - return { promise, resolve }; + return { promise, resolve, reject }; } beforeEach(() => { @@ -133,3 +135,85 @@ it("keeps entity B loading when an older same-auth entity A lookup settles first await waitFor(() => expect(screen.queryByRole("status")).not.toBeInTheDocument()); }); + +it("keeps the newest same-entity lookup authoritative when an older success settles first", async () => { + const user = userEvent.setup(); + const first = deferred>>(); + const second = deferred>>(); + vi.mocked(fetchRelatedEntity) + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise); + + render(); + await user.click( + await screen.findByRole("button", { name: /^(?:Customer master|고객 마스터)$/ }), + ); + const alpha = await screen.findByRole("button", { name: /Alpha Corp/ }); + await user.click(alpha); + await waitFor(() => expect(fetchRelatedEntity).toHaveBeenCalledTimes(1)); + await user.click(alpha); + await user.click(alpha); + await waitFor(() => expect(fetchRelatedEntity).toHaveBeenCalledTimes(2)); + + await act(async () => { + first.resolve({ + corporate_entity_id: "corp-a", + entity_name: "Alpha Corp", + related: [{ node_id: "old-post", node_type_code: "node_post", label: "Old result" }], + }); + await first.promise; + }); + + expect(screen.getByRole("status")).toHaveTextContent("Loading related posts..."); + expect(screen.queryByText("Old result")).not.toBeInTheDocument(); + + await act(async () => { + second.resolve({ + corporate_entity_id: "corp-a", + entity_name: "Alpha Corp", + related: [{ node_id: "new-post", node_type_code: "node_post", label: "Newest result" }], + }); + await second.promise; + }); + + await screen.findByText("Newest result"); + expect(screen.queryByText("Old result")).not.toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); +}); + +it("does not let an older same-entity failure replace a newer in-flight lookup", async () => { + const user = userEvent.setup(); + const first = deferred>>(); + const second = deferred>>(); + vi.mocked(fetchRelatedEntity) + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise); + + render(); + await user.click( + await screen.findByRole("button", { name: /^(?:Customer master|고객 마스터)$/ }), + ); + const alpha = await screen.findByRole("button", { name: /Alpha Corp/ }); + await user.click(alpha); + await waitFor(() => expect(fetchRelatedEntity).toHaveBeenCalledTimes(1)); + await user.click(alpha); + await user.click(alpha); + await waitFor(() => expect(fetchRelatedEntity).toHaveBeenCalledTimes(2)); + + await act(async () => { + first.reject(new Error("stale failure")); + await first.promise.catch(() => undefined); + }); + + expect(screen.getByRole("status")).toHaveTextContent("Loading related posts..."); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + + await act(async () => { + second.resolve({ corporate_entity_id: "corp-a", entity_name: "Alpha Corp", related: [] }); + await second.promise; + }); + + await screen.findByText("No linked posts yet."); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); +}); From 1834a3bfd230afa5dff25c7792666a9799e999ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 04:48:08 +0900 Subject: [PATCH 39/41] ci: stage bounded #932 request-generation repair --- ...tomation-932-related-generation-repair.yml | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .github/workflows/automation-932-related-generation-repair.yml diff --git a/.github/workflows/automation-932-related-generation-repair.yml b/.github/workflows/automation-932-related-generation-repair.yml new file mode 100644 index 000000000..622cc9cbc --- /dev/null +++ b/.github/workflows/automation-932-related-generation-repair.yml @@ -0,0 +1,85 @@ +name: automation-932-related-generation-repair + +on: + push: + branches: + - feat/i18n-ledger-frontend-consumer + paths: + - .github/workflows/automation-932-related-generation-repair.yml + +permissions: + contents: write + +jobs: + repair: + runs-on: ubuntu-latest + steps: + - name: Checkout exact branch head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + ref: feat/i18n-ledger-frontend-consumer + fetch-depth: 0 + + - name: Apply bounded same-entity request-generation repair + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + path = Path("frontend/src/App.tsx") + text = path.read_text() + + ref_anchor = """ const [master, setMaster] = useState(null);\n const masterRequestGeneration = useRef(0);\n const authGeneration = useRef(0);\n""" + ref_replacement = """ const [master, setMaster] = useState(null);\n const masterRequestGeneration = useRef(0);\n const relatedRequestGenerationByEntity = useRef>({});\n const authGeneration = useRef(0);\n""" + if text.count(ref_anchor) != 1: + raise SystemExit("expected exactly one Customer Master request-generation ref anchor") + text = text.replace(ref_anchor, ref_replacement, 1) + + old = """ async function loadRelatedEntity(entityId: string) {\n const requestAccessToken = accessToken;\n const requestAuthGeneration = authGeneration.current;\n setRelatedLoading(entityId);\n setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false }));\n try {\n const response = await fetchRelatedEntity(requestAccessToken, entityId);\n if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) {\n setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related }));\n setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false }));\n }\n } catch {\n if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) {\n setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: true }));\n }\n } finally {\n if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) {\n setRelatedLoading((owner) => (owner === entityId ? null : owner));\n }\n }\n }\n""" + new = """ async function loadRelatedEntity(entityId: string) {\n const requestAccessToken = accessToken;\n const requestAuthGeneration = authGeneration.current;\n const requestGeneration = (relatedRequestGenerationByEntity.current[entityId] ?? 0) + 1;\n relatedRequestGenerationByEntity.current[entityId] = requestGeneration;\n const ownsRequest = () =>\n requestAccessToken === currentAccessTokenRef.current &&\n requestAuthGeneration === authGeneration.current &&\n relatedRequestGenerationByEntity.current[entityId] === requestGeneration;\n setRelatedLoading(entityId);\n setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false }));\n try {\n const response = await fetchRelatedEntity(requestAccessToken, entityId);\n if (ownsRequest()) {\n setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related }));\n setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false }));\n }\n } catch {\n if (ownsRequest()) {\n setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: true }));\n }\n } finally {\n if (ownsRequest()) {\n setRelatedLoading((owner) => (owner === entityId ? null : owner));\n }\n }\n }\n""" + if text.count(old) != 1: + raise SystemExit("expected exactly one unrepaired loadRelatedEntity implementation") + text = text.replace(old, new, 1) + path.write_text(text) + PY + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 + with: + node-version: "24" + + - name: Enable Corepack + run: corepack enable + + - name: Install frontend dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + + - name: Verify focused realistic regressions + working-directory: frontend + run: pnpm exec vitest run src/customerMasterRelatedLoadingOwnership.test.tsx src/customerMasterRelatedFailureState.test.tsx + + - name: Verify frontend lint and production build + working-directory: frontend + run: | + pnpm run lint + pnpm run build + + - name: Remove purpose-complete workflow and publish causal source repair + shell: bash + run: | + rm .github/workflows/automation-932-related-generation-repair.yml + git diff --check + python - <<'PY' + import subprocess + changed = set(subprocess.check_output(["git", "status", "--short"], text=True).splitlines()) + names = {line[3:] for line in changed} + expected = {"frontend/src/App.tsx", ".github/workflows/automation-932-related-generation-repair.yml"} + if names != expected: + raise SystemExit(f"unexpected changed paths: {sorted(names)}") + PY + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add frontend/src/App.tsx .github/workflows/automation-932-related-generation-repair.yml + git commit -m "fix(customer-master): fence same-entity related requests" + git push origin HEAD:feat/i18n-ledger-frontend-consumer From 6d2e6249edcf13213c6110690b9b2bbfe5c410ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 04:50:17 +0900 Subject: [PATCH 40/41] test(customer-master): satisfy RelatedNode contract in overlap regression --- .../src/customerMasterRelatedLoadingOwnership.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/customerMasterRelatedLoadingOwnership.test.tsx b/frontend/src/customerMasterRelatedLoadingOwnership.test.tsx index 60a020c13..509c8a524 100644 --- a/frontend/src/customerMasterRelatedLoadingOwnership.test.tsx +++ b/frontend/src/customerMasterRelatedLoadingOwnership.test.tsx @@ -159,7 +159,9 @@ it("keeps the newest same-entity lookup authoritative when an older success sett first.resolve({ corporate_entity_id: "corp-a", entity_name: "Alpha Corp", - related: [{ node_id: "old-post", node_type_code: "node_post", label: "Old result" }], + related: [ + { node_id: "old-post", node_type_code: "node_post", label: "Old result", relevance: 1 }, + ], }); await first.promise; }); @@ -171,7 +173,9 @@ it("keeps the newest same-entity lookup authoritative when an older success sett second.resolve({ corporate_entity_id: "corp-a", entity_name: "Alpha Corp", - related: [{ node_id: "new-post", node_type_code: "node_post", label: "Newest result" }], + related: [ + { node_id: "new-post", node_type_code: "node_post", label: "Newest result", relevance: 1 }, + ], }); await second.promise; }); From c13f6c3744c68562859034f84437f603650fcf52 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:50:53 +0000 Subject: [PATCH 41/41] fix(customer-master): fence same-entity related requests --- ...tomation-932-related-generation-repair.yml | 85 ------------------- frontend/src/App.tsx | 13 ++- 2 files changed, 10 insertions(+), 88 deletions(-) delete mode 100644 .github/workflows/automation-932-related-generation-repair.yml diff --git a/.github/workflows/automation-932-related-generation-repair.yml b/.github/workflows/automation-932-related-generation-repair.yml deleted file mode 100644 index 622cc9cbc..000000000 --- a/.github/workflows/automation-932-related-generation-repair.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: automation-932-related-generation-repair - -on: - push: - branches: - - feat/i18n-ledger-frontend-consumer - paths: - - .github/workflows/automation-932-related-generation-repair.yml - -permissions: - contents: write - -jobs: - repair: - runs-on: ubuntu-latest - steps: - - name: Checkout exact branch head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: feat/i18n-ledger-frontend-consumer - fetch-depth: 0 - - - name: Apply bounded same-entity request-generation repair - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - path = Path("frontend/src/App.tsx") - text = path.read_text() - - ref_anchor = """ const [master, setMaster] = useState(null);\n const masterRequestGeneration = useRef(0);\n const authGeneration = useRef(0);\n""" - ref_replacement = """ const [master, setMaster] = useState(null);\n const masterRequestGeneration = useRef(0);\n const relatedRequestGenerationByEntity = useRef>({});\n const authGeneration = useRef(0);\n""" - if text.count(ref_anchor) != 1: - raise SystemExit("expected exactly one Customer Master request-generation ref anchor") - text = text.replace(ref_anchor, ref_replacement, 1) - - old = """ async function loadRelatedEntity(entityId: string) {\n const requestAccessToken = accessToken;\n const requestAuthGeneration = authGeneration.current;\n setRelatedLoading(entityId);\n setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false }));\n try {\n const response = await fetchRelatedEntity(requestAccessToken, entityId);\n if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) {\n setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related }));\n setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false }));\n }\n } catch {\n if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) {\n setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: true }));\n }\n } finally {\n if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) {\n setRelatedLoading((owner) => (owner === entityId ? null : owner));\n }\n }\n }\n""" - new = """ async function loadRelatedEntity(entityId: string) {\n const requestAccessToken = accessToken;\n const requestAuthGeneration = authGeneration.current;\n const requestGeneration = (relatedRequestGenerationByEntity.current[entityId] ?? 0) + 1;\n relatedRequestGenerationByEntity.current[entityId] = requestGeneration;\n const ownsRequest = () =>\n requestAccessToken === currentAccessTokenRef.current &&\n requestAuthGeneration === authGeneration.current &&\n relatedRequestGenerationByEntity.current[entityId] === requestGeneration;\n setRelatedLoading(entityId);\n setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false }));\n try {\n const response = await fetchRelatedEntity(requestAccessToken, entityId);\n if (ownsRequest()) {\n setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related }));\n setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false }));\n }\n } catch {\n if (ownsRequest()) {\n setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: true }));\n }\n } finally {\n if (ownsRequest()) {\n setRelatedLoading((owner) => (owner === entityId ? null : owner));\n }\n }\n }\n""" - if text.count(old) != 1: - raise SystemExit("expected exactly one unrepaired loadRelatedEntity implementation") - text = text.replace(old, new, 1) - path.write_text(text) - PY - - - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 - with: - node-version: "24" - - - name: Enable Corepack - run: corepack enable - - - name: Install frontend dependencies - working-directory: frontend - run: pnpm install --frozen-lockfile - - - name: Verify focused realistic regressions - working-directory: frontend - run: pnpm exec vitest run src/customerMasterRelatedLoadingOwnership.test.tsx src/customerMasterRelatedFailureState.test.tsx - - - name: Verify frontend lint and production build - working-directory: frontend - run: | - pnpm run lint - pnpm run build - - - name: Remove purpose-complete workflow and publish causal source repair - shell: bash - run: | - rm .github/workflows/automation-932-related-generation-repair.yml - git diff --check - python - <<'PY' - import subprocess - changed = set(subprocess.check_output(["git", "status", "--short"], text=True).splitlines()) - names = {line[3:] for line in changed} - expected = {"frontend/src/App.tsx", ".github/workflows/automation-932-related-generation-repair.yml"} - if names != expected: - raise SystemExit(f"unexpected changed paths: {sorted(names)}") - PY - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add frontend/src/App.tsx .github/workflows/automation-932-related-generation-repair.yml - git commit -m "fix(customer-master): fence same-entity related requests" - git push origin HEAD:feat/i18n-ledger-frontend-consumer diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9c1fc0c09..89214c61d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4786,6 +4786,7 @@ function CustomerMasterPanel({ const [copyAttempt, setCopyAttempt] = useState(0); const [master, setMaster] = useState(null); const masterRequestGeneration = useRef(0); + const relatedRequestGenerationByEntity = useRef>({}); const authGeneration = useRef(0); const currentAccessTokenRef = useRef(accessToken); currentAccessTokenRef.current = accessToken; @@ -4928,20 +4929,26 @@ function CustomerMasterPanel({ async function loadRelatedEntity(entityId: string) { const requestAccessToken = accessToken; const requestAuthGeneration = authGeneration.current; + const requestGeneration = (relatedRequestGenerationByEntity.current[entityId] ?? 0) + 1; + relatedRequestGenerationByEntity.current[entityId] = requestGeneration; + const ownsRequest = () => + requestAccessToken === currentAccessTokenRef.current && + requestAuthGeneration === authGeneration.current && + relatedRequestGenerationByEntity.current[entityId] === requestGeneration; setRelatedLoading(entityId); setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); try { const response = await fetchRelatedEntity(requestAccessToken, entityId); - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + if (ownsRequest()) { setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); } } catch { - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + if (ownsRequest()) { setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: true })); } } finally { - if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + if (ownsRequest()) { setRelatedLoading((owner) => (owner === entityId ? null : owner)); } }