From 6bfe4d488c4e18df411b3ff8834fa3b1d014e3ab Mon Sep 17 00:00:00 2001 From: Seowoo Han Date: Wed, 20 May 2026 20:14:46 +0900 Subject: [PATCH] Add enterprise initiative tag governance --- .../README.md | 32 ++ .../acceptance-notes.md | 29 ++ enterprise-initiative-tag-governance/demo.js | 124 +++++++ enterprise-initiative-tag-governance/demo.mp4 | Bin 0 -> 35277 bytes enterprise-initiative-tag-governance/demo.svg | 20 ++ enterprise-initiative-tag-governance/index.js | 319 ++++++++++++++++++ .../reports/reviewer-packet.json | 171 ++++++++++ .../requirements-map.md | 14 + enterprise-initiative-tag-governance/test.js | 181 ++++++++++ 9 files changed, 890 insertions(+) create mode 100644 enterprise-initiative-tag-governance/README.md create mode 100644 enterprise-initiative-tag-governance/acceptance-notes.md create mode 100644 enterprise-initiative-tag-governance/demo.js create mode 100644 enterprise-initiative-tag-governance/demo.mp4 create mode 100644 enterprise-initiative-tag-governance/demo.svg create mode 100644 enterprise-initiative-tag-governance/index.js create mode 100644 enterprise-initiative-tag-governance/reports/reviewer-packet.json create mode 100644 enterprise-initiative-tag-governance/requirements-map.md create mode 100644 enterprise-initiative-tag-governance/test.js diff --git a/enterprise-initiative-tag-governance/README.md b/enterprise-initiative-tag-governance/README.md new file mode 100644 index 0000000..b78109f --- /dev/null +++ b/enterprise-initiative-tag-governance/README.md @@ -0,0 +1,32 @@ +# Enterprise Initiative Tag Governance + +Self-contained Enterprise Tooling slice for SCIBASE issue #19. + +This module validates institution-defined initiative tags such as `GRANT-TRACKED`, `DOCTORAL-WORK`, or `PUBLIC-INITIATIVE` before they appear in admin dashboards, analytics rollups, or outbound webhook events. + +## What It Covers + +- Controlled initiative tag vocabularies for institutional admins. +- Scope checks by department, lab, or organization. +- Owner approval, expiry, funder linkage, required evidence, and reproducibility thresholds. +- Private or restricted project boundary checks before dashboard publishing. +- Mutual exclusion checks for conflicting internal and public tags. +- Dashboard rollups by tag and department. +- Signed webhook-ready governance events and a deterministic audit digest. + +## Local Validation + +```bash +node enterprise-initiative-tag-governance/test.js +node enterprise-initiative-tag-governance/demo.js +node --check enterprise-initiative-tag-governance/index.js +node --check enterprise-initiative-tag-governance/test.js +node --check enterprise-initiative-tag-governance/demo.js +ffprobe -v error -show_entries format=duration,size -show_entries stream=codec_name,width,height -of default=noprint_wrappers=1 enterprise-initiative-tag-governance/demo.mp4 +``` + +The demo writes: + +- `enterprise-initiative-tag-governance/reports/reviewer-packet.json` +- `enterprise-initiative-tag-governance/demo.svg` +- `enterprise-initiative-tag-governance/demo.mp4` is included as the short demo artifact. diff --git a/enterprise-initiative-tag-governance/acceptance-notes.md b/enterprise-initiative-tag-governance/acceptance-notes.md new file mode 100644 index 0000000..f94059d --- /dev/null +++ b/enterprise-initiative-tag-governance/acceptance-notes.md @@ -0,0 +1,29 @@ +# Acceptance Notes + +## Reviewer Checklist + +- Confirm `assessEnterpriseInitiativeTags` accepts synthetic policies, projects, assignments, and evidence records. +- Confirm approved tags can appear in dashboard rollups and signed webhook events. +- Confirm restricted or expired tags are held before dashboard publication. +- Confirm mutually exclusive tags on the same project are blocked. +- Confirm funder, open-access, and reproducibility requirements are surfaced as blockers or warnings. + +## Validation Evidence + +Run locally from the repository root: + +```bash +node enterprise-initiative-tag-governance/test.js +node enterprise-initiative-tag-governance/demo.js +node --check enterprise-initiative-tag-governance/index.js +node --check enterprise-initiative-tag-governance/test.js +node --check enterprise-initiative-tag-governance/demo.js +ffprobe -v error -show_entries format=duration,size -show_entries stream=codec_name,width,height -of default=noprint_wrappers=1 enterprise-initiative-tag-governance/demo.mp4 +``` + +Expected output includes: + +- `enterprise-initiative-tag-governance tests passed` +- A reviewer packet at `enterprise-initiative-tag-governance/reports/reviewer-packet.json` +- A static demo frame at `enterprise-initiative-tag-governance/demo.svg` +- A 1280x720 H.264 MP4 demo at `enterprise-initiative-tag-governance/demo.mp4` diff --git a/enterprise-initiative-tag-governance/demo.js b/enterprise-initiative-tag-governance/demo.js new file mode 100644 index 0000000..96e64cf --- /dev/null +++ b/enterprise-initiative-tag-governance/demo.js @@ -0,0 +1,124 @@ +"use strict" + +const fs = require("node:fs") +const path = require("node:path") +const { assessEnterpriseInitiativeTags } = require("./index") + +const input = { + now: "2026-05-20T00:00:00.000Z", + organizationId: "research-office-demo", + policies: [ + { + id: "GRANT-TRACKED", + label: "Grant tracked", + allowedScopes: ["biology", "materials"], + requiresOwnerApproval: true, + requiresExpiry: true, + maxDurationDays: 540, + requiredEvidence: ["grant-award-letter"], + publishToDashboard: true, + funderId: "nih-r01-42", + minimumReproducibilityScore: 80, + openAccessRequired: true, + }, + { + id: "DOCTORAL-WORK", + label: "Doctoral work", + allowedScopes: ["biology", "physics"], + requiresOwnerApproval: true, + requiresExpiry: true, + publishToDashboard: true, + }, + { + id: "PUBLIC-INITIATIVE", + label: "Public initiative", + allowedScopes: ["organization"], + requiresOwnerApproval: true, + requiresExpiry: true, + publishToDashboard: true, + restrictedDataAllowed: false, + }, + ], + evidence: [{ id: "grant-award-letter" }], + projects: [ + { + id: "project-atlas", + title: "Single-cell atlas release", + department: "biology", + visibility: "public", + dataClassification: "open", + funderIds: ["nih-r01-42"], + compliance: { openAccessStatus: "open", reproducibilityScore: 92 }, + }, + { + id: "project-embargo", + title: "Embargoed sponsor validation", + department: "oncology", + visibility: "private", + dataClassification: "restricted", + funderIds: ["sponsor-7"], + compliance: { openAccessStatus: "embargoed", reproducibilityScore: 68 }, + }, + ], + assignments: [ + { + projectId: "project-atlas", + tagId: "GRANT-TRACKED", + scope: "biology", + assignedAt: "2026-01-10T00:00:00.000Z", + expiresAt: "2026-12-31T00:00:00.000Z", + ownerApproval: true, + evidenceIds: ["grant-award-letter"], + }, + { + projectId: "project-atlas", + tagId: "DOCTORAL-WORK", + scope: "biology", + assignedAt: "2026-03-01T00:00:00.000Z", + expiresAt: "2026-11-30T00:00:00.000Z", + ownerApproval: true, + }, + { + projectId: "project-embargo", + tagId: "PUBLIC-INITIATIVE", + scope: "organization", + assignedAt: "2025-01-01T00:00:00.000Z", + expiresAt: "2026-01-01T00:00:00.000Z", + ownerApproval: false, + }, + ], +} + +const result = assessEnterpriseInitiativeTags(input) +const outDir = path.join(__dirname, "reports") +fs.mkdirSync(outDir, { recursive: true }) +fs.writeFileSync(path.join(outDir, "reviewer-packet.json"), `${JSON.stringify(result, null, 2)}\n`) + +const svg = ` + + + + Enterprise Initiative Tag Governance + Admin dashboard custom tags checked before analytics rollups and webhooks. + + ${result.summary.approved} + approved tags + + ${result.summary.warningCount} + warnings + + ${result.summary.held} + held tags + Reviewer signal + Status: ${result.status} + Webhook events: ${result.webhookEvents.length} + Audit digest: ${result.auditDigest.slice(0, 24)}... + +` + +fs.writeFileSync(path.join(__dirname, "demo.svg"), svg) +console.log(`status=${result.status}`) +console.log(`approved=${result.summary.approved} held=${result.summary.held} warnings=${result.summary.warningCount}`) +console.log(`auditDigest=${result.auditDigest}`) +console.log(`wrote ${path.relative(process.cwd(), path.join(outDir, "reviewer-packet.json"))}`) +console.log(`wrote ${path.relative(process.cwd(), path.join(__dirname, "demo.svg"))}`) diff --git a/enterprise-initiative-tag-governance/demo.mp4 b/enterprise-initiative-tag-governance/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..6e30a8180aafe68dc3766b8a40bdf10f2ccbccc5 GIT binary patch literal 35277 zcmX`S1C%De6D~Y9cWlq>*tTt(?_kHaZQHhO+ctK`w)M^LfA9Uy=}rnyRZ^*()19On z2ndMK)XCk>!qL_m2nYn|zxwB9GITL!w6m*m#ZcJ<}gtUalwhjP7MmAPL2WBQFCPE`-RyGD!!yknN-4B3X zRzXypmW5DIP51|C0xidm}!|9m{@;==1xv_T=ev=uC8=07A62& zD?=MPTL&}x|5~9lce1wn!Pwe4S=ib*auFIE8W|e%G7>rfOnI3JO#nt#w#Jscj9d&{ z41|U@hF0#50A2<+W-bOdMn+~rYXGk~z>Uz++31I2C$w{P|1te3^&L!j8R-~)jD8A2 zYYR7kiT-~c8GkJF9Sm*E0KANBgvRC$w$_IFKcMi-YU&7Z;-zIGbTW7NvG{Ss zMrdVgYiVfy!|DG&Arqmam4)$7Xa1jnfzZa`e~lPhSQ|S1*AWXFCxC;M;ScCXZe-=` zVCb%IY-??2==8%I|BR87gQ11ZkBc9mgW-P|QwKw9faA|-8R^@(|IijDyg$j(H#9M{ z`!5V5eIpA)$Nw6!Z~*)-Fjs(unYokEkIvQ(V54tlYxkr5KSH}7Q%ivRk8fTkR)+se z>RVgb{IG`yuWAbNs*B(2bXk^GD$51hC^}A+)gj$GFuM4FL)2jKQPe$cYzBw zqY5>>ne1fzUrC1_P|NN&r4i6!?gxe=jczED3=E(4>A&^`iua(;+^ou*7hb}v& zXN$2Vo%JS(w|a+WTk93HT)e zO)cJ^4ObAyr2a0EZ@7nCD#VTbGYtP0k5<1+b<40Nyspf6*_O+y^y3V zKCJ&&@k+WZ#4Pz*ErGxadz)y7=QG>iFnjj?vwSOH#O|6;#kH|@Da0UCC%)#M71s2K z0p>krEp;-rcD-CU=Y43PcgtH$<6+{bRl~uA2BN$E$0pqB&K51iC{m&prrZI!C@gR^ zS7a;4K{JGc_;9I}H$aXpCXTehIW+FnHF`6jrqe0dwy`n|YXVOK#P=PI`ez3DIxu#W zW-xB2T1~%@8b0bkjx2*(a!zi{6Lm+Vb3{rTe96dTZA&`HgBSw8=6NnHlv8HaWy~sFd{Rg?@IEY2ye~osA-( z(|PX9uof)g(bCbao`M{oQp5e@CHB3_z=YCamV!OeD!+it)5Ri_b+fZeEjXjr@^@{J z4*8ma6AQK)<6GeG8+Qp$b5%1AS{UVoVl=JkJ}Y7$RTs?(K6%phr!Zyx*xg86vG*Lk z1{?=%iTc5^h5oda=ulJLXDFYHAcJFk47j7{0zPwr7k5N-B-A*=@-QX!LF{w#0Wk?3 zVl7o>qvNm%%1buTGMK=*jLslC69~FY8}^RW@Lz`A+57c}!p?_AE4*pNOYmT`>o!Pu zGm~7k?Gr3^2ImLc3`C<}BQ%=b@9g>3@PFLg?;+R{1JmAJmX#Nf-@}-8K@VCV%Q)2S zD2xIX_xJ*GYSzlO?gOI!gy>3?&TD6)Qig=oO3p(5#?4javgV+WCMgO?sS=Lt%zB|X zl>Rfm8>hZ$ew=)aVUOiBQpaJYm;XRw>4H*SQ8Y zbMWM{IBh(!wa>qRD?%|3*|=$z^R06wBQD(0ZhoSZaj({P2PH}9;?zZ(gWI#J8p%(` zQpO3^zMF;{Bp7H1J}4}y9Ga9lVv^T1#a{{`{T{5G>7lbTZFOv61r%!0#;4Qz;CDtL zO?+Kiwnx)QG6o)06)l(=^P6;Fp*xdoXf(u`)ASA61(ixfcK4KM_1!U8{We=MNX)Cm zZWF^Hm;6z22InxTlT=(R2m2FWHxi0DJy;uvW8~tgdW;1p2l{yemH3OGAYTF5zdNr1 z*w*>(85U+TAYCTgUD1ISxNfsZjcMoywYR!|$!E~Ww}M}$2jLo+LuyWvt2Ec^?LGkd zww1Z7l}%dfe>CfaD*`BpyD;k&mU6YNDzb9xlsOZYXYk%BE0b$lA-Gb|VLpl;p?oBw z&(S(3*qJQ3%xT<-bPA5I-HD-bw69Ucxy{&KH_932_!CPimP_@e9apY>a-UUilMKT{ zCc?kj&A_`kSo^(a+kr-kd30!n5(Mq$dG|qoPJB9DuNyLR4LyJyPi%Zf0zx zumAY{7!Rq=E2u@{q48jMsKo#eKEFi}B_tUIZk4GQI zvYLD%`LhBi1-sTVUdvp=Vr;I>gV(*pp6^(2#PEt3sD+#2N>CTse~CxCq_E%#h91@Umw{7g`gnX#qqZ$~Zewh6FX$HB>0yv3I(ShKeT zZwflD8aPy$<$yLh;5s!Xx}kw^P|Ub;w^lt52hJ;+g^frY;H)K;fd)`E+urH+T*UEO zA{v2%>z8m|NAAYmfvFp+2iaLHwU_2~Bj$|lMD17pZ1i+N6BWpnwpqif${f#irRSbLFgxQ%> zoc~OKf~kF8jqDGlPlMa+!!h3(qy&Nv^iHnr=OYXUfW2Dmt(WXC=cwE6x@_P1sVQRp zEMA<~-WANjw?%_i_G+aNIPgr|rk`W=0uX=iZPp>#8+HL?CWRtuIi7ZAv*9;PNPUEm;_H&U{(2NF2U!6{K01CGgo+OMy%fNy0@`C_xvTSeR z5i9{lwL?q9OF@IT2z;cu`AmOF3G26k8SLS_Ph-%?$*T^p>^w8+$_LE`R^E{jC&kk# zt2hKlRxA1}Z7WYWeLTl$kRJLx%f4Yw3T}gOUMSuT#M8v#rOB|U4%?Z#PBj|N{Z)7r5w;WXAW3NFz5F&VM-~22+wwhkz!+E&EE&Y zh%L10aoG<_z|OC2;@6euN?#Y+`?|>~*3My&t_KP2g72a2gR5~CM1#0~e7_lRv8Yu~ z)k`l^w?Q1`suGiddpLXuCYtQz9$W_ovTuBX08Zo9wR2ZEI zY3I*q%$Boo0|W8aI`7V8QnVxG=M|bhUAS|10?lVgi8FNL1QMQO(8b6i3LZ2oOG3XcU)hu}~SUzlCbn!PEm zLXw}x=<&x>bY_CdE;BHKq(3(xbmE0_im32b*DH!P`!u}DOm5K|{lOz~ z<*aIww-PII$22TS{$d5w}ud1KoT;rnbk(HdJ=dP*XYzm%66bupx(! z+Gj@q+|~LwV1S^5>a1q}OPrl_b(Y9i?YTS0%EdvxViT$A*lgjYK4=4g$r;20IN2xeknqt5JyxiiLX zi##AGwWi_(?B2~DtU?@tm&4f1|`6K!bt)MUH!4c zFOh60vX}CcBY{hcY1g*Y_%~sFP@lXoF%4euFLuPTh^@@-P~1_3nemCR3Oo{%7C{!` z+OfHpMm$>=1Bs_P0^AX4^R}hdXib61CB`T%v3RaW$WV!tZWh?7CX+o1eHP?^x=vP2 zCve$gg`1lm_&#}(o=t(0Unk&JhpY;@Fg8)gzLZa-iV;IpmBZeu$gHldl=;>jaTj5_ z8H+*Gjt`=&QT6R3xiMy-BKez}7dn&rqp3HXl6v*5zDed z7`=)cCaH@Xa|hE4%trK}LR~!ezoDKj$lGLu9I65h$~MqxN$OFfd!W9q5ul)ywFx77h=F$e(l7r4i>x-iBF-Le>uj`-BNVAA^MhCCQwq=~)D0{gIVWZm zcPUKLUQ}b!K8Iu2pchz;!K{1kP)J|+pV$lC%Ou4owC+!_Ez2o&bjd+J4%_q2(0iHI zjbC)Lq@G2sJ^2aQ9)p%k5FQrclUQ)QtId7Ua6@97#n>MXTv0hU?aMqp{EKt*8UgTj zz`SS+PN9{L6!+jcN(y}~1-xC+<&1%Zw<432wkcKSvS~Ulfh@QMF(~|sUO!JqY zs{Yq>GJjJ7xnLl@!KSDC&R=vETqCTaU~@Q}6{1lcoyr-cuRS3!FO)p$=<)qXQmc61 zdJ&M`{BP;#)R@TdUkw z0z$6NFLqF8g;WvMF|)!E_)6DiqWg8og=VwY=mRby^^O3ryP(kyymSb+)?3z43ax{x7q;rWf+27-lMb%`Jty z;lLDa4w5+B@3HY@lF4O4_b&DsmFXr8k*{8z1;;jx{@$}cNROQP^7ynT9}lIYVWBv@ z;IbDq#Icbif0GxpXp*nox;4sKxqFpE5&Cz{r*AB@3*{JL@0LKVF}52jHGlPL0u>K` zvgU}g4W~vT z?PRFof7q9)Hat$UM3mNf2KP+a0ee%o&uIGg9PuR8cRsb5DpeY$k|4gH{m%`)fN`;ia?Z$VFb3zp#6Y;F3$NQ?~^QM=2 zD7rU05??;`_bK{B^%}Fos_AlS(p?UdUcy!(9S{`MWx4wK6K)WU^oc#CXli+O}q3u#RknKoq}A&g+zhCM^ESl zohbaZ94e}mmo+W(VhS@R!Tfv|*Uyy0dw7{JN*AH$J&Y#7F9+ zccV%I1E?;5)sX*of^FB4#d3*UQsEMGr}8b*)=dA3O0@@>VEZzm#*lVYyF>I(QP;WE z%Pr)8GfyQLg50=u58{{4i}2+4dMhpey=K2vyt9tXlxp{FC`rckLKmJatFrweUtyn#biE+l;q(Wl}*XK@zeJF+Iy{@UIA+Jh-ksQ^AU ztX6OD377eeRF8_yKj8~dvJ(2J;AelSD>BqfqcLfY*?KpIy(tC};1X_&FRwEY00M$l zmgwwEc*6TGD`Cw>X-V~p=JsqMLBZ9?xUH{!4duL~k&PqYJbTkz5@0OyCZ92Cx}N7w z@4pq6%dJreSFwdN>YNW-kw?b3U(6dIazibF7J;mX@putiIYRc8DGyM?fgu&BiR2#q z6EVLehq60q^bg^E?Lzk#xGIavnFIm_g)!&5#}EQ49cg?fcTgrPOw8@J>-cL&PJ`zXBlcs<7TBo!Q|-+9}wg+i_}3a}ch7J<;}PnP%ol zK}h|GXancvHm@>_|%Vc#RPMia=c+VXm5L)IE81FZAF+72$qm38(Oh@xgH^jqeQ zGF8l#vSRKqSXrj|XM*!>m#&iI5TZ8#HIt#G8vqW-=7B6q_(O_1+oA^L@}diRDdk#* zlleP4#Z(mKY#y+OSa5UIt4rbH!c@pR_-YhqFgX|P+n*f-aV)_t8TfE{WMhiY@HOw> zuwg5Fs6|zEG2l<(tW~EPf6pabwa#L<&lQ|!TRt2BU4E}R1VP_;Rz$gc@K`oS#I+>| z;a|D}iw7cJ7C~~{v*_)ip)_VZ_2@# z2(~M?f_~8htg8Vn@#^_mM^%Qd(~sT5@PwU+2fk8X4vE@D_2>c&dtu~ zRE<^^y>K?&awJXHR!Se35Q4$XR$q{2#jy6qjg}swrxGi;s|P#Zj1-%?`W97_XFSyK zvA1`Xg7BXBQJ9fWMmKKTGeGF3@J3?x@9=E9bq3gw-aVBm2XLpcVlGS2T=ugcs_t^s z8nJDTQ-Rkm_+Lgq=8*P~zcQV*V9J!Ms?>hP6*IKR*)c{u{HiE8_w)4W;G=DDT9BOY z#O6J6PXuE`!J%Wov8Yp(;*|pKF0V2e+Y>fOZ#Dutm-$Q$P&N?%6Ro(`aZs^bQ z$uQXGhi=O51{uNk%b&wlr8cZ>A>{$Jl?JpFMk%78jY(u+ zN=`OrN1Y`Ca}E6`tpY?G-KfP?>f}?&%F>9h47ghI{ile|F3@e?oP#^z#VFF0XFcNm z5jR>(MIW)mm?IC%NXlrJeSWAx(Xq)0dBkrQaRy}ygD56>S01| zyG@9wgtS_^cnpv5WKviP!O4gc7);UjX9o%bW=*!_FsX<3gUreEpII{Kn$ff+$5iu{ z_;51nC5>@MU`%IeCc$x?+Vhv{3RIgS9e8zV1Jpp;fo%}h-n^`Wc^G;iT4CY&2 z1gWB5ig!QBM7>#WCI~lSq0@(DnK#q;DI9k=gAZuWVpteu5Dq^%X(f!(B8jxS>>fV) zxNXs`*QYbOmBg0OXG4g)VIWk+)iS@_xYu*(y5^{}705pjQ^<8VdLWIk_0iBXyuzLSD2&y1Osw#ve9iX5a`zaMa?c?%E=m*YbV`DkVRy`4|)z9 zYgH5y#xh|&V1e}tzmvM`sj_%LP(ZF~{T)q~Awaqh71t0@yJgKFDj0(|2W7hwAN~TU z$&%jYsC&{bwph~_&Bw!HUHi}PfJ?>!PmS|A>n+w+Fou2)4$M4#(?e4#2yM?g5_Lu2 znp(>B6Cf{0Z~=NO3){PN5JO~?A<=h`gJ*V$~lVY+fF;zo+Pzf6R&dWs_S zD~A7UGgrYA4=Rdad11nyawmM|xS}*PK9F?VvVthmhE;H3wo!|F55XzH#_xJ_F=uyR}_T!VGenVIi1hbRd2OT7blkV$iEMSV6&q58wHMcr1l(R^F&J zyRU4kd6h~J) z(|b&st#Mg&RNkm&86q26b}14e6&c!fy&-%H{6a z#|(*5M^sur*AEyep%l&Zxfih!y|)RZ=WB89d?&6@NZ{)=Ua_y=E_(;CmaG+) zF2~WtO4}jZ#<}-)aF!C{+uZQ}m7uUYC2$SyU}Hw+ac#)MdG-a_EhgJ5Gbr%;Mdu;` z#l7M*2w@(TzJ5Bw|LKbi5HpCcf0CLCQuqyZOMjW^;IbrFX}2Zb8K#xg`9L?{1}`|g zrJQh(4{{~WkJ5^g-xmlVLxvJk2c44fF&V(?-xEX2t}TOTBO#;Yd znwaMKV)J#x)bZF?FOP(9d!=Ypw9%@=MF@uoj$C;KAk8z|pWtU~anKQs=z;f3R;lCO^HY=o{R?~8eR2Pi0@SBMx8ppB5?K6DMs@)GoV-ZT#>l~N6(rzp?s|I zx6WeVu2i0$_0I%kE#fa%cS@PvuXAB1dr)}dr8{p<3LUzyqlb*v&RdU-OcaK$R$b!D zv+XNBv}#{<%J6_~`rVkQ3)R{n1vSZC^Dc3CRKo^&KPw*+UpSo`Kd9Q@&4_Bri_kiu z26hfN4_u&9GR$k_bg^fH9)vfUbVIFuE{q<}UEEdgU}NgpF$O&6huraonfx5cfn?_> z#gSak8JDr6!`m)X&-NBOA~*P=o^l!RWGpfJqL)e=x>OQ|ONVy)+68V97g%v^)66zo>YC|;V?vn@F{gb%Gzn0Eh&aJ4!ThYYHWy1fX}_U7(r zOdYH4r22)%P59YkrY_n$#KiVy_`!!*pl+_QOlma+D_y!&q{rr^ImanagQNO8DpTeX z06^)VDJ?te$^T0Ehx?3SMCKGRH+Q`}goM5w9^IwW4?52fP;-s`;hot*9p}4H0p#8| zzOV}8nETZskqD+$cDD`e`PNCn>K4ul&SYVLcQ+;xn`Zb~lIO}+!^+)d2$qJ%o;tFJ zK&0qD@6&S5HKb^%EkRVU!`E&wkAydxTgZL(&WNL;WHBj7O{FZW?A@bzvFY#FT#)K( z0DSAQ@^iXzibQX342GvaG!7_jAps@OKbH`UR#$qc<4-vAJ-u?R;s4X@@4#%}qzj^N z@=jXr_V163ukJ7d85%X5F=}E2$vseyvu`*8`N=1CDiWGgPL%sdy)G@;?jL!$P3=bL42?A z%AK9OX|wNV`347g)K`5k{>Bo;C!RmIlZhpuEVZ|wJ0+Bxw55C* z^_x3|uF`7zVb$QpBTY&DB4`i_FB;cx{_g5J7yOCs+R_l+5Zz|jhdwA-Z~-Vko2;P8 zWb(M=GYuuW@r*|SI)~Sg>IBxLS-$d%BBDrmfkf+4QWB9R6BDpb4$X6ORP$1t9sh9o zbc#QPq)@Xu6k&Gt?dteC2HOL~H{zM=s=>zgk6ezh45Rcco@S^oyI!mXff{+xi%l7$ zB1dqImlI?LlZ=dt`44UxS_ZzT!jHwk(q)uxx!9f!&kOsaU=H4?^>$1xa^l)tuz3lj zTj8U=%mX=oq0wA0Uo9A_e;}3&3aZpbUj13;IFmM`qsoiXZfzqM-NwBy25U-pgckJ_ zatU1vJWp}nv6~P+$($1XC+r}YQ%S(ku`zB}OvXaNbphVS>ERDCb{Eyjw&rhXauoYT z@Zed|DKjDDqKK`5`W;l~slESEpLUn?3Q`Gbo&ZsIU08QrgC3Z^ju^FvRCmO`ge8T7 zM244PIMF52(Sbj}v??Vy;&(P}T2XO?h?%JHiKI;vH?(nhCItH_+3S?%Apc?K74y5u zP}hjV2op>FY}+3t`_7JqI_pJ*nx!bdZ@k?8zr+7nMSI zpiV6)Oj0z)&@OZn_3?kCxmA#O+on9yA&LBza2PMyY>R=pzHW%=_1;ZOr08;_LjS7T1?9AW)vHR?7E-19*}iR zzI)aPUo_>YJu6X^H;E-Gt{zelLs7lR2K#wo9~(Fs#KlJSaBcWt@b%f-i5u>_6yPmt&@ovG6pg|V?l;EBY!cQe;X!;P*77s9M;Xs#qqSi0^HMi((9~! zP5iY}bk4@UJ{CX=Od+s_YMKp5Q}-0@%O`0g3c>6cu(h16J}N&5p+BjAxq{sI1W;3WNU?3DZF)rekNUs1?6kMer16-L|O| zwait|#F`<7q8=!joku6Zd;_|DwNK_6kDQ1&iKNu#N)iBxKxCIXSb%Dvn+AFmS0yB6 zBIJ{*p=M;AUxyKr3Ou?-6z|R1heMH3R;q@&v=uuxN9$mOtgwV#IFXQ>cmn(sCa!^% z^cx3^`L$w!{OrDNg20I*ULP5)Qz>%y#(M_!17J zBU}7@4!YC9)CfeO@b)?_pdGtFg!ZC7;*}?s4Y(C!Z2CfL-eWv+Dqg|^AWR5{Z=oy5eb3rv0mqt^%@|D5w zc}c4u;kT2XVmZ~Oj{NC(%WGV%gm4^FI*jjGIzP8I1a|OjwfQ_6(1TIPgF<{I1~*`w zAB?O5*(HD1^Ry+LQk>?!Z19j@wEf#&%&xA%2yVIR#%okIQR+HhpST~zs~anVLr2(5 z<4tC&s0lvzLc^Xi-KdVrys;J@H2}}`lMakJ6aWUvHj#u0R58VhtJQhi{x(g#$jZch z|SucHA|z`%+3#~M@fGXeV+ie$N(Fo1$ar3xSfs{B|?o6F1*D1Q$qUqd<_eQfud zS7-A|I-Qzi**K9oSuRntb*u?Mz0(}L@BM@Z^d_Qq9=RXVT`K92rag40uMZ%a^%m}_ zl(-IH1|Q_RV)F-hB3x?8{!)BD?|@Cye|OgqTqwr{dvd-j3v1pazOEoOz)Lmm{fV0oPdb)cuOBv^h%v1sb;0a zru7(#nv#8^pv~P7igu@L+nkug@#EYL%F2ZSpQAaNjAWqc0QUmp3%t{*G)6}48=zk|bn!aRHW`7d1zs}cTiy@&#>I~E6^eXO z%~W}trpHBvkHB!H9T#&Eda;shM)W~Y+b29if9RMyABIO(nk;a2M5|E}(`BX!=TNrW!IQ``_h|LCM)qf2@C+UCdr&&Ra zVACac6;=vZ!9Lm8jZdekKEz&_QY^#QG)?q1c-~JRvT&d7gk{TQC7anz3?AGo zt+1I;un~uA_<&1Yf&SRGIh)%oGAR%FMic9~%Y7==Ti<1=WBn*#m66*$Ii#ztF;{T< zTX(Bq#MpR7jfdtVvF`50x<)fNVc~#gk>)ZaEs(nDSEINiQi$1qtrpnoZ(Uh3Ici6V z+hfLheqv$r+N5@ZaB3-!>sdAbwg3LgJhzrdbqLU$S<4Da@#7%;>=~5~~c2I^cV*gTsAy)yb)nc$4Hl zAs%-Si&7~&?<{mO7`%uw$eP1h>cHUuruOml^9&&iJG%V$O!;Q*!V#yIL`h`2m$6vr z(=Crz=#k}bEMf?3OEC3GqcWtI&5`S6l}nf@m?q8gY!kGU&#d2e9H7uCchOBB8n;cv z8lkqjrT?JgJ3u5ne>b}%xN^=F>gO;kS9S1w97h3_^{I*qM;Z&~;y+#psq@1L9&d~6 z-S!7;f>$RbR)cov|uxyXvE|k1=d7vX7SM#iY`uRn-QpL8~;cQp=FN z1*rdR2t?{G6?zsylvuoVN?dF&ml$8eSd~+DDso_roQV?;Eee3DBVE;&BPZQ7PY3!< zod45D; zP9QREPh`UN{-s}{aWKaF)$XZUTe$61T#|a-QmzutPDm&~MKGY3qz+8%9Kt7s)+^;P z6|z#MSS8momY>!r0|TvUYE^w9;ps~1r0e|R)7y_lFDR)u*|`J$1^2R4IlX>q)2MU` z>bYj#ywn{q+BXon^~}=^RwX&xJS!;~UvD@?q|X_oq2?H`Dzk%6nF-v}D1P`DWT8Ug z7x1^J77|kKvIHFXt7!|;RO&@X7oQ*a(v?f|TLP~8W;fA(dB7)=f4Thw*h1Xif0O{{e6R<;W2n?7dQtQxA1fAZnrFshq|a1C zAM0~JY*mH9>=!n)u9F)Q#|uy8xm=0Z*THY@_;d_J7i7Nl2%0VPWTiFi7(be$=FOnJiC(xBxM znLq}Y$@NC0hA9$PbS#yN$619=%9SV{MW>!gqQkXwf4S|1_A!ez%G}}+3Rc<15B4hf zAqqN1Y95s~MnNoc#G*gXTC4&6BLCRk#0~mSC(~F{GDiy5)Ifs&p;ZbU{D#&ULSzie z7)$ffhO^!s{kD6bXMhR*wsDd@#5j@6X6NfHn1bRjiRc}-!I>fy$=TY4Qr`K(ExFXx zWn9+n>{)`b1mv8nskRT*xb~N7GBfgdQ5`(cl0!Ck^5bgL5CoaF=Y|hTwBzLDn+!%+ zUB1DP!Z4RV3Dx!qJBG9o?$@T-Q3I1CzyD>C5~AJ$rnF&uOqaQS%5h1VuUvEz^X1|u z32&dkJI-D;+eA}6MZFQ5;3o4xwou|I(ZXstyLL`bjHJiVv^sTM5a|S-#-)fxECRM> zuU6LJ_BcS33zuR1?lT~lCgwgcgomX}U|B%GUQ^!vb}8bkRTsrTazGiAMG#2x9K%IG z{M}TW?g2c|YbV5hc#*Xz+9XBob$;!9kw7ezw2nwTiBWT}0ZTgxTx{%`S{s}lUzn~J zRks&@!;v$STl@D&>{l-jPtTVU=R80jv2LBB!~CEqK}+etw{Ls^voWpB*pMEe?g`Uc zS}f9VvRwU@!e^8URpGu~70?ydK$ndorZGa@?DN;YWosuX#4{`5j`fX##mweWgNrF6bU6Oh>j1*=!7DC{7B?#UY$@e-2KHdL~Rd&#sP zW)zxt!&UMX(W|38kudN~%LlM~VD?#ObPS>kL3OK@(qGR9KQ6k~T8@7j5&T~D zhLFcdwD3?>J8TRPx%@tZ0gHEOE2F_cvkVI)NraBDIFAf2sg>IdM|?t~^gPOYbTe*; zI=zIt5g+_xkFX72{h6~ixTIfCx#{GkpO=)ShV)`+n+U{HIeDW|j!B?yPvjtadS4hc zHAbd?eTpz`vs=Qy7+Hpa5%!lyzeBUK6&dZaF&6oX#?mDjIF)AMH?ek{1b*?~srOAP z8lT{Qe+i&IA59gZ%lQ52bMBzi#x^E)DNMAt@oEnE=4x2a8RTK}ZG951d50 zM!j2HOUk#uZPZ1VhGCM$SgfMj2@4bHqvGyOhYLkM!HzE3mG~u$;-hulh(J z!OkjN;!3}?Kz=m)O9MW;8r_732aZa`tu~0&_rTsqGBGe-7ITo~-Ziwn?06~Lr9>-u z{7=l~#=L9i9XRVu{%5B`X^733S|rqP7daUo7!sYJ(gFhQcfYU3P;2V!V_uNTw9|fv zeEd`761U}Y8aEVz5i&n~9qR2pnge6RIBc;yu<^y+jp>X8H(Qv6R>-PZ2uhtp@qNcV z4fDRlqmOEZ@vQ_ea~np+No6Y?duU>>>71#PF)*;o6?|}8eQ`?Cn$C_ljM_@OQmLHS z*z$0g--YV0@=d6nEmk$zg1XDtd9|*{TSaUfTIFT>u8*r}k8?+dJ}UzI$Sj6@c(UFZ z0??iV(9pv6NMujAyeX=w5igY{bcjiwL98o4B9zp$$4PZST~=t*L$^U&MP|z@B;Nh? z3{Wcw|12Hl5+q&@A*mf2SfoW_JDDiu=KznjY@aBkFlb( zDx>7j8E%2$JYWI%z$5b5Rz0EV#?!o+zwbwN%5P|^lVm*zelIDrB6nKuD+O!WBXb+G ze}1*dA!>=r5ykI4s(bt?X>#3&;0FQwa;=tAA|fyCz$0WFjlw{*qAOsYB$3dWG(tbdC z!L9$tA}e-~OV$3yMvg}pxr`g~H54JglCW_AQ~~mX{NE*LlwYl1dVYehozducwbN|B z@o$aeyGUX2TCIr09&RAFr1<>!&?Ea|7pRFnS6oAozl(fzRCFPsr%FjqeGZK12$j`0 zO)|jGx|cw}|4sul>R^LxkoM;W(Z+-a*&IwKfV8~~ z+i{ckN`ThdJU8aL?n+c74=lLkIEBC~aAwMDqdoUowkbvuT{s4$53z-+j zaXgPv^U1v$lg}i*o46u9U;}LL^dGmLyU42w^+!b#i^7X zQ1(exuG%w!>Hejl+hKyW+HKN#C^L9kL;nyLVEnYz)%FQ3@=A~p4cwhFrI)blp03gO zRtEyoZ_AIFi54#=E`Q8BzRcZw`G<9#+i4_$)_dDY#hLc-h1!I7=w`d0%p?uuTj6s0 z;*f3Yep*bsh0U(Irp|(OrV>@bA-}mX-7igW)b}85A!yV zC@J1xsN#Pw;4m{Dmn zY(-Dw&zz-w>#UVX{%97_%o$2GolyZ#Eza!!*WO!4#nEhgpo0_KLvXj?1a}GU?(QBe zxI4jJf(5q#!QFzpySux~8o1Z zX6OudyEgc8gXc5FjOWW+U}FDTVp%+~po7JH?cD=Yyas*cUAk@Gsx_sV?pvl zHqno1Ys-+UX~iyExaCXYjqH7y0g=2eLb8Ry^9f-OnjTX}Wqd<=a)mHk!N9{4TK$5z zF*DbL)B5I>%h$B;j=fH;&Fspt2eC2p`g%4N85uN_M)NTiGQ9f)zE{u5Ibeo30l2CV z4<`ruu&9(XSq8LT>lVV@Yc))JAj+)`U^(fJlRpqEr}LLSBw+h8X2*|Ag(~CO9~fhw zO7f?R7P0&M2#eXm9Y8FtC&)Qm;XS4J4yS;U>FE)YZF=4NQX7OwO7~e1_M;*l;hPS4 zPtACm=h@9ceBGF1ER(nb6dAna54gyg$PFowSnU#Gq;xw21Tr}!Fa*S+Rwuz?bQ!d; zMjenP7wX3m2U3aGy4QW28alnsSgehUlLi=fTw0rMy5D;Rx${YFUtBH;Y7_zuu|tR6 zw@Zf&3h-yT=`ek5q%!2tv26Ub0@20Wq~is00iQctYG3c}dmJVA?xr!3qz&~nMVwpl3S&yJ_foMTSMSu>_?9}$REhvR(ea!^Jv17fVsiCRxfpMvg+cuv;E z%c!&QXWQ8g!Hfl#z6O!usu45eCYGpw3+HcBE(^8Lx6(hZKl~Z&C?KmkqZ^xmw8~dNl6$ZI?gk~Eqf|TN69ycM4g=b5g%F*lt(}BH0QU`Ok#>KY zdRfY;N?NFwCo(D&e94WRSA0_Kc#X&5(6gr3OgHqDwHE8lB}asMfNbNDr_0u1KpvQz zL4birNQ?k_H&h=y;&wL5k>od@DD){kP4q_Q%g z;IJ}=bD;gKpx?GG`T%-7@%hoW9MYF`sKEnnZCna=1GJ1h_XpZ{arCE=VabMAbEU}|nZV559(BG!)8$0M;F>#xK=)dytXS_f zDWG7noW!gSx8bWidy8E0)(;JM3(xr(4?%3{%dICY3XN!5#5w0EGH7u%P&A8A&vC~@uz+1<*qb7qAV8MhCo!Gj{knq(Cf z=44Ao<2!=kDk&Ug6u<=1Y)PUm=9WY|r9{tuMptT~M;{rVK~}^R)X&><8FKN=@IH6K z490@S;2zRg^g3=~1ElzhEreq+ywjjkOd^Lt6ZZ~K4V767!ub-5$qA6Zt3f2dCwvFH zXYk8eF!D`feAd55zwcC8MyY=(jl}VwXhSRuA3u}47Ong)q42=f;s-tSs%WqyPDZ#jr`an=Wt%8N&{t&^$Og`3StHc>4VoqkB@g~M-oQg1 zM9`HvwiGcw%t}``35WjDroVxvEB<8(xXjr+*V&gTL^Y7@W{u>>ae+0a^N66T!Qp-G z-B*zN(C2sseZ$G0Cg!15x&_JbB!iEoOxbpYoh4}G`a54ep3@(P(cjY-g>4t?=Zr_u zU_?*HORZ*=G5O&_P}f`BiO^MVCsHnw)-aQnJ#AI)W&$84j^Ci4Eqds79@aAVX8^!H zu%^NA!a@6urvpHZ-4vNl*8xK@qF^N%^$>NywFFh8+We+%QSTNu=<>t>M1;Q;=)xz(Cp&`<`V%bcl(`f zX5^flRnL%Z`ES8&I(%@(qu*A6eNrTegf=8#@utlN@Ux({xgkb`+P|cKbOm1$%G8Vm z+h=h4LUL_i5$!)@n7amuX+=NBzDM5)0wZO@Crrl_P7bfOI4YBPFRr`UWqlgnqmY>7 z0^`f;jrZ6Aj%S3M#jg`xf&g1&aY}cW>g$x2qyd?XTl6k`lX6O-W(zL#*+?2zMAY|@ zPo|Uinfak@nVrg-+9-fvnhMFU8J`-d=*gnOs7N7h&BoKrJK%Z&(e=yB?deY3xFV(@ za(%@quS6)o(M7YE5XD^z;f@WHVx9FVOB5++5bOQ`#@+2E`lG-}4-!46K98n50#_Q| zAVcEgSA2<2)?dhs8=;tXCbP`P@Klt|&tz$27IPR^oRF?7SLBKU&CHg;Pa5`llp&_h zQmWLNatpzHk8ETw3PP5<8^4%^SNnYC4pg6j0-`_@;gU$R{h$W1aLFRH>;bysKe-&^W$Iv!B|pwz1$*v%MVbQNY5;R+aTVa2Y*tx;EkV&eeI{BV z=8k9a>{!W==8Kw=yZtdS16OcT>l#V@HcLf2M3e2-_~icHar+_K$Lh@w1_QD57)1vt zTc3q4dr)PeN>_DXjTh19f#f45u^@{+ZH2e&0gtirCGzhKZM{y`O4knLU6AXj(bCHJ znXnT%7k|Hk!(})ka^ZMxl))R-$KMEXo^7b;DXfkEr@MrR#a&? zvxo9&Y)Eru28U>UlE=SyrZqbb|Bw2x$@5UXzWb5HugmI*aM>b)oBfueTx;ig#D z2T?II)jSZL%}MV^Vo=u+lWjSt@}6D&z>yoQ6l8SOy7(QFS`G*b6`LToKoO@hK6sbR z3+bg+w0fse?IBjZ)lPdSQv%~WJ|-lFfNrEfcstjIJL-5}Pn@)Q?^h~%RJbF=N$#x4 zQIvJM!Q9wVnt$&HKxJvx4Cp$c2u=M+nMRm{zsDPF z%|znvCq7yU9-&TT{llExg^9Od z0a1K0&YSKUa_{;wc9mT|RLRZw4C$`CPu%y9M0SLlg?+*+8=9Wc4msKr*-VYYB=xAw z@5~lG5~E!!dwQg|R^?R)NzCw@X2c(?Y01Bhc4*aOt6Rvhe%Ya46;^ef960`Fo3_I_ zr(Z+>1zMccdu2bCW}&$rlZVmJs6WJXQ~|>MIkIuzorRxPf=pu3xF8v@Yyl|&XTc99x zp3=INSEoLm4A^5|8iVdBu6B?e##T1(`E%g|&z$pO05tyh9*O|a!r?%&tnEyH9|W^K zb!EG&JX=4bL-;@V7s#3QdYBFmU;IcVbyfW|3@3}EVu?T2AWAC^He-1@-OqIK z1(7;mC)b(RmtNmYL4=})6=EqFJetcd1WZXRxAC*I7wR`I2%6)7P^@4pr*K7xFAd>@ zyQ2XqCKkq)I7IHtZzdL^1c~S2C#r~HYxubu7K^F|F9N5+wBB{}gkldms|@%2sIq8K zwfEpJ!#s9)K0@Nn3S%BXDs>yeORLmr1}!}ELd7~XufQ_JYrUBmH=j@7$lvezak6NA zC}N%L{75BZhXxVfi9}5n{jh+ILiN=a?$K7@j8*Jkn(Q1$fg(B z`3TS%NKapzz0sk+Tt7Wyh)}U2FoDE~BmiQe8ACcLYlSybbOv3@9V6X_ zNk0A7$25XZLs|8Uk!hv(bD))8S;qlP4rd3{p%>PJmy*5D!0MN*`G*fUG#YH_8_L&b zcd|wTBWr|({s-#yrTT2dx>Flor$zU7cQ1Dlor-lcr1 zm9eRettH1e+N_kCMYLT5Em{e7a#hkQ%xn+F_M!65v# zOLZyr3OIKCHI=3D32a6LUMZW5OaT>^V zMQSp1xRKD-81R~h>td*!pTW8V2=MWl15I{E%?#mtvMF7}!#EzAVBNV4o!(3gwz$sz zblg^JUlU2HCSYlKIXO|IEMTyXdVCBOgY?k?dCSGBA$VML#bs0oAsFL2IAG={4ra7K1+1O1tJAmO7zQ-fd>{u8v&>)8Q zg5^;eJGeCN#BAGFV;zmeeqW!s-f6fgJ`r7!C2-+3=C{vvU^^mQ@NyBWgYF&?da9tU ztzeKSJyD>v0L>#tz<|~4wYPI{7)@B>1k}Vebt$L6P=2x+?YZ-Y@lW-GNa8TYi+F;6( zEv1BIZXZQkby%N6b(18rp?d?qcPClM5Z(s(h0B>$Dwpe+@y)80pwSl=qsAd4!dAcXtOAlJyv?zwCTj~$f@1T6g+7)2b;^O6ja$H5E5Siy+RQ45jg2sJ8g zB$G5`R7vYpZgEb)vy)coQ&dWtM-D{LP|Dj|m2ad%*RZD@E!o|D>=VGaHqd{T?>L5E zUM+gLhdwF+(D-o$?A!^Aepgaa_8$J~52j?7shPx$(tsdc^_*Rm=7!TwtuRv~DU>>l zz!&rOnZ6=BrseD&GM#;RL+Yx9FV_$kUgz^;m74sBggeJaR^&T>UEsj&GN|DziZ{u79)}!c0@hO!13LGU5E!DPDcod(Cz>5D+w;Yg;tHEgkLoHkR}GJErMTYOS6tcnL7Dx~ zBu}kS`%vU)&{--wg7V$3HM-)x; zWe9sd%=fTO?cD?z+Pv?lW;Qq!1d_eDm$Z-ia@&>(>2;#w3FO@j(-v+FQSny~Bbw&ZKv_ z=sv}>4o==52*A*M*1IwJA^Aq9%vw1;^?nrmtEh+>!1{7%fIm3Aq_(0+a4C9j5N5!TZfHYjT^v;EQ?YKPrQz-&Qkj&v z1|=q=-rMxGuAC$|?QmGIKoYk~fma&_yBN#aTCaGscdOTjRt*vqBJ3;QGivLfFj$3p z7-bCe#WMl32E7H1IS1y^>#istIdX{WtKR-K?pZL7A_Y2#nI6#=Z!-?ZJIX->Rcs{d;&su>I&~Y~wte{|f=;L~AIn=toV?>khKD`oRBi}G=0M@% zs}`^2GLpG=PhXOOsBvPF_1IzUf;F-2?`h8cg!)u{5u^&tpSbH?&T6jW<)GC$h4IOf;OfvhPTa@X~w zQEF}yNEEh(-56YcbN~tbG7X2`jQqY=C4)o6o??)lk%e7}IC3TANMn*lB01{uTDhVQ zq#X|-fner+>+Wb0nst5Z(^exdxKH@xy7tYRN{!UKNL_bRt1>%RrJUh9#2kEK-ol$? zq>6YEwJ`eXFS#2>kTZjg*e2xe01z{9Hr$D+=iI0qjb1Y3T$Y^uvsD0_L|6z&!63Q` z{xr#zQ+BQ{B4t47!kFxf zCInt1^rOWBw{JMR9igqhO|xMTujHO&(>Qc&N^6j)u4O;-9j&0|dBM0cw=vVCO!U+| z8(MlxE?aA2L~T!wEv`7iDeivBct>z*S4h_w<%;`Yefi?ZRsAeCyt^Q^@B2godSWie zoa4^ev0^mJQi0hlBS2mqSJ>WeVccAgqqOAs&{2*8oSk(L_U57Hqr%3g^y<}-`IV$r> z1p`xAZPL5+D!Hxu(#~%;K*ngr(P_ip5YDI$6wQ)wrdG~V@y8%kwF*rfmGK!(88SLg zeqd@Pey>VZ=KT^W{~=&Xw-`L+!pQ#R)VnyvfRte|bW$rQka?&>v%n68JY)W(JzLL*|61?a#Er&rL>I!ySSa_mS(j~d#X$qwR)D=M&@a3idG%uwt8ko zh@mce>0URn3JL9(-F`m5vIW;bp*;wu6rH)H0W{8s%f~*5K~r zKvLsFWab|eJn>P37 z2z?_Cn2Dej$Cd~+H-2D&J8 z^qecB+h|SXU`05D2EL!Yuy)1g&nTxIO}9C&F@qy*VjzQhSJtiL0Lx&^r!f4YIffP5 zw2Hnx6#V6TO9f$m$;(Y%E#waud@L+YH6=KdPM1&Q;3}-FT89uN_Rn8xB-~qRTnGXY z{5Ck;M%@u<;ZRa8>>-KP5$*;avN})-cDpP6#X(z`_-i!Z%YY+axARk`(r2vL=WJYj zSgxwLl;t_XNn7Tfe!FO^NR)$p4v-i~mB1@YjMO;qky@0P#rQs>%bj2`tg}JAtHwiW zqCS%1tFpzc=-fWW!Ukw%;sSQ#Zbh&D1 zXGzu6inId^apVZ`c6=M=(4*EjG77sxgHGuD>KyqNlDwAkqc`VT5Il+T^!*Amay$@m zw?Zp&sT(Pa)L3<&SFI#gTY1CzjGldBb!&*m0%Efcy<1x@3wsQKvkb&Zg zl(4Q781wp__?JR&Xz(IleoGO9LbJMxwbv5g`$UssMtlJ|Md~n%O65^z9TwQOv15)m z<9F(!dhIubIn#&}Z)MC03gwwF1c1snZS{9s9O~%*j{e@><+;Aoe2Zdnb#{AV8rS~G zZORL(R20zpc_g@i@Y_CKmu$xoDJ(zU!l?{crk35>r%%nPuBk}vqprr*`q(s;^j7ib zkfV&Bg)#X|_;cSv6w0gmpUwwjLTB{ zXc(_jn{N7`)FZS^0L<=1o$+;K`W*nq3^^Y`rD=VdXO1W7vNF zc4t|0HG0#ILfIGgw89O6W+R1S7kToee9O@ja~2Y667Rh_>-p!($4YZlJ>8DeEA4RS zWsQ-??S357OHvl+6#12_De$!!87gtFNix#vTe0G0j7?(s&8$POu_>3klCbu#_}97B z+wFv@hYOskm$BpWY{5%n$-T?c!^Cesf7)Uyh()!~?PjeOjw)h!kYbceNs5Z(no#RZ z%LVxav91xRup%ZwPm%>9;b%=PBMBT?lhcn|22HiELy!Q++d7vyhP94x_cDy zm8##Yn{*lWyy1lhtaB6w52a7{JV?tt&ZoDt*XbO_E_2aC@JX;ysnDAt4$Y*eTfnC; zNEVE?qPA>5&%3`!jod#o!e2)}wS7?u`0~b^Gv_LH#KF9ZCHuxKy_C+#CI3>FR_GSx zE`&?2!P8;yuIeFggW6Jw_R%agxb&u0L~ZhxgcWnv?QI^cvBmXIW?GO)o4xs_+V^AT zp`VP73k7T-I4VxUrjuef-jJz*ira2LYm=VN%ckEMJN^9Jewsz6%r+`yF!$BV9o-Z! z!igT5oL>I>^ECF)-W-B=d7*PUsqxkr!W#JNNttK$tW1P)r-j%8^Tj5`@vR9Dqb^5r ztivD!d5f)`)*<1J_WbLnViv z%iJ)Ltin%@ouiGw+~NnvPNpz`GTUfvhoMM?5WOu)G_r5hSfb-p5pvHv zIh%ct8?ln##@Ie384jzEnc0>`3(@?o9vr&xSLAdlsDnUho#Q63w zv!89l@U|K6gA1A^dkM*_A~$;t;Xa^hJCu7}(g(X2Q0uLDl>9uh^2FV3VH7QQE8QOztC!f=AEdoIi7pkwh~^Ww}D9FUO8SMZlOF;i4ebKCv$IkihD7>-64kHDd0A->rA1gd4P9bjBM9;1;~RwT}E0;4`>dR_{OC z+bP=YLxHCyyhk2j3A*KedptNG6{;*n#60WsoHavtj`0;svH9^H+6n-+ML++PiGv|c zvftTH<)Bed>nv7LYzxYVV#1t6CW`%}LdM(zTvK3)q}syIsG-fd2;tPtKP=F>eu)iT zWHs^#DPCNFOtSR`7E4r0iW0jpRn?y^#kIU;+1Qx*3W-)avz|Tj@3(KlX zB=<4)++nwF9$;?f`OGYu^L8Im*IK%52196Lnav2iNf(vCEfHL2Ae-6b&}U?h&T*F% zy&0~NM6F0wcABh7k%+}4qlXXiG(<1BG^_hzAkHzs+DN3%d{%xZ4zjC06zz-0LFp+1 zVZZ2otP||#TMm`kK3{zsVHGT7x2v6y2I4C2nLUG#1Lm5|846O*%I;zz<7>{4#_ZqX zK=4DyZ9~qT^E14Ze9ysrf+C>X;AIOxmTE@L4TmfU?bN$%?RV@Ui?>vxC3I>T@^-_c z_*&|SruRjq9upRbNr`8I^C{+)Zn3B=pu$>1QC&VF4H(VUsY0QfRXk$VN^QW3vZX@yexiy#uJJhMp?3}ae1JSRxdpQ} zL|N41tw<-hDtfxE-x*>iy7*-j>XvPd8paX86z)>3c0 z`+1H}OZtC`pGlm}^URYF5%zSwi+zXGFo>B3{dJ!2B&-!#+wqn#|Z zx57O>?52~~m558LTVrV{m8I0&Nj&+zQMBt^mw9HpE5My5z1M8txnhLb)@_O$bz6(B z%a-a)QsT0(y6M)eMR5Xqm%X(hft@AhXv_oUKGF@4bx*Imc|KH-j}r*OsVOqYqMnCNhZdSO~6 ziRGI%5AhjAV9mG_yB;4#1E|GN4PjYM7SySpLpt6h_+e!ul1I6DHse%oh&oi;2?gvt z*pR~RWC5-(F}Z@LvAD6uScT^!`Gg$!qjJ7uXe&25^g8M!(wcmGpMfP}5~gzp1hw^` zFL~fx2^m9#-&AjtqmPSWewKkjwLgSWl*AI+<6WlnvIle95pE$`jJgGi06KXesp5IR zD47N-<$GQZjJ4W)=a3GRXsm_jq6v#j)CS?lYjrt}jRnikgae`Zc;ca_0bntSFIE;& zO3)UjA##`l^pod%_bmfK!zRyn@z%+)Z@5FyKg;L!##p7cL0g|y+Lz_6<>tw^S3VxT zJfG^0g*2LRnZRfhtDR$TZeZ0)X!T6lNq2Y?7asS3L+(F+?xuKiYn^HRoes$@C5FtinbGY z^E}}6;Gf>9!F$oL2F^gKPqNBfzpc|*tJ=uw9xSC^L~So~z2DIwsKCi-_8H__M_{$3 z@U)IVy?~NTRYlYm$`iXp<~t-jJVlXlJY(y`j|Kg5zqVjnOE4FldRBooM;Nsf;WfhY zDPPd2pwW3=YwxO!!fO69?>Xhmp$e=!l#Fz2H(#C`_0zb2s!)G6-)e@-bu5xA6QtX? z9kW;}9PJ`>T<4{&V?<1t1{3cow;DkKzKVvyzQnFYXv@HbXeFXJv`0J4A*D1j9DSN| z+jNbeB9G8ENoUbyYAk$MjicO%At)X8VR+HC14_IKZ}{FN9MocR^<&2nx_v2aYb09O z8|D7D$v+`&1u7%#j6|olRLqkFK3xsh=H?NVfqx2@_>R-A?11r#59yLksbp zK3WQ*%f~*o z6rYyns8RCZFe1N0_!LB>x}F_NlAk9X%3KH%af^yF$OB+hZL!%|K7GrJv~wVYzUKPb z*0!a0t=rtgs*!`XYc8g?hJhLiXLlJ;=h~p^OR2$e@nY2A%o}xAp5N91@5kZ2+13sz z3PaS=wfc?5um1feV<@EMvbYTLZizHf?S_znGt86;gZ`;(si&+iRE2!57zOEYeYP1L z$^d5I&E=PA>Woddg=5a5c%_fyQ~X3fN0ewRVTVDR(ZGy|8N!Dt&gS{&KV6xWax9uV zc>~6yK-okA;QSz2AK!UH9ylifl9;Xm@9wX>p$gOhAmyv1(zPD`(Xtz#%&&6>f4WQ{ zh3`Ul{o3Xp48V|^e=`_`*4Q{6DTMh)nS@_C%85kpYYqvxwE#*5@a=v*{3#@sg00Vm z39rT2GFAvjON4fU<}k6Q;74rFkNVaX+2}4!I(odv;6e^v;A}F_t@v4Dx}snP@R!rJ z@($g;Z#zU}@mF43;(sB?Vr>|aKyui}_aV97qB{`6h<=Z%K^;4?lUjm#uP z6;fAV{oVv?#l=ZhN@8$|DN|z9tRO-rf9Mu4{C0ho=M05G4+;Y|-ZsQ4qo+qb#QD(c zhts?=I$2DQhN(5eL*#n@UEqjaeryePRh=&I=D_;xxb%uBeHM2%Hck_{nr}uoueChv z3Roh!+ku*VLVUjgs%UgXZxaQ;?jphZg4+DVuNUfp?@fa0xhOYU!)#!+_A(xLZUS9L~PEFa!?dM|yqd{<* z9zxujP8U=M))vFC8flwEVC`dDt{kOMUC%+F^)*i| z7PPM5t|e*3(H+YMV@Cf*i%BY$dar8GDD~p$n`nCVj>|Ca*QBI@PFw))PMaB4fuIgw z5&!^M6*?X_XxVM$%%_n6dAc@WRq!ef6A37u2_)UX{15TUfIPZHCjW-IUU|0oyM8Dw z^vc(EfA;}<0Vn0g5?g@2g z6=tHojMGd4Av#aPg-Henh6G`>m1QFBU1V7IYi+LREZv z9bSlrmuO+e8yKf4-aG^#lmL(bAlAPkfe{1pGlHb&{%Kt0k4c*#AZ9-n&5G1QtReqy zZsYwYF7PEF*9VaF{J#yCF|!g+;&r@{AQxak6o2rz7vQt_XO>?*f@=c#82D34fa7KP z=kWqSWjV}d2t{|WxBkO8PXfgNxcSrjKcfF5#Gc6_D_;L|f}B7rY0`TDLg5U^w?7@| zuU;+NfdYWa-TO~4LZ1FT7eK%5H`~EK1vwS7-fKhf4@_VHu~#-K{*=>y!1&ie|5F0< z^+mAk{22)U2{=4Adn=_7U|)op|FOR}>_BElkPMoC3XG7xe;oK6^Pf8VordrLaxsBq z(EVGuQa}9naM1%h0pgPXe}n7O{|uM=Z(R2O9xmtq9i-$8KlD}m zKg9Jq>#6utoBn&a{?OO_zlH0Md3g6f!}W*09{xRCtN%S*zx9=l_iy3)t*>$kqr1^z8uzxDMWoax_;{=c*vzxDNJANePv|F7-g?|JykNB&2f{|}Z4 zXrKPDhd_V!uZ{hGWe@*_Gy9jgexG0e!kPVRTy4M3uYcmp{wc0M^!1-PvHud+KX+jN z64xL4`al2mOCf*w<^S_vzx?w5`L91+8U5ut>i-k}W%Kf!|LZ5tKVGQ;OXvxKR)(Gb zf1&^SFK`k3#`V9?f0?{Irz8Do^880@-{|uj*Z&^>RrLFM15NV%%QG9_zXAq2|B4GF zz3(pm9zZa4U92Ce_D;pappb$$dCsRXU z{HFl63;e-b2LJ)Qe*PBzLk3v8Abo3V^iIJg$Juro1WNGj4hbW+gSGnMS95aNzm7$p)u#UjW@b9rR z0*ey>N?pG=X-o|*?f#Gfa56JA`b+vXRtDy6MD?rSsMcrXiiqYg#P)k zNeqA2;TYN(y|#G`uk~<_c9sOc)8Iq)4*HhBbi2KS{a+OF+NMI!$>8;>`8E7m=C1;X zSJs9&8K{wLRV5zDhu{)w8z+#;*tRN@)5HcE3j% zL=(6y_gBEQclf1&*UW1h37~FX(`Z0oubO!cfAE8v8vLy-u-$7pP)omJm_Opb?j{}^02ESVTYV&LU{W0G__<(h;f7HE3z;c^q z;5-`urk#PL10+@;`2vX#NGw2N0}?Zk_<{6V_e!)t;s(-d{r{ix!1LO`28j7J0MAPR zJ&^Pq4IT7=v6_|9uNasF5dPVk0xhteo~`Yx!TW2VHv4U|NeUfoUIpS9>iuGV6$${F NHDjPzV|uk}{|{JuWZeJ& literal 0 HcmV?d00001 diff --git a/enterprise-initiative-tag-governance/demo.svg b/enterprise-initiative-tag-governance/demo.svg new file mode 100644 index 0000000..2e0b12a --- /dev/null +++ b/enterprise-initiative-tag-governance/demo.svg @@ -0,0 +1,20 @@ + + + + + Enterprise Initiative Tag Governance + Admin dashboard custom tags checked before analytics rollups and webhooks. + + 2 + approved tags + + 0 + warnings + + 1 + held tags + Reviewer signal + Status: blocked + Webhook events: 3 + Audit digest: ce54335865ff8107bef5be05... + diff --git a/enterprise-initiative-tag-governance/index.js b/enterprise-initiative-tag-governance/index.js new file mode 100644 index 0000000..0d8b520 --- /dev/null +++ b/enterprise-initiative-tag-governance/index.js @@ -0,0 +1,319 @@ +"use strict" + +const crypto = require("node:crypto") + +function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]` + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}` + } + return JSON.stringify(value) +} + +function digest(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex") +} + +function normalizeId(value) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") +} + +function parseDate(value) { + if (!value) return null + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +function daysBetween(start, end) { + return Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) +} + +function toArray(value) { + return Array.isArray(value) ? value : [] +} + +function makePolicyMap(policies) { + return new Map( + toArray(policies).map((policy) => { + const id = normalizeId(policy.id || policy.label) + return [ + id, + { + ...policy, + id, + label: policy.label || id, + allowedScopes: toArray(policy.allowedScopes).map(normalizeId), + mutuallyExclusiveWith: toArray(policy.mutuallyExclusiveWith).map(normalizeId), + requiredEvidence: toArray(policy.requiredEvidence), + }, + ] + }), + ) +} + +function makeProjectMap(projects) { + return new Map( + toArray(projects).map((project) => [ + String(project.id), + { + ...project, + funderIds: toArray(project.funderIds).map(String), + tags: toArray(project.tags), + }, + ]), + ) +} + +function expandAssignments(input) { + const explicit = toArray(input.assignments) + const projectTags = toArray(input.projects).flatMap((project) => + toArray(project.initiativeTags).map((tag) => ({ + ...tag, + projectId: project.id, + })), + ) + + return [...explicit, ...projectTags].map((assignment, index) => ({ + ...assignment, + index, + projectId: String(assignment.projectId || ""), + tagId: normalizeId(assignment.tagId || assignment.tag || assignment.label), + scope: normalizeId(assignment.scope || assignment.department || assignment.lab || "organization"), + evidenceIds: toArray(assignment.evidenceIds).map(String), + })) +} + +function scopeMatches(policy, project, assignment) { + if (!policy.allowedScopes.length || policy.allowedScopes.includes("*")) return true + const candidates = [ + assignment.scope, + normalizeId(project.department), + normalizeId(project.lab), + normalizeId(project.organizationId), + "organization", + ].filter(Boolean) + + return candidates.some((candidate) => policy.allowedScopes.includes(candidate)) +} + +function hasRestrictedBoundary(project) { + const visibility = normalizeId(project.visibility) + const dataClass = normalizeId(project.dataClassification) + return ["private", "restricted", "sensitive", "embargoed"].includes(visibility) || + ["private", "restricted", "sensitive", "regulated", "phi"].includes(dataClass) +} + +function findProjectConflicts(assignment, siblings, policy, policyById) { + const conflicts = [] + for (const sibling of siblings) { + if (sibling.index === assignment.index || sibling.tagId === assignment.tagId) continue + const siblingPolicy = policyById.get(sibling.tagId) + if (!siblingPolicy) continue + if (policy.mutuallyExclusiveWith.includes(sibling.tagId)) conflicts.push(sibling.tagId) + if (siblingPolicy.mutuallyExclusiveWith.includes(assignment.tagId)) conflicts.push(sibling.tagId) + } + return [...new Set(conflicts)] +} + +function evaluateAssignment(assignment, context) { + const { evidenceIds, now, policyById, projectById, assignmentsByProject } = context + const blockers = [] + const warnings = [] + const project = projectById.get(assignment.projectId) + const policy = policyById.get(assignment.tagId) + + if (!assignment.projectId) blockers.push("missing project id") + if (!assignment.tagId) blockers.push("missing initiative tag id") + if (!project) blockers.push(`unknown project ${assignment.projectId || "(blank)"}`) + if (!policy) blockers.push(`unknown initiative tag ${assignment.tagId || "(blank)"}`) + + if (project && policy) { + if (!scopeMatches(policy, project, assignment)) { + blockers.push(`tag ${policy.id} is not allowed for scope ${assignment.scope}`) + } + + if (policy.requiresOwnerApproval && !assignment.ownerApproval) { + blockers.push(`tag ${policy.id} is missing owner approval`) + } + + const missingEvidence = policy.requiredEvidence.filter((requiredId) => { + return !assignment.evidenceIds.includes(requiredId) && !evidenceIds.has(requiredId) + }) + if (missingEvidence.length > 0) { + blockers.push(`tag ${policy.id} is missing required evidence: ${missingEvidence.join(", ")}`) + } + + const assignedAt = parseDate(assignment.assignedAt) + const expiresAt = parseDate(assignment.expiresAt) + if (policy.requiresExpiry && !expiresAt) { + blockers.push(`tag ${policy.id} is missing an expiry date`) + } + if (expiresAt && expiresAt < now) { + blockers.push(`tag ${policy.id} expired on ${expiresAt.toISOString().slice(0, 10)}`) + } + if (assignedAt && expiresAt && policy.maxDurationDays && daysBetween(assignedAt, expiresAt) > policy.maxDurationDays) { + warnings.push(`tag ${policy.id} exceeds the ${policy.maxDurationDays} day maximum duration`) + } + + if (policy.publishToDashboard && hasRestrictedBoundary(project) && !policy.restrictedDataAllowed) { + blockers.push(`tag ${policy.id} would expose a restricted project in admin dashboard rollups`) + } + + if (policy.funderId && !project.funderIds.includes(String(policy.funderId))) { + blockers.push(`tag ${policy.id} requires funder ${policy.funderId}`) + } + + const score = Number(project.compliance?.reproducibilityScore ?? 0) + if (policy.minimumReproducibilityScore && score < policy.minimumReproducibilityScore) { + warnings.push(`project reproducibility score ${score} is below ${policy.minimumReproducibilityScore}`) + } + + if (policy.openAccessRequired && normalizeId(project.compliance?.openAccessStatus) !== "open") { + warnings.push(`project open access status is ${project.compliance?.openAccessStatus || "missing"}`) + } + + const conflicts = findProjectConflicts( + assignment, + assignmentsByProject.get(assignment.projectId) || [], + policy, + policyById, + ) + if (conflicts.length > 0) { + blockers.push(`tag ${policy.id} conflicts with ${conflicts.join(", ")}`) + } + } + + const status = blockers.length > 0 ? "held" : warnings.length > 0 ? "needs-review" : "approved" + const event = { + type: status === "approved" ? "enterprise_tag.approved" : "enterprise_tag.held", + projectId: assignment.projectId, + tagId: assignment.tagId, + status, + blockerCount: blockers.length, + warningCount: warnings.length, + issuedAt: now.toISOString(), + } + + return { + projectId: assignment.projectId, + tagId: assignment.tagId, + label: policy?.label || assignment.tagId, + scope: assignment.scope, + status, + blockers, + warnings, + dashboardVisible: Boolean(policy?.publishToDashboard && status !== "held"), + event: { + ...event, + signature: digest(event), + }, + } +} + +function buildDashboardRollups(assignmentReports, projectById) { + const tagRollups = new Map() + const departmentRollups = new Map() + + for (const report of assignmentReports) { + const project = projectById.get(report.projectId) + const department = project?.department || "unassigned" + if (!tagRollups.has(report.tagId)) { + tagRollups.set(report.tagId, { + tagId: report.tagId, + label: report.label, + projectCount: 0, + approvedCount: 0, + heldCount: 0, + needsReviewCount: 0, + warningCount: 0, + blockerCount: 0, + departments: new Set(), + }) + } + const tag = tagRollups.get(report.tagId) + tag.projectCount += 1 + tag.warningCount += report.warnings.length + tag.blockerCount += report.blockers.length + tag.departments.add(department) + if (report.status === "approved") tag.approvedCount += 1 + if (report.status === "held") tag.heldCount += 1 + if (report.status === "needs-review") tag.needsReviewCount += 1 + + if (!departmentRollups.has(department)) { + departmentRollups.set(department, { + department, + assignedTags: 0, + dashboardVisibleTags: 0, + heldTags: 0, + }) + } + const dept = departmentRollups.get(department) + dept.assignedTags += 1 + if (report.dashboardVisible) dept.dashboardVisibleTags += 1 + if (report.status === "held") dept.heldTags += 1 + } + + return { + byTag: [...tagRollups.values()] + .map((rollup) => ({ ...rollup, departments: [...rollup.departments].sort() })) + .sort((a, b) => a.tagId.localeCompare(b.tagId)), + byDepartment: [...departmentRollups.values()].sort((a, b) => a.department.localeCompare(b.department)), + } +} + +function assessEnterpriseInitiativeTags(input = {}) { + const now = parseDate(input.now) || new Date() + const policyById = makePolicyMap(input.policies) + const projectById = makeProjectMap(input.projects) + const evidenceIds = new Set(toArray(input.evidence).map((item) => String(item.id))) + const assignments = expandAssignments(input) + const assignmentsByProject = new Map() + + for (const assignment of assignments) { + if (!assignmentsByProject.has(assignment.projectId)) assignmentsByProject.set(assignment.projectId, []) + assignmentsByProject.get(assignment.projectId).push(assignment) + } + + const context = { evidenceIds, now, policyById, projectById, assignmentsByProject } + const assignmentReports = assignments.map((assignment) => evaluateAssignment(assignment, context)) + const blockerCount = assignmentReports.reduce((sum, report) => sum + report.blockers.length, 0) + const warningCount = assignmentReports.reduce((sum, report) => sum + report.warnings.length, 0) + const dashboardRollups = buildDashboardRollups(assignmentReports, projectById) + + const reviewerPacket = { + organizationId: input.organizationId || "enterprise-org", + evaluatedAt: now.toISOString(), + status: blockerCount > 0 ? "blocked" : warningCount > 0 ? "needs-review" : "ready", + summary: { + policies: policyById.size, + projects: projectById.size, + assignments: assignments.length, + approved: assignmentReports.filter((report) => report.status === "approved").length, + needsReview: assignmentReports.filter((report) => report.status === "needs-review").length, + held: assignmentReports.filter((report) => report.status === "held").length, + blockerCount, + warningCount, + }, + assignmentReports, + dashboardRollups, + webhookEvents: assignmentReports.map((report) => report.event), + } + + return { + ...reviewerPacket, + auditDigest: digest(reviewerPacket), + } +} + +module.exports = { + assessEnterpriseInitiativeTags, + normalizeId, + stableStringify, +} diff --git a/enterprise-initiative-tag-governance/reports/reviewer-packet.json b/enterprise-initiative-tag-governance/reports/reviewer-packet.json new file mode 100644 index 0000000..a42ef42 --- /dev/null +++ b/enterprise-initiative-tag-governance/reports/reviewer-packet.json @@ -0,0 +1,171 @@ +{ + "organizationId": "research-office-demo", + "evaluatedAt": "2026-05-20T00:00:00.000Z", + "status": "blocked", + "summary": { + "policies": 3, + "projects": 2, + "assignments": 3, + "approved": 2, + "needsReview": 0, + "held": 1, + "blockerCount": 3, + "warningCount": 0 + }, + "assignmentReports": [ + { + "projectId": "project-atlas", + "tagId": "grant-tracked", + "label": "Grant tracked", + "scope": "biology", + "status": "approved", + "blockers": [], + "warnings": [], + "dashboardVisible": true, + "event": { + "type": "enterprise_tag.approved", + "projectId": "project-atlas", + "tagId": "grant-tracked", + "status": "approved", + "blockerCount": 0, + "warningCount": 0, + "issuedAt": "2026-05-20T00:00:00.000Z", + "signature": "0cd50d892226890059543852ffb28317a085118d9f2d8ba7ce29bc45d870db6e" + } + }, + { + "projectId": "project-atlas", + "tagId": "doctoral-work", + "label": "Doctoral work", + "scope": "biology", + "status": "approved", + "blockers": [], + "warnings": [], + "dashboardVisible": true, + "event": { + "type": "enterprise_tag.approved", + "projectId": "project-atlas", + "tagId": "doctoral-work", + "status": "approved", + "blockerCount": 0, + "warningCount": 0, + "issuedAt": "2026-05-20T00:00:00.000Z", + "signature": "67cf892ece6bccbfa50bc3e7f67e8d5e47923b8a7a9db135a5ffab7dbf768c37" + } + }, + { + "projectId": "project-embargo", + "tagId": "public-initiative", + "label": "Public initiative", + "scope": "organization", + "status": "held", + "blockers": [ + "tag public-initiative is missing owner approval", + "tag public-initiative expired on 2026-01-01", + "tag public-initiative would expose a restricted project in admin dashboard rollups" + ], + "warnings": [], + "dashboardVisible": false, + "event": { + "type": "enterprise_tag.held", + "projectId": "project-embargo", + "tagId": "public-initiative", + "status": "held", + "blockerCount": 3, + "warningCount": 0, + "issuedAt": "2026-05-20T00:00:00.000Z", + "signature": "ad4a314474888e8721c6931dc65e95819ab3c5044df033aa6879d31159dc29a5" + } + } + ], + "dashboardRollups": { + "byTag": [ + { + "tagId": "doctoral-work", + "label": "Doctoral work", + "projectCount": 1, + "approvedCount": 1, + "heldCount": 0, + "needsReviewCount": 0, + "warningCount": 0, + "blockerCount": 0, + "departments": [ + "biology" + ] + }, + { + "tagId": "grant-tracked", + "label": "Grant tracked", + "projectCount": 1, + "approvedCount": 1, + "heldCount": 0, + "needsReviewCount": 0, + "warningCount": 0, + "blockerCount": 0, + "departments": [ + "biology" + ] + }, + { + "tagId": "public-initiative", + "label": "Public initiative", + "projectCount": 1, + "approvedCount": 0, + "heldCount": 1, + "needsReviewCount": 0, + "warningCount": 0, + "blockerCount": 3, + "departments": [ + "oncology" + ] + } + ], + "byDepartment": [ + { + "department": "biology", + "assignedTags": 2, + "dashboardVisibleTags": 2, + "heldTags": 0 + }, + { + "department": "oncology", + "assignedTags": 1, + "dashboardVisibleTags": 0, + "heldTags": 1 + } + ] + }, + "webhookEvents": [ + { + "type": "enterprise_tag.approved", + "projectId": "project-atlas", + "tagId": "grant-tracked", + "status": "approved", + "blockerCount": 0, + "warningCount": 0, + "issuedAt": "2026-05-20T00:00:00.000Z", + "signature": "0cd50d892226890059543852ffb28317a085118d9f2d8ba7ce29bc45d870db6e" + }, + { + "type": "enterprise_tag.approved", + "projectId": "project-atlas", + "tagId": "doctoral-work", + "status": "approved", + "blockerCount": 0, + "warningCount": 0, + "issuedAt": "2026-05-20T00:00:00.000Z", + "signature": "67cf892ece6bccbfa50bc3e7f67e8d5e47923b8a7a9db135a5ffab7dbf768c37" + }, + { + "type": "enterprise_tag.held", + "projectId": "project-embargo", + "tagId": "public-initiative", + "status": "held", + "blockerCount": 3, + "warningCount": 0, + "issuedAt": "2026-05-20T00:00:00.000Z", + "signature": "ad4a314474888e8721c6931dc65e95819ab3c5044df033aa6879d31159dc29a5" + } + ], + "auditDigest": "ce54335865ff8107bef5be0524ae7bab67d38b5d234f3530b518622759f1e701" +} diff --git a/enterprise-initiative-tag-governance/requirements-map.md b/enterprise-initiative-tag-governance/requirements-map.md new file mode 100644 index 0000000..a4bd1bf --- /dev/null +++ b/enterprise-initiative-tag-governance/requirements-map.md @@ -0,0 +1,14 @@ +# Requirements Map + +| Issue #19 requirement | Coverage in this module | +| --- | --- | +| Admin dashboards | Produces dashboard rollups by initiative tag and department, with approved, held, warning, and blocker counts. | +| Custom tags or flags for internal initiatives | Validates controlled tag policies such as `GRANT-TRACKED`, `DOCTORAL-WORK`, and `PUBLIC-INITIATIVE`. | +| Contributor and productivity analytics integrity | Holds tags that lack owner approval, required evidence, valid scope, or expiry before they can influence rollups. | +| Compliance tracking | Checks funder linkage, open-access status, reproducibility score thresholds, and restricted-data dashboard exposure. | +| API and webhooks | Emits signed webhook-ready governance events for each approved or held initiative tag. | +| Enterprise-scale oversight | Produces deterministic reviewer packets and audit digests suitable for institutional admin review. | + +## Non-Overlap Note + +This submission is distinct from the existing dashboard/export/webhook/compliance/identity/retention/data-residency/SLA/lab-inventory/secret-rotation/quota/API-change/connector-certification/incident/funder-reporting/AI-model-governance/dashboard-attribution submissions. It focuses specifically on institution-defined initiative tag governance before dashboard and webhook publication. diff --git a/enterprise-initiative-tag-governance/test.js b/enterprise-initiative-tag-governance/test.js new file mode 100644 index 0000000..f0f2953 --- /dev/null +++ b/enterprise-initiative-tag-governance/test.js @@ -0,0 +1,181 @@ +"use strict" + +const assert = require("node:assert/strict") +const { assessEnterpriseInitiativeTags, normalizeId } = require("./index") + +const now = "2026-05-20T00:00:00.000Z" + +{ + const result = assessEnterpriseInitiativeTags({ + now, + organizationId: "university-alpha", + policies: [ + { + id: "GRANT-TRACKED", + label: "Grant tracked", + allowedScopes: ["biology", "chemistry"], + requiresOwnerApproval: true, + requiresExpiry: true, + maxDurationDays: 540, + requiredEvidence: ["grant-award-letter"], + publishToDashboard: true, + funderId: "nih-r01-42", + minimumReproducibilityScore: 80, + openAccessRequired: true, + }, + { + id: "DOCTORAL-WORK", + label: "Doctoral work", + allowedScopes: ["biology"], + requiresOwnerApproval: true, + requiresExpiry: true, + publishToDashboard: true, + }, + ], + evidence: [{ id: "grant-award-letter" }], + projects: [ + { + id: "project-1", + title: "Single-cell atlas release", + department: "biology", + visibility: "public", + dataClassification: "open", + funderIds: ["nih-r01-42"], + compliance: { openAccessStatus: "open", reproducibilityScore: 92 }, + }, + ], + assignments: [ + { + projectId: "project-1", + tagId: "GRANT-TRACKED", + scope: "biology", + assignedAt: "2026-01-10T00:00:00.000Z", + expiresAt: "2026-12-31T00:00:00.000Z", + ownerApproval: true, + evidenceIds: ["grant-award-letter"], + }, + { + projectId: "project-1", + tagId: "DOCTORAL-WORK", + scope: "biology", + assignedAt: "2026-03-01T00:00:00.000Z", + expiresAt: "2026-11-30T00:00:00.000Z", + ownerApproval: true, + }, + ], + }) + + assert.equal(result.status, "ready") + assert.equal(result.summary.approved, 2) + assert.equal(result.dashboardRollups.byTag.find((rollup) => rollup.tagId === "grant-tracked").projectCount, 1) + assert.match(result.webhookEvents[0].signature, /^[0-9a-f]{64}$/) + assert.match(result.auditDigest, /^[0-9a-f]{64}$/) +} + +{ + const result = assessEnterpriseInitiativeTags({ + now, + policies: [ + { + id: "PUBLIC-INITIATIVE", + label: "Public initiative", + allowedScopes: ["organization"], + requiresOwnerApproval: true, + requiresExpiry: true, + publishToDashboard: true, + restrictedDataAllowed: false, + }, + ], + projects: [ + { + id: "project-private", + title: "Embargoed sponsor project", + department: "oncology", + visibility: "private", + dataClassification: "restricted", + compliance: { openAccessStatus: "embargoed", reproducibilityScore: 70 }, + }, + ], + assignments: [ + { + projectId: "project-private", + tagId: "PUBLIC-INITIATIVE", + scope: "organization", + assignedAt: "2025-01-01T00:00:00.000Z", + expiresAt: "2026-01-01T00:00:00.000Z", + ownerApproval: false, + }, + ], + }) + + const report = result.assignmentReports[0] + assert.equal(result.status, "blocked") + assert.equal(report.status, "held") + assert.ok(report.blockers.includes("tag public-initiative is missing owner approval")) + assert.ok(report.blockers.includes("tag public-initiative expired on 2026-01-01")) + assert.ok(report.blockers.includes("tag public-initiative would expose a restricted project in admin dashboard rollups")) +} + +{ + const result = assessEnterpriseInitiativeTags({ + now, + policies: [ + { + id: "PUBLIC-RELEASE", + allowedScopes: ["physics"], + mutuallyExclusiveWith: ["INTERNAL-ONLY"], + publishToDashboard: true, + }, + { + id: "INTERNAL-ONLY", + allowedScopes: ["physics"], + mutuallyExclusiveWith: ["PUBLIC-RELEASE"], + publishToDashboard: false, + }, + ], + projects: [{ id: "project-2", department: "physics", visibility: "public", dataClassification: "open" }], + assignments: [ + { projectId: "project-2", tagId: "PUBLIC-RELEASE", scope: "physics" }, + { projectId: "project-2", tagId: "INTERNAL-ONLY", scope: "physics" }, + ], + }) + + assert.equal(result.status, "blocked") + assert.ok(result.assignmentReports[0].blockers.includes("tag public-release conflicts with internal-only")) + assert.ok(result.assignmentReports[1].blockers.includes("tag internal-only conflicts with public-release")) +} + +{ + const result = assessEnterpriseInitiativeTags({ + now, + policies: [ + { + id: "Funder Export", + allowedScopes: ["chemistry"], + funderId: "horizon-eu-9", + minimumReproducibilityScore: 90, + openAccessRequired: true, + publishToDashboard: true, + }, + ], + projects: [ + { + id: "project-3", + department: "chemistry", + visibility: "public", + dataClassification: "open", + funderIds: ["ukri-22"], + compliance: { openAccessStatus: "closed", reproducibilityScore: 82 }, + }, + ], + assignments: [{ projectId: "project-3", tagId: "Funder Export", scope: "chemistry" }], + }) + + assert.equal(normalizeId("Funder Export"), "funder-export") + assert.equal(result.status, "blocked") + assert.ok(result.assignmentReports[0].blockers.includes("tag funder-export requires funder horizon-eu-9")) + assert.ok(result.assignmentReports[0].warnings.includes("project reproducibility score 82 is below 90")) + assert.ok(result.assignmentReports[0].warnings.includes("project open access status is closed")) +} + +console.log("enterprise-initiative-tag-governance tests passed")