JfPLNO zIN}90N;#^4cFr2rc?Xm=JYtU$lgt)SD#9H_g-~g=s!q^-k?io7x#HGf%)WVFCN_~l zT2NsE_}_>%sCOK@{9$evH$fBCh&<`{xo8~IZ-E;W-u1%DO3Yn~$j4`*`2A1H6PAWm zEeUm6WVTYD$!IXcYvLDUF15jAv;ZH$Wc->BU?pA zA*-+VUDisB?W+o!vN!1lCmLZyQjtk&m@t!Sed&nPX+MC`bIZv$0MTj$#jRp{X>jJZ zVt=|rY!BaibYg^wU+J#z9CfsL#0b$KpGqp`NQ7L4ewXzw+111l%$>E{oe2y3fe@Q0zArF*^?}w7%h^!57~9 zEc)_{=A%9{A5f|*RK@;G#AHtWcJkpM76^KF`#+9o0iV^P2`jvo_Q}ZCrEk@wEhe9C zX!JE?UM4KT&F&NUXA?f}1scm{z7Y34 M^`0ZO32?RF=(KQW^rjd)_RgM;~&|s&x2Q>AXdKg^c8G5&h^p<5V1+%*; zp6oC6?b+#Hv(G!JTyhx;aH*;%KtN*mg4{xst(9U*Y0ot>KPS2 uU*TcNzxq+`gzHHATB+^$>mu!Md7+n#CJFKV7*8%xYYSBc|t3_EFyb z$V0Foz^`J&b&{zIt8ZMwg6XP9W)v`lX|s#==!Q*t`br%!;REXPy?ToZ;@ S3WUMwvX}PLb&3)X*`0OiH`h}Xm69bP<0Qj3^|sA=#fCo1>2~ v|ApUV@bC9TIYsnXO{$iAFsW3%TV)7z(zpoX*$POtC>r&Iz|MO z#`Z?b=wK8{p?j?r_s7)4paL&4?}l~)8=uz=*R3@IY0+IAaq(Pv^C=jh7XRT7`9-{6 zfP4rfe9PfD6T|~G2RNHr_S!_atyz }7QcCAKhuksgW$zSi>KmkgRNs#YLst#nssQy!8dGpFa$ zu|l`*^jfVG7iW6>o+#I*i9bz^)K7vXEIE3I( ~+c_~iHD%>$P_H`}G#y}hqZYsyv#;$F|qiA;xg^-X6Cv@bGGe)>y_u7l@P~AmI zAIFsz9gb$4@zaikxub^9tkJl?# waC@bB0I`I{Y)Kz2^-~i0b^=V+gw?tOvN)0w#@o*kWBT0){aR$n<@^=T zCVL%fo-Su!t}S79sX%RQ!ol aw8=ggEx9sSJ!0CV?a0Zh zXlIUeSEhh0a!X=cRAb&lXqY1zW@7MW98hM=Bn*TrB)NYsWw0H0d!@EFj%qr{^Y)yE zf=1D3;WHAG5f*Nz@^;Yr!6neh1 z2nhw|84$c^RTlNtKWvOPS=h z0;q-4O%wB!=i5lQ-(uKGV32jY@IbVCErj4S(US-^!li;RNMMIW!4%exH@pbT-T>@V z2?rbA(&5v(!rLALJrLqu&)K(sWZTxXI5(GY%2U9K4n!@ $;;^9w)^rc3?@8w5MkFibG+?a1&Acw zH}y`+O4aV1ce-ZCL0`&fc_~$c*6&;#&p!phF}ZpsITb;yKv~QtXeUHpf9pU0;*bS> z{>c|PA0}^7RiJ7`6Ckk3Bq$*0HVKU?r#z~a+nPdD0foStcm^O2F`Hy>pEUr4eHjpD zbg8AQu;{Kn4Hh=VSp^UzPfuXOTD2=s7|Z8Xp6Sefs`j>i3d=HF)K~zuNqk+3ho&6w zpS!}4f;!s-z0KBG+Da~MOycTAWY{%-t5!5nWNI|UUG&is2l31Eyo`%cI@09%6X@>1 zM1F|%H;w{H3@NVVbrY?R+do|zcx{pQNvqf@DiN^mZdg8(@|+K!V-;hN;IDysJVLKc zGXs&2yVp7YnDN=o{OG6-Rdn7}(fWp!rLb@nQQV-39Db}bFy?r^{*_cjnk=N7j*Ac< zBv+nW3BB3^y`C%=T}Z;XfqGWPm%N?y(>M2}AKX_m;P@)6@a1ho7z=c0HMDh2%ewE= z$gcJ8j;1OYuM}T8BACB>hn4#_8iybXw|R=zii+WSSdq$`Zg0_AEx9xSg|{a+BImf6 zX``VeTW7)Yp3e#!*21fcOX4|o7%ndL?pknBdlNn1fwNn4!RGRK9u0h`|5e8#vo1s@ z)Vt2c$A>X5S{Ebihy2*CHYlk)#R?!1sjB!oAmp=|*E~UYNt#QsOChqGE#~Xm4&(>) z=IU71`lL50tI;H+u0H3Hp%!LLUfVD z`Bvz#)mdG6u$3Iqs7zXoyb%UXA)!HP4RTR)T 8ge3s%vF`9131s&Kk*qNTit< z*%ilx7U_Gp0*wzbrZ{dL(N9RI%4u-gt-}iKbVK`}O@%R}qlVgTSZE{B76)e>C`Zgy z)))=N-oEl6wveOG&ib<8!SF?Ju&41EeU9{&JiIoR?9&nULWP71d|IPjZ}%Ooy7xg> zyn3% 3+ix*Y5hk$Ha zYn(1qHbiyfE83z;Gj>xe0>r(iQkjmA?rA5RxZ@H0XS0y5Sr2 ^dncl+O@x<=DQ83Sxz#IxWk#BB*B);EJqiX+pu~jr=Ovdm6? Mp3}>&NBBG_E zl50-&9yT;lEewGVS&`J9I8UHJZH~u?b$oOhX~ 38Ci5!^HYPTvYHx6gT%eldU-MQoKOg0!> z&+zr>>sE%d3&PaR8&2dolGf{ }zBY@&=x>6kJ4l9-jzP9cZvEwa~RLtWlaq zbBD{l1hv*CC(}hu%N+po>2*gOf-i^NO@yG8p>onci+&y{GaX?yoO@{kof0D$7UuO8 zruthJF14#yKIoY;6HjVx_A)#p?obpdTk}WtM|m#m1@!XmSl32R&*;mOOvn#!uCQ{l z%VXXoC%Fj5eMSEqP|MhR-6j!{nwoAM=*b=$5NHGPa^!~-YVX#Gu`$oWU~y%)99NvY z^SvlFr{u~#(RTra> kQH}*Tz^WqpM~4_}2YHx@hl#POLZG%q52P0%)o)O1}f?Y9g=stx$>g8>TamqN1u_ zs7tfQ3=zc=k~kZSA09CJ^Ea}Vp!Y=@AeT9%*>oVf!J05;oH@>6-FgF)1_JA&pq-iR zhT^S-I`~1{TuPCS^v63a$_vVscrl9H9B}?^!;*#0WK660q7CB~bkrB!TC@;+RsKM8 z{XziU%{2Kp)2SB `?NIZYIevv1<6Vb z$g+>9#rW;sWXz@@PN s!DpxS6^d1}G!FOhmE43IV{B1pwf_|Iq+^J?x65`Y zlSu%jU@SL+hJ*Jwrq6|DNGOGaz;mZj7C2M5d`2|6Oc7L{&7KwcV6<9g$*{FN&%Bw2 zzPgx@TjYMG!3GBHPB*{R?jq;Ra`hNwpN~5Xq0Qy2ud`8#I~Z@pry {^%xJGLStzL^7ELSrg+oh%gO z(TPWjJ8q98Cv`$AIJ~ I>Y`w?e;b=Y8hu;N&ZHR{^lqDVKchmi z(~u=k60t$iEb5azRgr1Wz$=M3>HMStU1WJ}4^zRh%Qkcw#mY+{zn@U}k<0sL%{Th# zOtoX{>|ECVG&{@K >+9_Xrjl@xy~0zsh{|D>+Z0%ZnZ=p3A!gfE4qjQek-KU z4OPs|ODJt9f`l)40oU&(wTIC+ndn4bJvpl*6P6hn5aFaRMkx^~Z&03S2He-_<>v1d zYXm)!Br=L%a4zmyUKKMhwwyMpMsM%-*{e$wS8;^o|8~V^6waxuKnWWy6dsvZHrSDt zZJ?8o7UzJM<}fVDjO_#~pTMyaH{_Zz#+$zY{QSWe@lrvq--y<<*SFyLa)MKg0!i2R$(EMZA5*p2H*EBLgcf*>vEwzp z7^r*m0lno3nP~Nv9_zRUR_feDh?w@#5YvA98=^xCc9%NSWHVk*m>Kd%L8!I<`7=dd z1n}N{J=dw|QLzsT0v}00jcP`;LvNFEVX+%1J!o-7$1KXAsJsn%5)2qhNZxKkiSX}A zxYIP%-k@esZv+|f&Wl*ch9R^qi%xKVqJUT=#qL?n1cuy|NMN4mK}CwJDOK97qNOH8 z?z5?5oC0ermPu>}3(CrrP&vAEn-9do=7C+RJNIPRo2Ql9xMp?xYjNb9mMVP6AH~ls z#N#Qx`O+A0UJhmla>v_O`vj!)uB-#wsR|Ma%d0ZVK2N?2#-fL=t!0pAbSMHJicH8^ z2T7$A>=?9YN=V*Mf{~(l+W+oPKVAFRVaPuS5C8yyB%DlXEsShpx6eh_Bp_y?s(l*^isN R^EK(ohX@30vRa;KpvfRa= DlH}mYK;LKPk)mfB@)XWA>?a{e5+)nfo(><6W$Wp8{s1WZ z6J}bJ2RVHYIEaPQL&P!YxxkW97vosQ0a(xF>Y6-H0QEu>= Z>F za^XV<007GX=7CWa{1O!|nE6yVxde>L`hQS;mmu_bgA#z4#zRr8IxjD%smBa&L+OHe z)J-YWI?E5n1@SN be`G8le^;fHB5nEnlh6!za@K(qdV!T-A*GF1K}28Ul^_+f_(r+ 9FPEr!3@Ve$7Eek{|Bt6yRGX@{GChhhD<82)O9 zbmHG*_+f{1{@-Ir7yLIEk|}?S;jeZ`7x_JgpYHl2u>8$kfBMDmg9z9!9)if<-St;~ z0S+1e$v6M+LIxPauR_KT41cr3f8zt#fry3w8z20C*x{dw4 =1sX{m0n }cl1x~ z3W$YM8h@^Neb9Ya#b2~Th5!I?x;A!puHXr bp*`A^w$qN(1Ztd-|K6U+IPF0AHvC{N9n$ z)Wq2dOaV=;ot%G)0!w(13;z={%)O1t3qvrE)W+oRW9I|6u>h!dfA7g)VPft0QwHGS z0;NCbhf?F=LM16fTN7*3hc+ tikkqCugTWCgh<_(!(86;PN5;S?2c#P#xf5OM%IU{E+@viyx2w^c^1r z{-6S214|W!FaeN&J^~2M!DY#U&
t8-S~gng4;bj2hYa$68mTQNByDt z!2d1_T>h8;!yf;&&ks91ERX+}*2njG0fN`O@pok3v3+0nKi4^gH<;)7llM@7pX*$M zo1uZ}r{K&C&P3qM3(n-={1C?;>fliHm-^%X&+8O_!~aj$A8ZMZMaFjE@*zFMq6b?W zxtKT`f=g{1)9)pi1d#nX(u3a)j)wO355f3fiQ&bM*9p~oXS)Z1MBq#O9)vsy1;`n? PnsGd3WMyY$VP^S1UXt%U literal 0 HcmV?d00001 diff --git a/resumable-upload-checkpoint-guard/reports/summary.svg b/resumable-upload-checkpoint-guard/reports/summary.svg new file mode 100644 index 00000000..cff70e8c --- /dev/null +++ b/resumable-upload-checkpoint-guard/reports/summary.svg @@ -0,0 +1,28 @@ + diff --git a/resumable-upload-checkpoint-guard/reports/upload-checkpoint-packet.json b/resumable-upload-checkpoint-guard/reports/upload-checkpoint-packet.json new file mode 100644 index 00000000..95dcc316 --- /dev/null +++ b/resumable-upload-checkpoint-guard/reports/upload-checkpoint-packet.json @@ -0,0 +1,149 @@ +[ + { + "scenario": "missing-chunk-abort", + "uploadId": "upload-climate-parquet", + "generatedAt": "2026-05-22T14:10:00Z", + "expiresAt": "2026-05-23T00:00:00Z", + "artifactPath": "datasets/climate-observations.parquet", + "decision": "abort-and-reupload", + "integrityScore": 35, + "findings": [ + { + "type": "missing-upload-chunk", + "severity": "critical", + "missingChunks": [ + 2 + ], + "message": "Upload checkpoint is missing chunk indexes 2" + }, + { + "type": "upload-size-mismatch", + "severity": "major", + "expectedSizeBytes": 4096, + "receivedBytes": 3072, + "message": "Received 3072 bytes but artifact manifest expects 4096" + } + ], + "requiredActions": [ + { + "type": "abort_incomplete_checkpoint", + "target": "upload-climate-parquet", + "reason": "durable artifact commits require contiguous multipart coverage" + }, + { + "type": "reconcile_upload_size", + "target": "upload-climate-parquet", + "reason": "chunk byte totals must match the artifact manifest" + } + ], + "summary": { + "expectedChunks": 4, + "receivedChunks": 3, + "coverage": 0.75, + "expectedSizeBytes": 4096, + "receivedBytes": 3072, + "severityCounts": { + "critical": 1, + "major": 1 + } + } + }, + { + "scenario": "checksum-hold", + "uploadId": "upload-notebook", + "generatedAt": "2026-05-22T14:10:00Z", + "expiresAt": "2026-05-23T00:00:00Z", + "artifactPath": "notebooks/reproduce.ipynb", + "decision": "hold-resume", + "integrityScore": 75, + "findings": [ + { + "type": "chunk-checksum-mismatch", + "severity": "major", + "chunk": 1, + "declaredHash": "sha256:declared", + "observedHash": "sha256:observed", + "message": "Chunk 1 declares sha256:declared but observed sha256:observed" + } + ], + "requiredActions": [ + { + "type": "reupload_chunk", + "target": "upload-notebook:chunk-1", + "reason": "chunk checksum evidence must match before upload resume or artifact commit" + } + ], + "summary": { + "expectedChunks": 2, + "receivedChunks": 2, + "coverage": 1, + "expectedSizeBytes": 2048, + "receivedBytes": 2048, + "severityCounts": { + "major": 1 + } + } + }, + { + "scenario": "metadata-hold", + "uploadId": "upload-rna-counts", + "generatedAt": "2026-05-22T14:10:00Z", + "expiresAt": "2026-05-23T00:00:00Z", + "artifactPath": "datasets/rna-counts.csv", + "decision": "hold-metadata", + "integrityScore": 80, + "findings": [ + { + "type": "missing-final-manifest-hash", + "severity": "metadata", + "message": "Artifact lacks final manifest hash" + }, + { + "type": "missing-metadata-schema", + "severity": "metadata", + "message": "Artifact lacks DataCite/schema.org metadata schema evidence" + } + ], + "requiredActions": [ + { + "type": "record_final_manifest_hash", + "target": "datasets/rna-counts.csv", + "reason": "durable commits need a stable manifest hash for replay and DOI metadata" + }, + { + "type": "attach_metadata_schema", + "target": "datasets/rna-counts.csv", + "reason": "hosted research artifacts need machine-readable metadata before commit" + } + ], + "summary": { + "expectedChunks": 1, + "receivedChunks": 1, + "coverage": 1, + "expectedSizeBytes": 1000, + "receivedBytes": 1000, + "severityCounts": { + "metadata": 2 + } + } + }, + { + "scenario": "clean-upload", + "uploadId": "upload-clean-supplement", + "generatedAt": "2026-05-22T14:10:00Z", + "expiresAt": "2026-05-23T00:00:00Z", + "artifactPath": "supplements/source-data.zip", + "decision": "commit-artifact", + "integrityScore": 100, + "findings": [], + "requiredActions": [], + "summary": { + "expectedChunks": 3, + "receivedChunks": 3, + "coverage": 1, + "expectedSizeBytes": 3072, + "receivedBytes": 3072, + "severityCounts": {} + } + } +] diff --git a/resumable-upload-checkpoint-guard/sample-data.js b/resumable-upload-checkpoint-guard/sample-data.js new file mode 100644 index 00000000..052c5700 --- /dev/null +++ b/resumable-upload-checkpoint-guard/sample-data.js @@ -0,0 +1,73 @@ +const scenarios = [ + { + name: 'missing-chunk-abort', + uploadId: 'upload-climate-parquet', + generatedAt: '2026-05-22T14:10:00Z', + expiresAt: '2026-05-23T00:00:00Z', + artifact: { + path: 'datasets/climate-observations.parquet', + expectedChunks: 4, + expectedSizeBytes: 4096, + finalManifestHash: 'sha256:manifest-ok', + metadataSchema: 'DataCite-4.5', + }, + chunks: [ + {index: 0, sizeBytes: 1024, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'}, + {index: 1, sizeBytes: 1024, declaredHash: 'sha256:c1', observedHash: 'sha256:c1'}, + {index: 3, sizeBytes: 1024, declaredHash: 'sha256:c3', observedHash: 'sha256:c3'}, + ], + }, + { + name: 'checksum-hold', + uploadId: 'upload-notebook', + generatedAt: '2026-05-22T14:10:00Z', + expiresAt: '2026-05-23T00:00:00Z', + artifact: { + path: 'notebooks/reproduce.ipynb', + expectedChunks: 2, + expectedSizeBytes: 2048, + finalManifestHash: 'sha256:manifest-ok', + metadataSchema: 'schema.org/Dataset', + }, + chunks: [ + {index: 0, sizeBytes: 1024, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'}, + {index: 1, sizeBytes: 1024, declaredHash: 'sha256:declared', observedHash: 'sha256:observed'}, + ], + }, + { + name: 'metadata-hold', + uploadId: 'upload-rna-counts', + generatedAt: '2026-05-22T14:10:00Z', + expiresAt: '2026-05-23T00:00:00Z', + artifact: { + path: 'datasets/rna-counts.csv', + expectedChunks: 1, + expectedSizeBytes: 1000, + finalManifestHash: '', + metadataSchema: '', + }, + chunks: [ + {index: 0, sizeBytes: 1000, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'}, + ], + }, + { + name: 'clean-upload', + uploadId: 'upload-clean-supplement', + generatedAt: '2026-05-22T14:10:00Z', + expiresAt: '2026-05-23T00:00:00Z', + artifact: { + path: 'supplements/source-data.zip', + expectedChunks: 3, + expectedSizeBytes: 3072, + finalManifestHash: 'sha256:manifest-clean', + metadataSchema: 'DataCite-4.5', + }, + chunks: [ + {index: 0, sizeBytes: 1024, declaredHash: 'sha256:a', observedHash: 'sha256:a'}, + {index: 1, sizeBytes: 1024, declaredHash: 'sha256:b', observedHash: 'sha256:b'}, + {index: 2, sizeBytes: 1024, declaredHash: 'sha256:c', observedHash: 'sha256:c'}, + ], + }, +]; + +module.exports = {scenarios}; diff --git a/resumable-upload-checkpoint-guard/test.js b/resumable-upload-checkpoint-guard/test.js new file mode 100644 index 00000000..b29143d0 --- /dev/null +++ b/resumable-upload-checkpoint-guard/test.js @@ -0,0 +1,112 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + evaluateUploadCheckpoint, + buildCheckpointReport, +} = require('./index'); + +test('blocks commit when upload chunks are not contiguous', () => { + const result = evaluateUploadCheckpoint({ + uploadId: 'upload-climate-parquet', + generatedAt: '2026-05-22T14:10:00Z', + expiresAt: '2026-05-23T00:00:00Z', + artifact: { + path: 'datasets/climate-observations.parquet', + expectedChunks: 4, + expectedSizeBytes: 4096, + finalManifestHash: 'sha256:manifest-ok', + metadataSchema: 'DataCite-4.5', + }, + chunks: [ + {index: 0, sizeBytes: 1024, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'}, + {index: 1, sizeBytes: 1024, declaredHash: 'sha256:c1', observedHash: 'sha256:c1'}, + {index: 3, sizeBytes: 1024, declaredHash: 'sha256:c3', observedHash: 'sha256:c3'}, + ], + }); + + assert.equal(result.decision, 'abort-and-reupload'); + assert.equal(result.findings[0].type, 'missing-upload-chunk'); + assert.deepEqual(result.findings[0].missingChunks, [2]); + assert.equal(result.summary.receivedChunks, 3); +}); + +test('holds resume when a chunk checksum does not match checkpoint evidence', () => { + const result = evaluateUploadCheckpoint({ + uploadId: 'upload-notebook', + generatedAt: '2026-05-22T14:10:00Z', + expiresAt: '2026-05-23T00:00:00Z', + artifact: { + path: 'notebooks/reproduce.ipynb', + expectedChunks: 2, + expectedSizeBytes: 2048, + finalManifestHash: 'sha256:manifest-ok', + metadataSchema: 'schema.org/Dataset', + }, + chunks: [ + {index: 0, sizeBytes: 1024, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'}, + {index: 1, sizeBytes: 1024, declaredHash: 'sha256:declared', observedHash: 'sha256:observed'}, + ], + }); + + assert.equal(result.decision, 'hold-resume'); + assert.equal(result.findings.length, 1); + assert.equal(result.findings[0].type, 'chunk-checksum-mismatch'); + assert.equal(result.requiredActions[0].type, 'reupload_chunk'); +}); + +test('requires final manifest hash and metadata schema before durable artifact commit', () => { + const result = evaluateUploadCheckpoint({ + uploadId: 'upload-rna-counts', + generatedAt: '2026-05-22T14:10:00Z', + expiresAt: '2026-05-23T00:00:00Z', + artifact: { + path: 'datasets/rna-counts.csv', + expectedChunks: 1, + expectedSizeBytes: 1000, + finalManifestHash: '', + metadataSchema: '', + }, + chunks: [ + {index: 0, sizeBytes: 1000, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'}, + ], + }); + + assert.equal(result.decision, 'hold-metadata'); + assert.deepEqual( + result.findings.map((finding) => finding.type), + ['missing-final-manifest-hash', 'missing-metadata-schema'] + ); +}); + +test('approves complete checkpoint and builds deterministic reviewer report', () => { + const result = evaluateUploadCheckpoint({ + uploadId: 'upload-clean-supplement', + generatedAt: '2026-05-22T14:10:00Z', + expiresAt: '2026-05-23T00:00:00Z', + artifact: { + path: 'supplements/source-data.zip', + expectedChunks: 3, + expectedSizeBytes: 3072, + finalManifestHash: 'sha256:manifest-clean', + metadataSchema: 'DataCite-4.5', + }, + chunks: [ + {index: 0, sizeBytes: 1024, declaredHash: 'sha256:a', observedHash: 'sha256:a'}, + {index: 1, sizeBytes: 1024, declaredHash: 'sha256:b', observedHash: 'sha256:b'}, + {index: 2, sizeBytes: 1024, declaredHash: 'sha256:c', observedHash: 'sha256:c'}, + ], + }); + + assert.equal(result.decision, 'commit-artifact'); + assert.equal(result.findings.length, 0); + assert.equal(result.integrityScore, 100); + assert.equal(result.summary.coverage, 1); + + const report = buildCheckpointReport(result); + assert.match(report, /# Resumable Upload Checkpoint Guard Report/); + assert.match(report, /Upload: upload-clean-supplement/); + assert.match(report, /Decision: commit-artifact/); + assert.match(report, /Integrity score: 100/); + assert.match(report, /Findings: 0/); +});