From 272473e6fcdad1389d30426b77aa1eeeb0a7df33 Mon Sep 17 00:00:00 2001 From: Tom Wieland Date: Wed, 21 Oct 2026 02:43:28 +0200 Subject: [PATCH 01/87] =?UTF-8?q?chore(maestro):=20carry=200.10.1=E2=80=93?= =?UTF-8?q?0.12=20harness=20state=20onto=20main;=20add=20.dockerignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Wieland --- .dockerignore | 11 + .../contracts/tsk-mv9xeqip-axc0oj/v1.json | 132 ++++++------ .../contracts/tsk-mv9xeqip-pixe4f/v1.json | 132 ++++++------ .../contracts/tsk-mva026ub-1u86pn/v1.json | 66 ++++++ .../contracts/tsk-mvaoq06i-s0i7vr/v1.json | 56 +++++ .../contracts/tsk-mvaw7lqp-9fes2a/v1.json | 41 ++++ .../contracts/tsk-mvawkapy-fwcf2f/v1.json | 47 +++++ .../contracts/tsk-mvawkapy-h9kogq/v1.json | 42 ++++ .../contracts/tsk-mvawkapy-lzvg52/v1.json | 47 +++++ .../contracts/tsk-mvawkapy-nam47g/v1.json | 42 ++++ .../contracts/tsk-mvawkapy-xdespa/v1.json | 37 ++++ .../contracts/tsk-mvb0jm49-3ytgwg/v1.json | 41 ++++ .../contracts/tsk-mvb0jm8t-w9qmja/v1.json | 36 ++++ .../contracts/tsk-mvb0jmcb-ajdki6/v1.json | 41 ++++ .../contracts/tsk-mvb0jmfu-jljtq9/v1.json | 41 ++++ .../contracts/tsk-mvb0jmj8-is6qdv/v1.json | 41 ++++ .../contracts/tsk-mvb3399t-pysl4g/v1.json | 41 ++++ .../contracts/tsk-mvb8iicl-xlm756/v1.json | 46 +++++ .../contracts/tsk-mvb8iifv-1kp3tn/v1.json | 46 +++++ .../contracts/tsk-mvb8iij2-kzsw5d/v1.json | 41 ++++ .../contracts/tsk-mvb8sxid-wpkrtg/v1.json | 51 +++++ .../contracts/tsk-mvb9dhfl-6cdwnk/v1.json | 41 ++++ .../contracts/tsk-mvb9wqu3-mo1a0u/v1.json | 41 ++++ .../contracts/tsk-mvbanfma-w9xz64/v1.json | 46 +++++ .../contracts/tsk-mvbbmmz0-vebdrd/v1.json | 41 ++++ .../contracts/tsk-mvbdsp4f-0yq2ci/v1.json | 64 ++++++ .../contracts/tsk-mvbdsp4f-2m3dmu/v1.json | 64 ++++++ .../contracts/tsk-mvbdsp4f-5hrfbn/v1.json | 64 ++++++ .../contracts/tsk-mvbdsp4f-a1hau9/v1.json | 64 ++++++ .../contracts/tsk-mvbdsp4f-bcinb5/v1.json | 64 ++++++ .../contracts/tsk-mvbdsp4f-c6kk1f/v1.json | 64 ++++++ .../contracts/tsk-mvbdsp4f-cbg990/v1.json | 64 ++++++ .../contracts/tsk-mvbdsp4f-e7yo8j/v1.json | 64 ++++++ .../contracts/tsk-mvbdsp4f-ex9rcm/v1.json | 64 ++++++ .../contracts/tsk-mvbdsp4f-i8nl5p/v1.json | 64 ++++++ .../contracts/tsk-mvbdsp4f-p6l6nv/v1.json | 64 ++++++ .../contracts/tsk-mvbdsp4f-souo6g/v1.json | 64 ++++++ .../contracts/tsk-mvbdsp4f-spcif4/v1.json | 64 ++++++ .../contracts/tsk-mvbdsp4f-ua6x6x/v1.json | 64 ++++++ .../contracts/tsk-mvbdsp4f-wd3fai/v1.json | 64 ++++++ .../contracts/tsk-mvbdsp4f-xmojqz/v1.json | 64 ++++++ .../contracts/tsk-mvbdsp4f-y1xx0b/v1.json | 64 ++++++ .../contracts/tsk-mvbg796n-xpvbrc/v1.json | 48 +++++ .../contracts/tsk-mvbh3xq5-gl0mv7/v1.json | 43 ++++ .../contracts/tsk-mvbh3xu3-3dzjef/v1.json | 43 ++++ .../contracts/tsk-mvbh3xxf-emsxrb/v1.json | 43 ++++ .../contracts/tsk-mvbh3y0u-n3qtug/v1.json | 43 ++++ .../contracts/tsk-mvbh3y45-3zte0e/v1.json | 43 ++++ .../contracts/tsk-mvbh3y7e-zu7pb4/v1.json | 43 ++++ .../contracts/tsk-mvbh3yap-l09oxk/v1.json | 43 ++++ .../contracts/tsk-mvbh3ye3-0dqyir/v1.json | 43 ++++ .../contracts/tsk-mvbh3yhd-mlyvgg/v1.json | 43 ++++ .../contracts/tsk-mvbh3yki-lxt7mh/v1.json | 43 ++++ .../contracts/tsk-mvbh3yns-8zm2bm/v1.json | 43 ++++ .../contracts/tsk-mvbh3yr2-bsovx7/v1.json | 43 ++++ .../contracts/tsk-mvbh3yue-83n9vp/v1.json | 43 ++++ .../contracts/tsk-mvbh3yxp-p9pp0e/v1.json | 43 ++++ .../contracts/tsk-mvbh3z0z-819ef0/v1.json | 43 ++++ .../contracts/tsk-mvbh3z49-hbvk3j/v1.json | 43 ++++ .../contracts/tsk-mvbh3z7s-2ol5gk/v1.json | 43 ++++ .../contracts/tsk-mvbl55h5-z4i872/v1.json | 48 +++++ .../contracts/tsk-mvblcbcl-ojq8t0/v1.json | 43 ++++ .../contracts/tsk-mvblcbgd-lktftw/v1.json | 43 ++++ .../contracts/tsk-mvblcbjl-ijn35n/v1.json | 43 ++++ .../contracts/tsk-mvblcbms-o8fsc1/v1.json | 43 ++++ .../contracts/tsk-mvbm09jg-c92j9a/v1.json | 43 ++++ .../contracts/tsk-mvbp968a-2m4p9i/v1.json | 63 ++++++ .../contracts/tsk-mvcjv2xv-k2omgn/v1.json | 43 ++++ .../contracts/tsk-mvcjv31o-sevooz/v1.json | 43 ++++ .../contracts/tsk-mvcjv35b-3d8syc/v1.json | 43 ++++ .../contracts/tsk-mvcjv38n-qmey5i/v1.json | 43 ++++ .../contracts/tsk-mvcjv3bz-jrd70e/v1.json | 43 ++++ .../contracts/tsk-mvcjv3fd-36woc8/v1.json | 43 ++++ .../contracts/tsk-mvcjv3iq-y7wzar/v1.json | 43 ++++ .../contracts/tsk-mvcjv3m4-28cr81/v1.json | 43 ++++ .../contracts/tsk-mvcjv3pj-tef3al/v1.json | 43 ++++ .../contracts/tsk-mvcjv3sy-7786hr/v1.json | 43 ++++ .../contracts/tsk-mvcjv3wa-40y1j9/v1.json | 43 ++++ .../contracts/tsk-mvcjv3zi-qxn0mq/v1.json | 43 ++++ .../contracts/tsk-mvcjv42t-2myvtg/v1.json | 43 ++++ .../contracts/tsk-mvcjv466-ezgto8/v1.json | 43 ++++ .../contracts/tsk-mvcjv49j-j19atg/v1.json | 43 ++++ .../contracts/tsk-mvcjv4cx-u59do2/v1.json | 43 ++++ .../contracts/tsk-mvcjv4gc-nokr7e/v1.json | 43 ++++ .../contracts/tsk-mvcjv4jp-snazrp/v1.json | 43 ++++ .../contracts/tsk-mvcjv4mz-pvmh4b/v1.json | 43 ++++ .../contracts/tsk-mvcpexet-7d5r2f/v1.json | 43 ++++ .../contracts/tsk-mvcpexhy-k9wn2c/v1.json | 43 ++++ .../contracts/tsk-mvcpexl4-f87btw/v1.json | 43 ++++ .../contracts/tsk-mvcpexoh-4d52ro/v1.json | 43 ++++ .../contracts/tsk-mvcpexrp-ffsx4n/v1.json | 43 ++++ .../contracts/tsk-mvcpexut-0na9la/v1.json | 43 ++++ .../contracts/tsk-mvcpexxx-4ohdhl/v1.json | 43 ++++ .../contracts/tsk-mvcpey14-2k0wis/v1.json | 43 ++++ .../contracts/tsk-mvcpey4b-b89h8y/v1.json | 43 ++++ .../contracts/tsk-mvcpey7z-zm5f97/v1.json | 43 ++++ .../contracts/tsk-mvcpeyb9-t3b4bu/v1.json | 43 ++++ .../contracts/tsk-mvcpeyei-jtr1wv/v1.json | 43 ++++ .../contracts/tsk-mvcpeyhp-jmyzba/v1.json | 43 ++++ .../contracts/tsk-mvcpeyky-peqniz/v1.json | 43 ++++ .../contracts/tsk-mvcrl0u6-q95oms/v1.json | 43 ++++ .../contracts/tsk-mvcrl0xj-ldfumo/v1.json | 43 ++++ .../contracts/tsk-mvcrl10t-m63g70/v1.json | 43 ++++ .../contracts/tsk-mvcrl146-fp6v3u/v1.json | 43 ++++ .../contracts/tsk-mvd9mln9-g1f798/v1.json | 43 ++++ .../contracts/tsk-mvd9mlqi-lnd416/v1.json | 43 ++++ .../contracts/tsk-mvd9mltr-5gll5q/v1.json | 43 ++++ .../contracts/tsk-mvd9mlx1-w6ietq/v1.json | 43 ++++ .../contracts/tsk-mvdf5hvl-qkcn8e/v1.json | 53 +++++ .../contracts/tsk-mvdfbkr7-8wkwg5/v1.json | 58 ++++++ .../contracts/tsk-mvdfoc36-i9tzlz/v1.json | 53 +++++ .../contracts/tsk-mvdfsdm7-rk163d/v1.json | 68 +++++++ .../contracts/tsk-mvdftqvl-77wo59/v1.json | 53 +++++ .../contracts/tsk-mvdfwlxi-vkqc20/v1.json | 58 ++++++ .../contracts/tsk-mvdgoa0t-18vdc1/v1.json | 48 +++++ .../contracts/tsk-mvdgtk7t-ly5lul/v1.json | 53 +++++ .../contracts/tsk-mvdgvhpd-9mxdxa/v1.json | 68 +++++++ .../contracts/tsk-mvdh79dx-xb500e/v1.json | 53 +++++ .../contracts/tsk-mvdh8or6-37dwa9/v1.json | 53 +++++ .../contracts/tsk-mvdhyh5g-5yq01o/v1.json | 53 +++++ .../contracts/tsk-mvdhzjr6-6xpm3i/v1.json | 58 ++++++ .../contracts/tsk-mvdi4g74-udxxs3/v1.json | 58 ++++++ .../contracts/tsk-mveorm3d-m113ii/v1.json | 63 ++++++ .../contracts/tsk-mveouc1i-byube1/v1.json | 53 +++++ .../contracts/tsk-mveovcwy-q3fo5g/v1.json | 63 ++++++ .../contracts/tsk-mveovhgv-tk9wbj/v1.json | 53 +++++ .../contracts/tsk-mveovo0t-2qcudv/v1.json | 58 ++++++ .../contracts/tsk-mveox57m-tvw0xp/v1.json | 48 +++++ .../contracts/tsk-mveox92p-bcbotq/v1.json | 63 ++++++ .../contracts/tsk-mveoxlor-3b9mxh/v1.json | 53 +++++ .../contracts/tsk-mveoxp2g-hrduy2/v1.json | 53 +++++ .../contracts/tsk-mveoxua6-li0n6j/v1.json | 58 ++++++ .../contracts/tsk-mveoxvjj-lwme2p/v1.json | 58 ++++++ .../contracts/tsk-mvep8nuq-do4lci/v1.json | 63 ++++++ .../contracts/tsk-mvepmv97-fe5jxj/v1.json | 53 +++++ .../contracts/tsk-mvepyvw7-ohtsdc/v1.json | 58 ++++++ .../contracts/tsk-mvepzuqe-owgjul/v1.json | 48 +++++ .../contracts/tsk-mveq0q6b-7wurki/v1.json | 53 +++++ .../contracts/tsk-mveqc5k9-6weba4/v1.json | 58 ++++++ .../contracts/tsk-mverrnjm-v3bnoo/v1.json | 58 ++++++ .../contracts/tsk-mvervb9q-tizqjp/v1.json | 58 ++++++ .../contracts/tsk-mvet31ys-gowf3l/v1.json | 63 ++++++ .../contracts/tsk-mveutmw9-l3uhuv/v1.json | 48 +++++ .../contracts/tsk-mveuyqcl-h6cafd/v1.json | 53 +++++ .../contracts/tsk-mvey8ngt-tghgvt/v1.json | 48 +++++ .../contracts/tsk-mvey915n-8cs2b5/v1.json | 53 +++++ .../contracts/tsk-mvf4c3oa-dc40fo/v1.json | 58 ++++++ .../contracts/tsk-mvf4c3s4-xn4847/v1.json | 63 ++++++ .../contracts/tsk-mvf4xez4-00j1za/v1.json | 68 +++++++ .../contracts/tsk-mvf4xf42-qjodki/v1.json | 68 +++++++ .../contracts/tsk-mvf4xf8t-hyoe72/v1.json | 68 +++++++ .../contracts/tsk-mvf70f4h-isuu0c/v1.json | 58 ++++++ .../contracts/tsk-mvf70fjh-fmn5my/v1.json | 58 ++++++ .../contracts/tsk-mvf70fq3-r9ruuc/v1.json | 53 +++++ .../contracts/tsk-mvf7jpll-z8910m/v1.json | 63 ++++++ .../contracts/tsk-mvf9rld7-x1htmf/v1.json | 58 ++++++ .../contracts/tsk-mvf9rlgq-6v0ddu/v1.json | 63 ++++++ .../contracts/tsk-mvf9rlkh-oyyusj/v1.json | 58 ++++++ .../contracts/tsk-mvf9rlnr-ts175i/v1.json | 53 +++++ .../contracts/tsk-mvf9rlqz-0bc6j8/v1.json | 53 +++++ .../contracts/tsk-mvf9twme-9avzxk/v1.json | 68 +++++++ .../contracts/tsk-mvfamuiv-lyz0jg/v1.json | 63 ++++++ .../contracts/tsk-mvfc5h86-z8ef14/v1.json | 63 ++++++ .../contracts/tsk-mvfdi1tt-t8vr7z/v1.json | 58 ++++++ .../contracts/tsk-mvffsow8-ogb7fz/v1.json | 58 ++++++ .../contracts/tsk-mvfiv513-vvk5an/v1.json | 53 +++++ .../contracts/tsk-mvfixl0v-di486t/v1.json | 58 ++++++ .../contracts/tsk-mvfixl4d-wu25gw/v1.json | 63 ++++++ .../contracts/tsk-mvfl8a7k-641iow/v1.json | 63 ++++++ .../contracts/tsk-mvfl8acb-0806yq/v1.json | 58 ++++++ .../contracts/tsk-mvfl8agk-vm3y4j/v1.json | 58 ++++++ .../contracts/tsk-mvfns901-uz5ej3/v1.json | 53 +++++ .../contracts/tsk-mvfo16u4-pdz4fg/v1.json | 68 +++++++ .../contracts/tsk-mvfohzqk-xg47t7/v1.json | 58 ++++++ .../contracts/tsk-mvfoqs1j-gik5ls/v1.json | 53 +++++ .../contracts/tsk-mvfr897j-gvxts8/v1.json | 63 ++++++ .../contracts/tsk-mvfr89bs-sr3cs4/v1.json | 58 ++++++ .../contracts/tsk-mvfusz4r-iiqduh/v1.json | 53 +++++ .../contracts/tsk-mvfusz8h-6pkxkz/v1.json | 53 +++++ .../contracts/tsk-mvfuszbz-xpikgu/v1.json | 53 +++++ .../contracts/tsk-mvfypvdw-vae18k/v1.json | 53 +++++ .../contracts/tsk-mvg7dsl7-vj0q93/v1.json | 48 +++++ .maestro/handoffs/hnd-mva0298c-me1y21.json | 8 + .maestro/handoffs/hnd-mva252at-pr2b3c.json | 14 +- .maestro/handoffs/hnd-mva252f7-xnovdq.json | 14 +- .maestro/handoffs/hnd-mvaoq242-me2o4m.json | 8 + .maestro/handoffs/hnd-mvaw7nlf-ed7vrh.json | 8 + .maestro/handoffs/hnd-mvawksua-h712jx.json | 8 + .maestro/handoffs/hnd-mvawkszp-29udpy.json | 8 + .maestro/handoffs/hnd-mvawkt3c-5swjq7.json | 8 + .maestro/handoffs/hnd-mvawkt70-9y0x1w.json | 8 + .maestro/handoffs/hnd-mvawktb2-t3ltbl.json | 8 + .maestro/handoffs/hnd-mvawktep-3xvzc4.json | 8 + .maestro/handoffs/hnd-mvawktj5-mwz6z0.json | 8 + .maestro/handoffs/hnd-mvawktn5-g53b4r.json | 8 + .maestro/handoffs/hnd-mvawktqx-vnlrf9.json | 8 + .maestro/handoffs/hnd-mvb0jt66-2g908f.json | 8 + .maestro/handoffs/hnd-mvb0jt9s-fvr2yl.json | 8 + .maestro/handoffs/hnd-mvb0jtdi-ursc66.json | 8 + .maestro/handoffs/hnd-mvb0jth1-tdn9by.json | 8 + .maestro/handoffs/hnd-mvb0jtkl-vt76vb.json | 8 + .maestro/handoffs/hnd-mvb33b29-ii64px.json | 8 + .maestro/handoffs/hnd-mvb8imbw-zpztu8.json | 8 + .maestro/handoffs/hnd-mvb8imf4-fwzmyp.json | 8 + .maestro/handoffs/hnd-mvb8imig-3o9kbh.json | 8 + .maestro/handoffs/hnd-mvb8sz6d-olit3r.json | 8 + .maestro/handoffs/hnd-mvb9djg1-k0xl30.json | 8 + .maestro/handoffs/hnd-mvb9wspe-9rw8zj.json | 8 + .maestro/handoffs/hnd-mvbanfqt-hdmvj4.json | 8 + .maestro/handoffs/hnd-mvbbmn4y-n67iy2.json | 8 + .maestro/handoffs/hnd-mvbbxyhh-7fero4.json | 8 + .maestro/handoffs/hnd-mvbdt6e0-cq4w17.json | 8 + .maestro/handoffs/hnd-mvbdt6hw-cwl9w5.json | 8 + .maestro/handoffs/hnd-mvbdt6ld-m5zgd1.json | 8 + .maestro/handoffs/hnd-mvbdt6ou-pxb02n.json | 8 + .maestro/handoffs/hnd-mvbdxynm-ezlxsq.json | 8 + .maestro/handoffs/hnd-mvbdxyr4-xhp815.json | 8 + .maestro/handoffs/hnd-mvbeajau-9niym8.json | 8 + .maestro/handoffs/hnd-mvbebkra-115n3a.json | 8 + .maestro/handoffs/hnd-mvbehsqt-0lumo0.json | 8 + .maestro/handoffs/hnd-mvbfbg6b-osf2tg.json | 8 + .maestro/handoffs/hnd-mvbfbgfb-oqy0zz.json | 8 + .maestro/handoffs/hnd-mvbfl58d-tt87q9.json | 8 + .maestro/handoffs/hnd-mvbg11az-pplnlu.json | 8 + .maestro/handoffs/hnd-mvbg11fu-zd71q6.json | 8 + .maestro/handoffs/hnd-mvbg11k5-o1xub8.json | 8 + .maestro/handoffs/hnd-mvbg7cwu-eruy8b.json | 8 + .maestro/handoffs/hnd-mvbgn40k-5y8z1q.json | 8 + .maestro/handoffs/hnd-mvbh37v7-jvnioq.json | 8 + .maestro/handoffs/hnd-mvbh47rq-2yd70k.json | 8 + .maestro/handoffs/hnd-mvbh47wl-18lsui.json | 8 + .maestro/handoffs/hnd-mvbh4801-qu8h90.json | 8 + .maestro/handoffs/hnd-mvbh483d-zraw8o.json | 8 + .maestro/handoffs/hnd-mvbh486t-2jsuyj.json | 8 + .maestro/handoffs/hnd-mvbh48a5-61nmc9.json | 8 + .maestro/handoffs/hnd-mvbh48di-biv6bq.json | 8 + .maestro/handoffs/hnd-mvbhysda-q1j7ue.json | 8 + .maestro/handoffs/hnd-mvbhysrz-wr7b5l.json | 8 + .maestro/handoffs/hnd-mvbhyt6n-uzy320.json | 8 + .maestro/handoffs/hnd-mvbhythp-4ewmoa.json | 8 + .maestro/handoffs/hnd-mvbixqfc-etm0af.json | 8 + .maestro/handoffs/hnd-mvbixqtv-unbvn4.json | 8 + .maestro/handoffs/hnd-mvbixr8c-4u9dah.json | 8 + .maestro/handoffs/hnd-mvbixrim-0r572w.json | 8 + .maestro/handoffs/hnd-mvbjroej-gj30uo.json | 8 + .maestro/handoffs/hnd-mvbko9c8-l29lcm.json | 8 + .maestro/handoffs/hnd-mvbl5872-wy4cqd.json | 8 + .maestro/handoffs/hnd-mvblcgxb-7689rf.json | 8 + .maestro/handoffs/hnd-mvbm0dm9-uaot89.json | 8 + .maestro/handoffs/hnd-mvbm0dxo-wfeibq.json | 8 + .maestro/handoffs/hnd-mvboub0s-yyifmq.json | 8 + .maestro/handoffs/hnd-mvboub42-dptr1j.json | 8 + .maestro/handoffs/hnd-mvbp9cb2-ynfrbt.json | 8 + .maestro/handoffs/hnd-mvcjwrrq-bwj4fl.json | 8 + .maestro/handoffs/hnd-mvcjwrv7-e8wvsl.json | 8 + .maestro/handoffs/hnd-mvcjwryk-dawvm1.json | 8 + .maestro/handoffs/hnd-mvcjws21-86akbh.json | 8 + .maestro/handoffs/hnd-mvckbvk5-jaqzzo.json | 8 + .maestro/handoffs/hnd-mvckciuq-61emr2.json | 8 + .maestro/handoffs/hnd-mvcke9ej-skzb62.json | 8 + .maestro/handoffs/hnd-mvcke9im-f2w51o.json | 8 + .maestro/handoffs/hnd-mvcke9ml-380z2z.json | 8 + .maestro/handoffs/hnd-mvckg208-lfaowm.json | 8 + .maestro/handoffs/hnd-mvcngf2f-n63f4h.json | 8 + .maestro/handoffs/hnd-mvcngf76-3hfcz7.json | 8 + .maestro/handoffs/hnd-mvcngfau-c1xgey.json | 8 + .maestro/handoffs/hnd-mvcngff3-emrowy.json | 8 + .maestro/handoffs/hnd-mvcno4nc-tx4u4d.json | 8 + .maestro/handoffs/hnd-mvcntjt5-gf4zzv.json | 8 + .maestro/handoffs/hnd-mvcohj0b-sbgsmm.json | 8 + .maestro/handoffs/hnd-mvcp8ti4-ckcuoy.json | 8 + .maestro/handoffs/hnd-mvcpf9cu-7b2wdg.json | 8 + .maestro/handoffs/hnd-mvcphvsc-pxv725.json | 8 + .maestro/handoffs/hnd-mvcpjskd-1nf2m1.json | 8 + .maestro/handoffs/hnd-mvcpu9nh-vfrxqf.json | 8 + .maestro/handoffs/hnd-mvcpw45p-0txp3i.json | 8 + .maestro/handoffs/hnd-mvcpyh8n-xr8446.json | 8 + .maestro/handoffs/hnd-mvcpztar-w3hc5j.json | 8 + .maestro/handoffs/hnd-mvcqbavx-vvrnlc.json | 8 + .maestro/handoffs/hnd-mvcqjayl-6b57lx.json | 8 + .maestro/handoffs/hnd-mvcqjb23-4npqiz.json | 8 + .maestro/handoffs/hnd-mvcqkor9-hq8oxz.json | 8 + .maestro/handoffs/hnd-mvcqnczr-kyf3rw.json | 8 + .maestro/handoffs/hnd-mvcqw1h2-424dvt.json | 8 + .maestro/handoffs/hnd-mvcriam1-bqhjqh.json | 8 + .maestro/handoffs/hnd-mvcrl4hr-od6djl.json | 8 + .maestro/handoffs/hnd-mvcs34g2-4loqul.json | 8 + .maestro/handoffs/hnd-mvcsu6cd-4i9s43.json | 8 + .maestro/handoffs/hnd-mvcsu6fr-rx1lcj.json | 8 + .maestro/handoffs/hnd-mvcyx5nd-xcvxfx.json | 8 + .maestro/handoffs/hnd-mvd9mq32-vs3dxb.json | 8 + .maestro/handoffs/hnd-mvd9mq6d-56bytn.json | 8 + .maestro/handoffs/hnd-mvd9mq9j-64lcpp.json | 8 + .maestro/handoffs/hnd-mvd9mqct-s3qigy.json | 8 + .maestro/handoffs/hnd-mvdf5hzv-1vd86z.json | 8 + .maestro/handoffs/hnd-mvdfbkz0-4rc5uf.json | 8 + .maestro/handoffs/hnd-mvdfoc6y-xmudjx.json | 8 + .maestro/handoffs/hnd-mvdfsdqy-u2cjrp.json | 8 + .maestro/handoffs/hnd-mvdftr16-dnrzzq.json | 8 + .maestro/handoffs/hnd-mvdfwmhn-laeq8g.json | 8 + .maestro/handoffs/hnd-mvdgoahm-j72n5v.json | 8 + .maestro/handoffs/hnd-mvdgtkfl-rrer1t.json | 8 + .maestro/handoffs/hnd-mvdgvhst-0wxhbe.json | 8 + .maestro/handoffs/hnd-mvdh79oi-5t4vmp.json | 8 + .maestro/handoffs/hnd-mvdh8oum-bldf8i.json | 8 + .maestro/handoffs/hnd-mvdhyi3w-88v6t5.json | 8 + .maestro/handoffs/hnd-mvdhzjyq-o3bz4i.json | 8 + .maestro/handoffs/hnd-mvdi4gnm-or3p2i.json | 8 + .maestro/handoffs/hnd-mveorq6o-e28wi2.json | 9 + .maestro/handoffs/hnd-mveoueu4-u3in6l.json | 9 + .maestro/handoffs/hnd-mveovgh3-aes6qu.json | 9 + .maestro/handoffs/hnd-mveovkga-prr8eq.json | 9 + .maestro/handoffs/hnd-mveovq6s-hyok5n.json | 9 + .maestro/handoffs/hnd-mveox9ks-1r3cy1.json | 9 + .maestro/handoffs/hnd-mveoxbsl-ar20k6.json | 9 + .maestro/handoffs/hnd-mveoxqc3-tglltz.json | 9 + .maestro/handoffs/hnd-mveoxtkq-lv8wud.json | 9 + .maestro/handoffs/hnd-mveoxwda-d3ftoo.json | 9 + .maestro/handoffs/hnd-mveoxxlk-q6wkhh.json | 9 + .maestro/handoffs/hnd-mvep8rov-ch6ejb.json | 9 + .maestro/handoffs/hnd-mvepmxyt-7093rp.json | 9 + .maestro/handoffs/hnd-mvepyyol-8q4c6f.json | 9 + .maestro/handoffs/hnd-mvepzwqg-3qfxix.json | 9 + .maestro/handoffs/hnd-mveq0u8w-04mvta.json | 9 + .maestro/handoffs/hnd-mveqc9ku-ih146m.json | 9 + .maestro/handoffs/hnd-mverrqrl-kz3loy.json | 9 + .maestro/handoffs/hnd-mvervfj5-f7s37a.json | 9 + .maestro/handoffs/hnd-mvet362j-c5n0q4.json | 9 + .maestro/handoffs/hnd-mveutsko-5e3598.json | 9 + .maestro/handoffs/hnd-mveuytou-dg69ok.json | 9 + .maestro/handoffs/hnd-mvey8qiy-9k97jg.json | 9 + .maestro/handoffs/hnd-mvey93y7-xgkwdd.json | 9 + .maestro/handoffs/hnd-mvf4c7jq-hcrpup.json | 9 + .maestro/handoffs/hnd-mvf4c7n4-h8zr34.json | 9 + .maestro/handoffs/hnd-mvf4xhps-rlvmox.json | 9 + .maestro/handoffs/hnd-mvf4xht7-w7txiw.json | 9 + .maestro/handoffs/hnd-mvf4xhwj-yuuhyt.json | 9 + .maestro/handoffs/hnd-mvf70j04-ignopf.json | 9 + .maestro/handoffs/hnd-mvf70j3t-1647p5.json | 9 + .maestro/handoffs/hnd-mvf70jau-jdae3e.json | 9 + .maestro/handoffs/hnd-mvf7k37j-xa6yn0.json | 9 + .maestro/handoffs/hnd-mvf9ru02-w16tdx.json | 9 + .maestro/handoffs/hnd-mvf9ru3k-bc3z8d.json | 9 + .maestro/handoffs/hnd-mvf9ru73-dl7s09.json | 9 + .maestro/handoffs/hnd-mvf9ruaj-y9f0rq.json | 9 + .maestro/handoffs/hnd-mvf9rudx-ukmrm3.json | 9 + .maestro/handoffs/hnd-mvf9tz10-1wjgu2.json | 9 + .maestro/handoffs/hnd-mvfamy84-dk5m97.json | 9 + .maestro/handoffs/hnd-mvfc5l2f-u5kopa.json | 9 + .maestro/handoffs/hnd-mvfdi4tp-6rj4ti.json | 9 + .maestro/handoffs/hnd-mvffsve2-moiovn.json | 9 + .maestro/handoffs/hnd-mvfiv7r1-eqp13y.json | 9 + .maestro/handoffs/hnd-mvfixpxa-dfovpz.json | 9 + .maestro/handoffs/hnd-mvfixq0m-cyzmzk.json | 9 + .maestro/handoffs/hnd-mvfl8u9a-1h9ez0.json | 9 + .maestro/handoffs/hnd-mvfl8uck-05ow73.json | 9 + .maestro/handoffs/hnd-mvfl8ufu-d0ys04.json | 9 + .maestro/handoffs/hnd-mvfnsfmi-3foa18.json | 9 + .maestro/handoffs/hnd-mvfo19vx-6d7juf.json | 9 + .maestro/handoffs/hnd-mvfoia1c-2yr46a.json | 9 + .maestro/handoffs/hnd-mvfopmkt-uacfpv.json | 9 + .maestro/handoffs/hnd-mvfoquw2-hxgwko.json | 9 + .maestro/handoffs/hnd-mvfr8bys-1y3gcf.json | 9 + .maestro/handoffs/hnd-mvfr8c25-iso6zn.json | 9 + .maestro/handoffs/hnd-mvfut7k6-n8w03d.json | 9 + .maestro/handoffs/hnd-mvfut7nh-z4d33s.json | 9 + .maestro/handoffs/hnd-mvfut7qu-gfx0k2.json | 9 + .maestro/handoffs/hnd-mvfyq2gw-jnzct2.json | 9 + .maestro/handoffs/hnd-mvg7didq-expbem.json | 9 + .maestro/handoffs/hnd-mvg7dwx5-3f47z9.json | 9 + .maestro/missions/release-0-10.execution.md | 19 +- .maestro/policies/.pending-loosenings.json | 5 + .maestro/tasks/NOW.md | 15 +- .maestro/tasks/contracts/c-003692.json | 43 ++++ .maestro/tasks/contracts/c-02ad50.json | 43 ++++ .maestro/tasks/contracts/c-05c7ce.json | 63 ++++++ .maestro/tasks/contracts/c-0aa267.json | 48 +++++ .maestro/tasks/contracts/c-0ace4c.json | 63 ++++++ .maestro/tasks/contracts/c-0fb2aa.json | 58 ++++++ .maestro/tasks/contracts/c-101f8f.json | 53 +++++ .maestro/tasks/contracts/c-10ff0e.json | 43 ++++ .maestro/tasks/contracts/c-127d7d.json | 58 ++++++ .maestro/tasks/contracts/c-12b6e9.json | 41 ++++ .maestro/tasks/contracts/c-13f98e.json | 66 ++++++ .maestro/tasks/contracts/c-147f9f.json | 63 ++++++ .maestro/tasks/contracts/c-16e5d7.json | 58 ++++++ .maestro/tasks/contracts/c-18d82e.json | 53 +++++ .maestro/tasks/contracts/c-19cd27.json | 63 ++++++ .maestro/tasks/contracts/c-1a0e33.json | 64 ++++++ .maestro/tasks/contracts/c-1ba8bb.json | 53 +++++ .maestro/tasks/contracts/c-1c636c.json | 43 ++++ .maestro/tasks/contracts/c-1e07fc.json | 51 +++++ .maestro/tasks/contracts/c-1fb1ff.json | 63 ++++++ .maestro/tasks/contracts/c-2123cb.json | 53 +++++ .maestro/tasks/contracts/c-2136ca.json | 53 +++++ .maestro/tasks/contracts/c-246560.json | 43 ++++ .maestro/tasks/contracts/c-24b4dd.json | 43 ++++ .maestro/tasks/contracts/c-24f7a7.json | 64 ++++++ .maestro/tasks/contracts/c-2618bf.json | 43 ++++ .maestro/tasks/contracts/c-2a2a87.json | 41 ++++ .maestro/tasks/contracts/c-2a710f.json | 43 ++++ .maestro/tasks/contracts/c-2c1844.json | 41 ++++ .maestro/tasks/contracts/c-2dbc7a.json | 43 ++++ .maestro/tasks/contracts/c-2dfbca.json | 43 ++++ .maestro/tasks/contracts/c-2e2040.json | 58 ++++++ .maestro/tasks/contracts/c-322722.json | 41 ++++ .maestro/tasks/contracts/c-3254b7.json | 64 ++++++ .maestro/tasks/contracts/c-334ee9.json | 64 ++++++ .maestro/tasks/contracts/c-3565d2.json | 58 ++++++ .maestro/tasks/contracts/c-3628a6.json | 64 ++++++ .maestro/tasks/contracts/c-380a8e.json | 43 ++++ .maestro/tasks/contracts/c-3bc0d9.json | 43 ++++ .maestro/tasks/contracts/c-3d5ca3.json | 53 +++++ .maestro/tasks/contracts/c-3e3e19.json | 43 ++++ .maestro/tasks/contracts/c-3e4a1c.json | 48 +++++ .maestro/tasks/contracts/c-3f05f7.json | 58 ++++++ .maestro/tasks/contracts/c-3f3d66.json | 43 ++++ .maestro/tasks/contracts/c-413616.json | 64 ++++++ .maestro/tasks/contracts/c-436b66.json | 43 ++++ .maestro/tasks/contracts/c-44b421.json | 48 +++++ .maestro/tasks/contracts/c-4537bf.json | 63 ++++++ .maestro/tasks/contracts/c-46323a.json | 53 +++++ .maestro/tasks/contracts/c-46ad51.json | 47 +++++ .maestro/tasks/contracts/c-48c6ea.json | 41 ++++ .maestro/tasks/contracts/c-4930bc.json | 43 ++++ .maestro/tasks/contracts/c-4a73f5.json | 64 ++++++ .maestro/tasks/contracts/c-4d02a6.json | 68 +++++++ .maestro/tasks/contracts/c-4efb50.json | 43 ++++ .maestro/tasks/contracts/c-521413.json | 132 ++++++------ .maestro/tasks/contracts/c-53f8c8.json | 56 +++++ .maestro/tasks/contracts/c-545f94.json | 43 ++++ .maestro/tasks/contracts/c-56fc6b.json | 58 ++++++ .maestro/tasks/contracts/c-58c9b3.json | 43 ++++ .maestro/tasks/contracts/c-59c955.json | 58 ++++++ .maestro/tasks/contracts/c-59e3f5.json | 53 +++++ .maestro/tasks/contracts/c-5a67b2.json | 43 ++++ .maestro/tasks/contracts/c-5dd9e6.json | 43 ++++ .maestro/tasks/contracts/c-5e5f1b.json | 58 ++++++ .maestro/tasks/contracts/c-616b6d.json | 48 +++++ .maestro/tasks/contracts/c-64de72.json | 64 ++++++ .maestro/tasks/contracts/c-65b606.json | 43 ++++ .maestro/tasks/contracts/c-65c93f.json | 63 ++++++ .maestro/tasks/contracts/c-66740d.json | 43 ++++ .maestro/tasks/contracts/c-66b5da.json | 43 ++++ .maestro/tasks/contracts/c-670353.json | 68 +++++++ .maestro/tasks/contracts/c-678e4b.json | 43 ++++ .maestro/tasks/contracts/c-6882ee.json | 53 +++++ .maestro/tasks/contracts/c-69fb70.json | 63 ++++++ .maestro/tasks/contracts/c-6bfbf2.json | 41 ++++ .maestro/tasks/contracts/c-6c5676.json | 58 ++++++ .maestro/tasks/contracts/c-6cf47e.json | 43 ++++ .maestro/tasks/contracts/c-6dd4b5.json | 43 ++++ .maestro/tasks/contracts/c-6e0faf.json | 43 ++++ .maestro/tasks/contracts/c-6fdef8.json | 68 +++++++ .maestro/tasks/contracts/c-7068f8.json | 43 ++++ .maestro/tasks/contracts/c-767817.json | 43 ++++ .maestro/tasks/contracts/c-788775.json | 43 ++++ .maestro/tasks/contracts/c-7a66d1.json | 43 ++++ .maestro/tasks/contracts/c-7b35a3.json | 43 ++++ .maestro/tasks/contracts/c-7b91e1.json | 48 +++++ .maestro/tasks/contracts/c-7c3672.json | 132 ++++++------ .maestro/tasks/contracts/c-7f89b4.json | 43 ++++ .maestro/tasks/contracts/c-84f283.json | 53 +++++ .maestro/tasks/contracts/c-851506.json | 64 ++++++ .maestro/tasks/contracts/c-8541de.json | 64 ++++++ .maestro/tasks/contracts/c-8595ef.json | 68 +++++++ .maestro/tasks/contracts/c-868c33.json | 68 +++++++ .maestro/tasks/contracts/c-88e200.json | 46 +++++ .maestro/tasks/contracts/c-8be221.json | 46 +++++ .maestro/tasks/contracts/c-8c758a.json | 64 ++++++ .maestro/tasks/contracts/c-8d23e8.json | 63 ++++++ .maestro/tasks/contracts/c-8d39d3.json | 42 ++++ .maestro/tasks/contracts/c-8e6ced.json | 43 ++++ .maestro/tasks/contracts/c-8ec8e1.json | 43 ++++ .maestro/tasks/contracts/c-8f8103.json | 43 ++++ .maestro/tasks/contracts/c-9135db.json | 48 +++++ .maestro/tasks/contracts/c-94ed0c.json | 42 ++++ .maestro/tasks/contracts/c-9572b2.json | 43 ++++ .maestro/tasks/contracts/c-9594b9.json | 43 ++++ .maestro/tasks/contracts/c-960273.json | 53 +++++ .maestro/tasks/contracts/c-97a670.json | 43 ++++ .maestro/tasks/contracts/c-98539c.json | 58 ++++++ .maestro/tasks/contracts/c-9b461a.json | 43 ++++ .maestro/tasks/contracts/c-9d2ff0.json | 43 ++++ .maestro/tasks/contracts/c-9f4505.json | 68 +++++++ .maestro/tasks/contracts/c-a07b64.json | 63 ++++++ .maestro/tasks/contracts/c-a3cc1c.json | 63 ++++++ .maestro/tasks/contracts/c-a51e96.json | 43 ++++ .maestro/tasks/contracts/c-abad9b.json | 64 ++++++ .maestro/tasks/contracts/c-ac1d03.json | 41 ++++ .maestro/tasks/contracts/c-b00f37.json | 64 ++++++ .maestro/tasks/contracts/c-b3042e.json | 43 ++++ .maestro/tasks/contracts/c-b3ea78.json | 48 +++++ .maestro/tasks/contracts/c-b48bf0.json | 63 ++++++ .maestro/tasks/contracts/c-b4e562.json | 48 +++++ .maestro/tasks/contracts/c-bb0c59.json | 58 ++++++ .maestro/tasks/contracts/c-bb2dad.json | 43 ++++ .maestro/tasks/contracts/c-bb48d3.json | 43 ++++ .maestro/tasks/contracts/c-bd9972.json | 43 ++++ .maestro/tasks/contracts/c-be3c11.json | 43 ++++ .maestro/tasks/contracts/c-bf7e36.json | 43 ++++ .maestro/tasks/contracts/c-c1a71d.json | 58 ++++++ .maestro/tasks/contracts/c-c295a2.json | 43 ++++ .maestro/tasks/contracts/c-c296e8.json | 53 +++++ .maestro/tasks/contracts/c-c2c7ad.json | 46 +++++ .maestro/tasks/contracts/c-c32380.json | 53 +++++ .maestro/tasks/contracts/c-c44cae.json | 58 ++++++ .maestro/tasks/contracts/c-c44ef8.json | 53 +++++ .maestro/tasks/contracts/c-c62743.json | 53 +++++ .maestro/tasks/contracts/c-c68bf3.json | 58 ++++++ .maestro/tasks/contracts/c-c9b753.json | 43 ++++ .maestro/tasks/contracts/c-c9f395.json | 53 +++++ .maestro/tasks/contracts/c-ca3b4a.json | 43 ++++ .maestro/tasks/contracts/c-cb766a.json | 47 +++++ .maestro/tasks/contracts/c-cd7ec4.json | 43 ++++ .maestro/tasks/contracts/c-cf44a1.json | 53 +++++ .maestro/tasks/contracts/c-cfe3a5.json | 58 ++++++ .maestro/tasks/contracts/c-d0ac4c.json | 68 +++++++ .maestro/tasks/contracts/c-d16fdf.json | 43 ++++ .maestro/tasks/contracts/c-d1f9fb.json | 64 ++++++ .maestro/tasks/contracts/c-d2023b.json | 58 ++++++ .maestro/tasks/contracts/c-d26a99.json | 41 ++++ .maestro/tasks/contracts/c-d54700.json | 53 +++++ .maestro/tasks/contracts/c-d71720.json | 43 ++++ .maestro/tasks/contracts/c-d73d72.json | 36 ++++ .maestro/tasks/contracts/c-d76698.json | 58 ++++++ .maestro/tasks/contracts/c-d82f5c.json | 64 ++++++ .maestro/tasks/contracts/c-d90e27.json | 43 ++++ .maestro/tasks/contracts/c-d959a2.json | 43 ++++ .maestro/tasks/contracts/c-dd89a6.json | 43 ++++ .maestro/tasks/contracts/c-de4276.json | 53 +++++ .maestro/tasks/contracts/c-de5028.json | 64 ++++++ .maestro/tasks/contracts/c-df7c71.json | 37 ++++ .maestro/tasks/contracts/c-e0acee.json | 58 ++++++ .maestro/tasks/contracts/c-e1164a.json | 58 ++++++ .maestro/tasks/contracts/c-e1b585.json | 43 ++++ .maestro/tasks/contracts/c-e5a1cd.json | 58 ++++++ .maestro/tasks/contracts/c-e8bbec.json | 53 +++++ .maestro/tasks/contracts/c-e952b5.json | 58 ++++++ .maestro/tasks/contracts/c-ead934.json | 41 ++++ .maestro/tasks/contracts/c-eb0e89.json | 43 ++++ .maestro/tasks/contracts/c-ef88f6.json | 63 ++++++ .maestro/tasks/contracts/c-efb330.json | 63 ++++++ .maestro/tasks/contracts/c-f1bc8a.json | 58 ++++++ .maestro/tasks/contracts/c-f2a10d.json | 64 ++++++ .maestro/tasks/contracts/c-f4b245.json | 53 +++++ .maestro/tasks/contracts/c-f6083b.json | 53 +++++ .maestro/tasks/contracts/c-f62814.json | 53 +++++ .maestro/tasks/contracts/c-f7c029.json | 53 +++++ .maestro/tasks/contracts/c-f7e98d.json | 53 +++++ .maestro/tasks/contracts/c-f8ad18.json | 43 ++++ .maestro/tasks/contracts/c-f91953.json | 41 ++++ .maestro/tasks/contracts/c-fafa10.json | 43 ++++ .maestro/tasks/contracts/c-fd27d1.json | 43 ++++ .maestro/tasks/contracts/index.jsonl | 184 +++++++++++++++++ .maestro/tasks/tasks.jsonl | 191 +++++++++++++++++- .../vrd-1792095960897-05f747.json | 56 +++++ .../vrd-1792100100814-45442b.json | 53 +++++ .../vrd-1792150235104-564363.json | 58 ++++++ .../vrd-1792150263387-703eaf.json | 67 ++++++ .../vrd-1792150299594-567e52.json | 63 ++++++ .../vrd-1792150761543-e282f5.json | 57 ++++++ 562 files changed, 20933 insertions(+), 292 deletions(-) create mode 100644 .dockerignore create mode 100644 .maestro/contracts/tsk-mva026ub-1u86pn/v1.json create mode 100644 .maestro/contracts/tsk-mvaoq06i-s0i7vr/v1.json create mode 100644 .maestro/contracts/tsk-mvaw7lqp-9fes2a/v1.json create mode 100644 .maestro/contracts/tsk-mvawkapy-fwcf2f/v1.json create mode 100644 .maestro/contracts/tsk-mvawkapy-h9kogq/v1.json create mode 100644 .maestro/contracts/tsk-mvawkapy-lzvg52/v1.json create mode 100644 .maestro/contracts/tsk-mvawkapy-nam47g/v1.json create mode 100644 .maestro/contracts/tsk-mvawkapy-xdespa/v1.json create mode 100644 .maestro/contracts/tsk-mvb0jm49-3ytgwg/v1.json create mode 100644 .maestro/contracts/tsk-mvb0jm8t-w9qmja/v1.json create mode 100644 .maestro/contracts/tsk-mvb0jmcb-ajdki6/v1.json create mode 100644 .maestro/contracts/tsk-mvb0jmfu-jljtq9/v1.json create mode 100644 .maestro/contracts/tsk-mvb0jmj8-is6qdv/v1.json create mode 100644 .maestro/contracts/tsk-mvb3399t-pysl4g/v1.json create mode 100644 .maestro/contracts/tsk-mvb8iicl-xlm756/v1.json create mode 100644 .maestro/contracts/tsk-mvb8iifv-1kp3tn/v1.json create mode 100644 .maestro/contracts/tsk-mvb8iij2-kzsw5d/v1.json create mode 100644 .maestro/contracts/tsk-mvb8sxid-wpkrtg/v1.json create mode 100644 .maestro/contracts/tsk-mvb9dhfl-6cdwnk/v1.json create mode 100644 .maestro/contracts/tsk-mvb9wqu3-mo1a0u/v1.json create mode 100644 .maestro/contracts/tsk-mvbanfma-w9xz64/v1.json create mode 100644 .maestro/contracts/tsk-mvbbmmz0-vebdrd/v1.json create mode 100644 .maestro/contracts/tsk-mvbdsp4f-0yq2ci/v1.json create mode 100644 .maestro/contracts/tsk-mvbdsp4f-2m3dmu/v1.json create mode 100644 .maestro/contracts/tsk-mvbdsp4f-5hrfbn/v1.json create mode 100644 .maestro/contracts/tsk-mvbdsp4f-a1hau9/v1.json create mode 100644 .maestro/contracts/tsk-mvbdsp4f-bcinb5/v1.json create mode 100644 .maestro/contracts/tsk-mvbdsp4f-c6kk1f/v1.json create mode 100644 .maestro/contracts/tsk-mvbdsp4f-cbg990/v1.json create mode 100644 .maestro/contracts/tsk-mvbdsp4f-e7yo8j/v1.json create mode 100644 .maestro/contracts/tsk-mvbdsp4f-ex9rcm/v1.json create mode 100644 .maestro/contracts/tsk-mvbdsp4f-i8nl5p/v1.json create mode 100644 .maestro/contracts/tsk-mvbdsp4f-p6l6nv/v1.json create mode 100644 .maestro/contracts/tsk-mvbdsp4f-souo6g/v1.json create mode 100644 .maestro/contracts/tsk-mvbdsp4f-spcif4/v1.json create mode 100644 .maestro/contracts/tsk-mvbdsp4f-ua6x6x/v1.json create mode 100644 .maestro/contracts/tsk-mvbdsp4f-wd3fai/v1.json create mode 100644 .maestro/contracts/tsk-mvbdsp4f-xmojqz/v1.json create mode 100644 .maestro/contracts/tsk-mvbdsp4f-y1xx0b/v1.json create mode 100644 .maestro/contracts/tsk-mvbg796n-xpvbrc/v1.json create mode 100644 .maestro/contracts/tsk-mvbh3xq5-gl0mv7/v1.json create mode 100644 .maestro/contracts/tsk-mvbh3xu3-3dzjef/v1.json create mode 100644 .maestro/contracts/tsk-mvbh3xxf-emsxrb/v1.json create mode 100644 .maestro/contracts/tsk-mvbh3y0u-n3qtug/v1.json create mode 100644 .maestro/contracts/tsk-mvbh3y45-3zte0e/v1.json create mode 100644 .maestro/contracts/tsk-mvbh3y7e-zu7pb4/v1.json create mode 100644 .maestro/contracts/tsk-mvbh3yap-l09oxk/v1.json create mode 100644 .maestro/contracts/tsk-mvbh3ye3-0dqyir/v1.json create mode 100644 .maestro/contracts/tsk-mvbh3yhd-mlyvgg/v1.json create mode 100644 .maestro/contracts/tsk-mvbh3yki-lxt7mh/v1.json create mode 100644 .maestro/contracts/tsk-mvbh3yns-8zm2bm/v1.json create mode 100644 .maestro/contracts/tsk-mvbh3yr2-bsovx7/v1.json create mode 100644 .maestro/contracts/tsk-mvbh3yue-83n9vp/v1.json create mode 100644 .maestro/contracts/tsk-mvbh3yxp-p9pp0e/v1.json create mode 100644 .maestro/contracts/tsk-mvbh3z0z-819ef0/v1.json create mode 100644 .maestro/contracts/tsk-mvbh3z49-hbvk3j/v1.json create mode 100644 .maestro/contracts/tsk-mvbh3z7s-2ol5gk/v1.json create mode 100644 .maestro/contracts/tsk-mvbl55h5-z4i872/v1.json create mode 100644 .maestro/contracts/tsk-mvblcbcl-ojq8t0/v1.json create mode 100644 .maestro/contracts/tsk-mvblcbgd-lktftw/v1.json create mode 100644 .maestro/contracts/tsk-mvblcbjl-ijn35n/v1.json create mode 100644 .maestro/contracts/tsk-mvblcbms-o8fsc1/v1.json create mode 100644 .maestro/contracts/tsk-mvbm09jg-c92j9a/v1.json create mode 100644 .maestro/contracts/tsk-mvbp968a-2m4p9i/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv2xv-k2omgn/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv31o-sevooz/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv35b-3d8syc/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv38n-qmey5i/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv3bz-jrd70e/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv3fd-36woc8/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv3iq-y7wzar/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv3m4-28cr81/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv3pj-tef3al/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv3sy-7786hr/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv3wa-40y1j9/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv3zi-qxn0mq/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv42t-2myvtg/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv466-ezgto8/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv49j-j19atg/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv4cx-u59do2/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv4gc-nokr7e/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv4jp-snazrp/v1.json create mode 100644 .maestro/contracts/tsk-mvcjv4mz-pvmh4b/v1.json create mode 100644 .maestro/contracts/tsk-mvcpexet-7d5r2f/v1.json create mode 100644 .maestro/contracts/tsk-mvcpexhy-k9wn2c/v1.json create mode 100644 .maestro/contracts/tsk-mvcpexl4-f87btw/v1.json create mode 100644 .maestro/contracts/tsk-mvcpexoh-4d52ro/v1.json create mode 100644 .maestro/contracts/tsk-mvcpexrp-ffsx4n/v1.json create mode 100644 .maestro/contracts/tsk-mvcpexut-0na9la/v1.json create mode 100644 .maestro/contracts/tsk-mvcpexxx-4ohdhl/v1.json create mode 100644 .maestro/contracts/tsk-mvcpey14-2k0wis/v1.json create mode 100644 .maestro/contracts/tsk-mvcpey4b-b89h8y/v1.json create mode 100644 .maestro/contracts/tsk-mvcpey7z-zm5f97/v1.json create mode 100644 .maestro/contracts/tsk-mvcpeyb9-t3b4bu/v1.json create mode 100644 .maestro/contracts/tsk-mvcpeyei-jtr1wv/v1.json create mode 100644 .maestro/contracts/tsk-mvcpeyhp-jmyzba/v1.json create mode 100644 .maestro/contracts/tsk-mvcpeyky-peqniz/v1.json create mode 100644 .maestro/contracts/tsk-mvcrl0u6-q95oms/v1.json create mode 100644 .maestro/contracts/tsk-mvcrl0xj-ldfumo/v1.json create mode 100644 .maestro/contracts/tsk-mvcrl10t-m63g70/v1.json create mode 100644 .maestro/contracts/tsk-mvcrl146-fp6v3u/v1.json create mode 100644 .maestro/contracts/tsk-mvd9mln9-g1f798/v1.json create mode 100644 .maestro/contracts/tsk-mvd9mlqi-lnd416/v1.json create mode 100644 .maestro/contracts/tsk-mvd9mltr-5gll5q/v1.json create mode 100644 .maestro/contracts/tsk-mvd9mlx1-w6ietq/v1.json create mode 100644 .maestro/contracts/tsk-mvdf5hvl-qkcn8e/v1.json create mode 100644 .maestro/contracts/tsk-mvdfbkr7-8wkwg5/v1.json create mode 100644 .maestro/contracts/tsk-mvdfoc36-i9tzlz/v1.json create mode 100644 .maestro/contracts/tsk-mvdfsdm7-rk163d/v1.json create mode 100644 .maestro/contracts/tsk-mvdftqvl-77wo59/v1.json create mode 100644 .maestro/contracts/tsk-mvdfwlxi-vkqc20/v1.json create mode 100644 .maestro/contracts/tsk-mvdgoa0t-18vdc1/v1.json create mode 100644 .maestro/contracts/tsk-mvdgtk7t-ly5lul/v1.json create mode 100644 .maestro/contracts/tsk-mvdgvhpd-9mxdxa/v1.json create mode 100644 .maestro/contracts/tsk-mvdh79dx-xb500e/v1.json create mode 100644 .maestro/contracts/tsk-mvdh8or6-37dwa9/v1.json create mode 100644 .maestro/contracts/tsk-mvdhyh5g-5yq01o/v1.json create mode 100644 .maestro/contracts/tsk-mvdhzjr6-6xpm3i/v1.json create mode 100644 .maestro/contracts/tsk-mvdi4g74-udxxs3/v1.json create mode 100644 .maestro/contracts/tsk-mveorm3d-m113ii/v1.json create mode 100644 .maestro/contracts/tsk-mveouc1i-byube1/v1.json create mode 100644 .maestro/contracts/tsk-mveovcwy-q3fo5g/v1.json create mode 100644 .maestro/contracts/tsk-mveovhgv-tk9wbj/v1.json create mode 100644 .maestro/contracts/tsk-mveovo0t-2qcudv/v1.json create mode 100644 .maestro/contracts/tsk-mveox57m-tvw0xp/v1.json create mode 100644 .maestro/contracts/tsk-mveox92p-bcbotq/v1.json create mode 100644 .maestro/contracts/tsk-mveoxlor-3b9mxh/v1.json create mode 100644 .maestro/contracts/tsk-mveoxp2g-hrduy2/v1.json create mode 100644 .maestro/contracts/tsk-mveoxua6-li0n6j/v1.json create mode 100644 .maestro/contracts/tsk-mveoxvjj-lwme2p/v1.json create mode 100644 .maestro/contracts/tsk-mvep8nuq-do4lci/v1.json create mode 100644 .maestro/contracts/tsk-mvepmv97-fe5jxj/v1.json create mode 100644 .maestro/contracts/tsk-mvepyvw7-ohtsdc/v1.json create mode 100644 .maestro/contracts/tsk-mvepzuqe-owgjul/v1.json create mode 100644 .maestro/contracts/tsk-mveq0q6b-7wurki/v1.json create mode 100644 .maestro/contracts/tsk-mveqc5k9-6weba4/v1.json create mode 100644 .maestro/contracts/tsk-mverrnjm-v3bnoo/v1.json create mode 100644 .maestro/contracts/tsk-mvervb9q-tizqjp/v1.json create mode 100644 .maestro/contracts/tsk-mvet31ys-gowf3l/v1.json create mode 100644 .maestro/contracts/tsk-mveutmw9-l3uhuv/v1.json create mode 100644 .maestro/contracts/tsk-mveuyqcl-h6cafd/v1.json create mode 100644 .maestro/contracts/tsk-mvey8ngt-tghgvt/v1.json create mode 100644 .maestro/contracts/tsk-mvey915n-8cs2b5/v1.json create mode 100644 .maestro/contracts/tsk-mvf4c3oa-dc40fo/v1.json create mode 100644 .maestro/contracts/tsk-mvf4c3s4-xn4847/v1.json create mode 100644 .maestro/contracts/tsk-mvf4xez4-00j1za/v1.json create mode 100644 .maestro/contracts/tsk-mvf4xf42-qjodki/v1.json create mode 100644 .maestro/contracts/tsk-mvf4xf8t-hyoe72/v1.json create mode 100644 .maestro/contracts/tsk-mvf70f4h-isuu0c/v1.json create mode 100644 .maestro/contracts/tsk-mvf70fjh-fmn5my/v1.json create mode 100644 .maestro/contracts/tsk-mvf70fq3-r9ruuc/v1.json create mode 100644 .maestro/contracts/tsk-mvf7jpll-z8910m/v1.json create mode 100644 .maestro/contracts/tsk-mvf9rld7-x1htmf/v1.json create mode 100644 .maestro/contracts/tsk-mvf9rlgq-6v0ddu/v1.json create mode 100644 .maestro/contracts/tsk-mvf9rlkh-oyyusj/v1.json create mode 100644 .maestro/contracts/tsk-mvf9rlnr-ts175i/v1.json create mode 100644 .maestro/contracts/tsk-mvf9rlqz-0bc6j8/v1.json create mode 100644 .maestro/contracts/tsk-mvf9twme-9avzxk/v1.json create mode 100644 .maestro/contracts/tsk-mvfamuiv-lyz0jg/v1.json create mode 100644 .maestro/contracts/tsk-mvfc5h86-z8ef14/v1.json create mode 100644 .maestro/contracts/tsk-mvfdi1tt-t8vr7z/v1.json create mode 100644 .maestro/contracts/tsk-mvffsow8-ogb7fz/v1.json create mode 100644 .maestro/contracts/tsk-mvfiv513-vvk5an/v1.json create mode 100644 .maestro/contracts/tsk-mvfixl0v-di486t/v1.json create mode 100644 .maestro/contracts/tsk-mvfixl4d-wu25gw/v1.json create mode 100644 .maestro/contracts/tsk-mvfl8a7k-641iow/v1.json create mode 100644 .maestro/contracts/tsk-mvfl8acb-0806yq/v1.json create mode 100644 .maestro/contracts/tsk-mvfl8agk-vm3y4j/v1.json create mode 100644 .maestro/contracts/tsk-mvfns901-uz5ej3/v1.json create mode 100644 .maestro/contracts/tsk-mvfo16u4-pdz4fg/v1.json create mode 100644 .maestro/contracts/tsk-mvfohzqk-xg47t7/v1.json create mode 100644 .maestro/contracts/tsk-mvfoqs1j-gik5ls/v1.json create mode 100644 .maestro/contracts/tsk-mvfr897j-gvxts8/v1.json create mode 100644 .maestro/contracts/tsk-mvfr89bs-sr3cs4/v1.json create mode 100644 .maestro/contracts/tsk-mvfusz4r-iiqduh/v1.json create mode 100644 .maestro/contracts/tsk-mvfusz8h-6pkxkz/v1.json create mode 100644 .maestro/contracts/tsk-mvfuszbz-xpikgu/v1.json create mode 100644 .maestro/contracts/tsk-mvfypvdw-vae18k/v1.json create mode 100644 .maestro/contracts/tsk-mvg7dsl7-vj0q93/v1.json create mode 100644 .maestro/handoffs/hnd-mva0298c-me1y21.json create mode 100644 .maestro/handoffs/hnd-mvaoq242-me2o4m.json create mode 100644 .maestro/handoffs/hnd-mvaw7nlf-ed7vrh.json create mode 100644 .maestro/handoffs/hnd-mvawksua-h712jx.json create mode 100644 .maestro/handoffs/hnd-mvawkszp-29udpy.json create mode 100644 .maestro/handoffs/hnd-mvawkt3c-5swjq7.json create mode 100644 .maestro/handoffs/hnd-mvawkt70-9y0x1w.json create mode 100644 .maestro/handoffs/hnd-mvawktb2-t3ltbl.json create mode 100644 .maestro/handoffs/hnd-mvawktep-3xvzc4.json create mode 100644 .maestro/handoffs/hnd-mvawktj5-mwz6z0.json create mode 100644 .maestro/handoffs/hnd-mvawktn5-g53b4r.json create mode 100644 .maestro/handoffs/hnd-mvawktqx-vnlrf9.json create mode 100644 .maestro/handoffs/hnd-mvb0jt66-2g908f.json create mode 100644 .maestro/handoffs/hnd-mvb0jt9s-fvr2yl.json create mode 100644 .maestro/handoffs/hnd-mvb0jtdi-ursc66.json create mode 100644 .maestro/handoffs/hnd-mvb0jth1-tdn9by.json create mode 100644 .maestro/handoffs/hnd-mvb0jtkl-vt76vb.json create mode 100644 .maestro/handoffs/hnd-mvb33b29-ii64px.json create mode 100644 .maestro/handoffs/hnd-mvb8imbw-zpztu8.json create mode 100644 .maestro/handoffs/hnd-mvb8imf4-fwzmyp.json create mode 100644 .maestro/handoffs/hnd-mvb8imig-3o9kbh.json create mode 100644 .maestro/handoffs/hnd-mvb8sz6d-olit3r.json create mode 100644 .maestro/handoffs/hnd-mvb9djg1-k0xl30.json create mode 100644 .maestro/handoffs/hnd-mvb9wspe-9rw8zj.json create mode 100644 .maestro/handoffs/hnd-mvbanfqt-hdmvj4.json create mode 100644 .maestro/handoffs/hnd-mvbbmn4y-n67iy2.json create mode 100644 .maestro/handoffs/hnd-mvbbxyhh-7fero4.json create mode 100644 .maestro/handoffs/hnd-mvbdt6e0-cq4w17.json create mode 100644 .maestro/handoffs/hnd-mvbdt6hw-cwl9w5.json create mode 100644 .maestro/handoffs/hnd-mvbdt6ld-m5zgd1.json create mode 100644 .maestro/handoffs/hnd-mvbdt6ou-pxb02n.json create mode 100644 .maestro/handoffs/hnd-mvbdxynm-ezlxsq.json create mode 100644 .maestro/handoffs/hnd-mvbdxyr4-xhp815.json create mode 100644 .maestro/handoffs/hnd-mvbeajau-9niym8.json create mode 100644 .maestro/handoffs/hnd-mvbebkra-115n3a.json create mode 100644 .maestro/handoffs/hnd-mvbehsqt-0lumo0.json create mode 100644 .maestro/handoffs/hnd-mvbfbg6b-osf2tg.json create mode 100644 .maestro/handoffs/hnd-mvbfbgfb-oqy0zz.json create mode 100644 .maestro/handoffs/hnd-mvbfl58d-tt87q9.json create mode 100644 .maestro/handoffs/hnd-mvbg11az-pplnlu.json create mode 100644 .maestro/handoffs/hnd-mvbg11fu-zd71q6.json create mode 100644 .maestro/handoffs/hnd-mvbg11k5-o1xub8.json create mode 100644 .maestro/handoffs/hnd-mvbg7cwu-eruy8b.json create mode 100644 .maestro/handoffs/hnd-mvbgn40k-5y8z1q.json create mode 100644 .maestro/handoffs/hnd-mvbh37v7-jvnioq.json create mode 100644 .maestro/handoffs/hnd-mvbh47rq-2yd70k.json create mode 100644 .maestro/handoffs/hnd-mvbh47wl-18lsui.json create mode 100644 .maestro/handoffs/hnd-mvbh4801-qu8h90.json create mode 100644 .maestro/handoffs/hnd-mvbh483d-zraw8o.json create mode 100644 .maestro/handoffs/hnd-mvbh486t-2jsuyj.json create mode 100644 .maestro/handoffs/hnd-mvbh48a5-61nmc9.json create mode 100644 .maestro/handoffs/hnd-mvbh48di-biv6bq.json create mode 100644 .maestro/handoffs/hnd-mvbhysda-q1j7ue.json create mode 100644 .maestro/handoffs/hnd-mvbhysrz-wr7b5l.json create mode 100644 .maestro/handoffs/hnd-mvbhyt6n-uzy320.json create mode 100644 .maestro/handoffs/hnd-mvbhythp-4ewmoa.json create mode 100644 .maestro/handoffs/hnd-mvbixqfc-etm0af.json create mode 100644 .maestro/handoffs/hnd-mvbixqtv-unbvn4.json create mode 100644 .maestro/handoffs/hnd-mvbixr8c-4u9dah.json create mode 100644 .maestro/handoffs/hnd-mvbixrim-0r572w.json create mode 100644 .maestro/handoffs/hnd-mvbjroej-gj30uo.json create mode 100644 .maestro/handoffs/hnd-mvbko9c8-l29lcm.json create mode 100644 .maestro/handoffs/hnd-mvbl5872-wy4cqd.json create mode 100644 .maestro/handoffs/hnd-mvblcgxb-7689rf.json create mode 100644 .maestro/handoffs/hnd-mvbm0dm9-uaot89.json create mode 100644 .maestro/handoffs/hnd-mvbm0dxo-wfeibq.json create mode 100644 .maestro/handoffs/hnd-mvboub0s-yyifmq.json create mode 100644 .maestro/handoffs/hnd-mvboub42-dptr1j.json create mode 100644 .maestro/handoffs/hnd-mvbp9cb2-ynfrbt.json create mode 100644 .maestro/handoffs/hnd-mvcjwrrq-bwj4fl.json create mode 100644 .maestro/handoffs/hnd-mvcjwrv7-e8wvsl.json create mode 100644 .maestro/handoffs/hnd-mvcjwryk-dawvm1.json create mode 100644 .maestro/handoffs/hnd-mvcjws21-86akbh.json create mode 100644 .maestro/handoffs/hnd-mvckbvk5-jaqzzo.json create mode 100644 .maestro/handoffs/hnd-mvckciuq-61emr2.json create mode 100644 .maestro/handoffs/hnd-mvcke9ej-skzb62.json create mode 100644 .maestro/handoffs/hnd-mvcke9im-f2w51o.json create mode 100644 .maestro/handoffs/hnd-mvcke9ml-380z2z.json create mode 100644 .maestro/handoffs/hnd-mvckg208-lfaowm.json create mode 100644 .maestro/handoffs/hnd-mvcngf2f-n63f4h.json create mode 100644 .maestro/handoffs/hnd-mvcngf76-3hfcz7.json create mode 100644 .maestro/handoffs/hnd-mvcngfau-c1xgey.json create mode 100644 .maestro/handoffs/hnd-mvcngff3-emrowy.json create mode 100644 .maestro/handoffs/hnd-mvcno4nc-tx4u4d.json create mode 100644 .maestro/handoffs/hnd-mvcntjt5-gf4zzv.json create mode 100644 .maestro/handoffs/hnd-mvcohj0b-sbgsmm.json create mode 100644 .maestro/handoffs/hnd-mvcp8ti4-ckcuoy.json create mode 100644 .maestro/handoffs/hnd-mvcpf9cu-7b2wdg.json create mode 100644 .maestro/handoffs/hnd-mvcphvsc-pxv725.json create mode 100644 .maestro/handoffs/hnd-mvcpjskd-1nf2m1.json create mode 100644 .maestro/handoffs/hnd-mvcpu9nh-vfrxqf.json create mode 100644 .maestro/handoffs/hnd-mvcpw45p-0txp3i.json create mode 100644 .maestro/handoffs/hnd-mvcpyh8n-xr8446.json create mode 100644 .maestro/handoffs/hnd-mvcpztar-w3hc5j.json create mode 100644 .maestro/handoffs/hnd-mvcqbavx-vvrnlc.json create mode 100644 .maestro/handoffs/hnd-mvcqjayl-6b57lx.json create mode 100644 .maestro/handoffs/hnd-mvcqjb23-4npqiz.json create mode 100644 .maestro/handoffs/hnd-mvcqkor9-hq8oxz.json create mode 100644 .maestro/handoffs/hnd-mvcqnczr-kyf3rw.json create mode 100644 .maestro/handoffs/hnd-mvcqw1h2-424dvt.json create mode 100644 .maestro/handoffs/hnd-mvcriam1-bqhjqh.json create mode 100644 .maestro/handoffs/hnd-mvcrl4hr-od6djl.json create mode 100644 .maestro/handoffs/hnd-mvcs34g2-4loqul.json create mode 100644 .maestro/handoffs/hnd-mvcsu6cd-4i9s43.json create mode 100644 .maestro/handoffs/hnd-mvcsu6fr-rx1lcj.json create mode 100644 .maestro/handoffs/hnd-mvcyx5nd-xcvxfx.json create mode 100644 .maestro/handoffs/hnd-mvd9mq32-vs3dxb.json create mode 100644 .maestro/handoffs/hnd-mvd9mq6d-56bytn.json create mode 100644 .maestro/handoffs/hnd-mvd9mq9j-64lcpp.json create mode 100644 .maestro/handoffs/hnd-mvd9mqct-s3qigy.json create mode 100644 .maestro/handoffs/hnd-mvdf5hzv-1vd86z.json create mode 100644 .maestro/handoffs/hnd-mvdfbkz0-4rc5uf.json create mode 100644 .maestro/handoffs/hnd-mvdfoc6y-xmudjx.json create mode 100644 .maestro/handoffs/hnd-mvdfsdqy-u2cjrp.json create mode 100644 .maestro/handoffs/hnd-mvdftr16-dnrzzq.json create mode 100644 .maestro/handoffs/hnd-mvdfwmhn-laeq8g.json create mode 100644 .maestro/handoffs/hnd-mvdgoahm-j72n5v.json create mode 100644 .maestro/handoffs/hnd-mvdgtkfl-rrer1t.json create mode 100644 .maestro/handoffs/hnd-mvdgvhst-0wxhbe.json create mode 100644 .maestro/handoffs/hnd-mvdh79oi-5t4vmp.json create mode 100644 .maestro/handoffs/hnd-mvdh8oum-bldf8i.json create mode 100644 .maestro/handoffs/hnd-mvdhyi3w-88v6t5.json create mode 100644 .maestro/handoffs/hnd-mvdhzjyq-o3bz4i.json create mode 100644 .maestro/handoffs/hnd-mvdi4gnm-or3p2i.json create mode 100644 .maestro/handoffs/hnd-mveorq6o-e28wi2.json create mode 100644 .maestro/handoffs/hnd-mveoueu4-u3in6l.json create mode 100644 .maestro/handoffs/hnd-mveovgh3-aes6qu.json create mode 100644 .maestro/handoffs/hnd-mveovkga-prr8eq.json create mode 100644 .maestro/handoffs/hnd-mveovq6s-hyok5n.json create mode 100644 .maestro/handoffs/hnd-mveox9ks-1r3cy1.json create mode 100644 .maestro/handoffs/hnd-mveoxbsl-ar20k6.json create mode 100644 .maestro/handoffs/hnd-mveoxqc3-tglltz.json create mode 100644 .maestro/handoffs/hnd-mveoxtkq-lv8wud.json create mode 100644 .maestro/handoffs/hnd-mveoxwda-d3ftoo.json create mode 100644 .maestro/handoffs/hnd-mveoxxlk-q6wkhh.json create mode 100644 .maestro/handoffs/hnd-mvep8rov-ch6ejb.json create mode 100644 .maestro/handoffs/hnd-mvepmxyt-7093rp.json create mode 100644 .maestro/handoffs/hnd-mvepyyol-8q4c6f.json create mode 100644 .maestro/handoffs/hnd-mvepzwqg-3qfxix.json create mode 100644 .maestro/handoffs/hnd-mveq0u8w-04mvta.json create mode 100644 .maestro/handoffs/hnd-mveqc9ku-ih146m.json create mode 100644 .maestro/handoffs/hnd-mverrqrl-kz3loy.json create mode 100644 .maestro/handoffs/hnd-mvervfj5-f7s37a.json create mode 100644 .maestro/handoffs/hnd-mvet362j-c5n0q4.json create mode 100644 .maestro/handoffs/hnd-mveutsko-5e3598.json create mode 100644 .maestro/handoffs/hnd-mveuytou-dg69ok.json create mode 100644 .maestro/handoffs/hnd-mvey8qiy-9k97jg.json create mode 100644 .maestro/handoffs/hnd-mvey93y7-xgkwdd.json create mode 100644 .maestro/handoffs/hnd-mvf4c7jq-hcrpup.json create mode 100644 .maestro/handoffs/hnd-mvf4c7n4-h8zr34.json create mode 100644 .maestro/handoffs/hnd-mvf4xhps-rlvmox.json create mode 100644 .maestro/handoffs/hnd-mvf4xht7-w7txiw.json create mode 100644 .maestro/handoffs/hnd-mvf4xhwj-yuuhyt.json create mode 100644 .maestro/handoffs/hnd-mvf70j04-ignopf.json create mode 100644 .maestro/handoffs/hnd-mvf70j3t-1647p5.json create mode 100644 .maestro/handoffs/hnd-mvf70jau-jdae3e.json create mode 100644 .maestro/handoffs/hnd-mvf7k37j-xa6yn0.json create mode 100644 .maestro/handoffs/hnd-mvf9ru02-w16tdx.json create mode 100644 .maestro/handoffs/hnd-mvf9ru3k-bc3z8d.json create mode 100644 .maestro/handoffs/hnd-mvf9ru73-dl7s09.json create mode 100644 .maestro/handoffs/hnd-mvf9ruaj-y9f0rq.json create mode 100644 .maestro/handoffs/hnd-mvf9rudx-ukmrm3.json create mode 100644 .maestro/handoffs/hnd-mvf9tz10-1wjgu2.json create mode 100644 .maestro/handoffs/hnd-mvfamy84-dk5m97.json create mode 100644 .maestro/handoffs/hnd-mvfc5l2f-u5kopa.json create mode 100644 .maestro/handoffs/hnd-mvfdi4tp-6rj4ti.json create mode 100644 .maestro/handoffs/hnd-mvffsve2-moiovn.json create mode 100644 .maestro/handoffs/hnd-mvfiv7r1-eqp13y.json create mode 100644 .maestro/handoffs/hnd-mvfixpxa-dfovpz.json create mode 100644 .maestro/handoffs/hnd-mvfixq0m-cyzmzk.json create mode 100644 .maestro/handoffs/hnd-mvfl8u9a-1h9ez0.json create mode 100644 .maestro/handoffs/hnd-mvfl8uck-05ow73.json create mode 100644 .maestro/handoffs/hnd-mvfl8ufu-d0ys04.json create mode 100644 .maestro/handoffs/hnd-mvfnsfmi-3foa18.json create mode 100644 .maestro/handoffs/hnd-mvfo19vx-6d7juf.json create mode 100644 .maestro/handoffs/hnd-mvfoia1c-2yr46a.json create mode 100644 .maestro/handoffs/hnd-mvfopmkt-uacfpv.json create mode 100644 .maestro/handoffs/hnd-mvfoquw2-hxgwko.json create mode 100644 .maestro/handoffs/hnd-mvfr8bys-1y3gcf.json create mode 100644 .maestro/handoffs/hnd-mvfr8c25-iso6zn.json create mode 100644 .maestro/handoffs/hnd-mvfut7k6-n8w03d.json create mode 100644 .maestro/handoffs/hnd-mvfut7nh-z4d33s.json create mode 100644 .maestro/handoffs/hnd-mvfut7qu-gfx0k2.json create mode 100644 .maestro/handoffs/hnd-mvfyq2gw-jnzct2.json create mode 100644 .maestro/handoffs/hnd-mvg7didq-expbem.json create mode 100644 .maestro/handoffs/hnd-mvg7dwx5-3f47z9.json create mode 100644 .maestro/policies/.pending-loosenings.json create mode 100644 .maestro/tasks/contracts/c-003692.json create mode 100644 .maestro/tasks/contracts/c-02ad50.json create mode 100644 .maestro/tasks/contracts/c-05c7ce.json create mode 100644 .maestro/tasks/contracts/c-0aa267.json create mode 100644 .maestro/tasks/contracts/c-0ace4c.json create mode 100644 .maestro/tasks/contracts/c-0fb2aa.json create mode 100644 .maestro/tasks/contracts/c-101f8f.json create mode 100644 .maestro/tasks/contracts/c-10ff0e.json create mode 100644 .maestro/tasks/contracts/c-127d7d.json create mode 100644 .maestro/tasks/contracts/c-12b6e9.json create mode 100644 .maestro/tasks/contracts/c-13f98e.json create mode 100644 .maestro/tasks/contracts/c-147f9f.json create mode 100644 .maestro/tasks/contracts/c-16e5d7.json create mode 100644 .maestro/tasks/contracts/c-18d82e.json create mode 100644 .maestro/tasks/contracts/c-19cd27.json create mode 100644 .maestro/tasks/contracts/c-1a0e33.json create mode 100644 .maestro/tasks/contracts/c-1ba8bb.json create mode 100644 .maestro/tasks/contracts/c-1c636c.json create mode 100644 .maestro/tasks/contracts/c-1e07fc.json create mode 100644 .maestro/tasks/contracts/c-1fb1ff.json create mode 100644 .maestro/tasks/contracts/c-2123cb.json create mode 100644 .maestro/tasks/contracts/c-2136ca.json create mode 100644 .maestro/tasks/contracts/c-246560.json create mode 100644 .maestro/tasks/contracts/c-24b4dd.json create mode 100644 .maestro/tasks/contracts/c-24f7a7.json create mode 100644 .maestro/tasks/contracts/c-2618bf.json create mode 100644 .maestro/tasks/contracts/c-2a2a87.json create mode 100644 .maestro/tasks/contracts/c-2a710f.json create mode 100644 .maestro/tasks/contracts/c-2c1844.json create mode 100644 .maestro/tasks/contracts/c-2dbc7a.json create mode 100644 .maestro/tasks/contracts/c-2dfbca.json create mode 100644 .maestro/tasks/contracts/c-2e2040.json create mode 100644 .maestro/tasks/contracts/c-322722.json create mode 100644 .maestro/tasks/contracts/c-3254b7.json create mode 100644 .maestro/tasks/contracts/c-334ee9.json create mode 100644 .maestro/tasks/contracts/c-3565d2.json create mode 100644 .maestro/tasks/contracts/c-3628a6.json create mode 100644 .maestro/tasks/contracts/c-380a8e.json create mode 100644 .maestro/tasks/contracts/c-3bc0d9.json create mode 100644 .maestro/tasks/contracts/c-3d5ca3.json create mode 100644 .maestro/tasks/contracts/c-3e3e19.json create mode 100644 .maestro/tasks/contracts/c-3e4a1c.json create mode 100644 .maestro/tasks/contracts/c-3f05f7.json create mode 100644 .maestro/tasks/contracts/c-3f3d66.json create mode 100644 .maestro/tasks/contracts/c-413616.json create mode 100644 .maestro/tasks/contracts/c-436b66.json create mode 100644 .maestro/tasks/contracts/c-44b421.json create mode 100644 .maestro/tasks/contracts/c-4537bf.json create mode 100644 .maestro/tasks/contracts/c-46323a.json create mode 100644 .maestro/tasks/contracts/c-46ad51.json create mode 100644 .maestro/tasks/contracts/c-48c6ea.json create mode 100644 .maestro/tasks/contracts/c-4930bc.json create mode 100644 .maestro/tasks/contracts/c-4a73f5.json create mode 100644 .maestro/tasks/contracts/c-4d02a6.json create mode 100644 .maestro/tasks/contracts/c-4efb50.json create mode 100644 .maestro/tasks/contracts/c-53f8c8.json create mode 100644 .maestro/tasks/contracts/c-545f94.json create mode 100644 .maestro/tasks/contracts/c-56fc6b.json create mode 100644 .maestro/tasks/contracts/c-58c9b3.json create mode 100644 .maestro/tasks/contracts/c-59c955.json create mode 100644 .maestro/tasks/contracts/c-59e3f5.json create mode 100644 .maestro/tasks/contracts/c-5a67b2.json create mode 100644 .maestro/tasks/contracts/c-5dd9e6.json create mode 100644 .maestro/tasks/contracts/c-5e5f1b.json create mode 100644 .maestro/tasks/contracts/c-616b6d.json create mode 100644 .maestro/tasks/contracts/c-64de72.json create mode 100644 .maestro/tasks/contracts/c-65b606.json create mode 100644 .maestro/tasks/contracts/c-65c93f.json create mode 100644 .maestro/tasks/contracts/c-66740d.json create mode 100644 .maestro/tasks/contracts/c-66b5da.json create mode 100644 .maestro/tasks/contracts/c-670353.json create mode 100644 .maestro/tasks/contracts/c-678e4b.json create mode 100644 .maestro/tasks/contracts/c-6882ee.json create mode 100644 .maestro/tasks/contracts/c-69fb70.json create mode 100644 .maestro/tasks/contracts/c-6bfbf2.json create mode 100644 .maestro/tasks/contracts/c-6c5676.json create mode 100644 .maestro/tasks/contracts/c-6cf47e.json create mode 100644 .maestro/tasks/contracts/c-6dd4b5.json create mode 100644 .maestro/tasks/contracts/c-6e0faf.json create mode 100644 .maestro/tasks/contracts/c-6fdef8.json create mode 100644 .maestro/tasks/contracts/c-7068f8.json create mode 100644 .maestro/tasks/contracts/c-767817.json create mode 100644 .maestro/tasks/contracts/c-788775.json create mode 100644 .maestro/tasks/contracts/c-7a66d1.json create mode 100644 .maestro/tasks/contracts/c-7b35a3.json create mode 100644 .maestro/tasks/contracts/c-7b91e1.json create mode 100644 .maestro/tasks/contracts/c-7f89b4.json create mode 100644 .maestro/tasks/contracts/c-84f283.json create mode 100644 .maestro/tasks/contracts/c-851506.json create mode 100644 .maestro/tasks/contracts/c-8541de.json create mode 100644 .maestro/tasks/contracts/c-8595ef.json create mode 100644 .maestro/tasks/contracts/c-868c33.json create mode 100644 .maestro/tasks/contracts/c-88e200.json create mode 100644 .maestro/tasks/contracts/c-8be221.json create mode 100644 .maestro/tasks/contracts/c-8c758a.json create mode 100644 .maestro/tasks/contracts/c-8d23e8.json create mode 100644 .maestro/tasks/contracts/c-8d39d3.json create mode 100644 .maestro/tasks/contracts/c-8e6ced.json create mode 100644 .maestro/tasks/contracts/c-8ec8e1.json create mode 100644 .maestro/tasks/contracts/c-8f8103.json create mode 100644 .maestro/tasks/contracts/c-9135db.json create mode 100644 .maestro/tasks/contracts/c-94ed0c.json create mode 100644 .maestro/tasks/contracts/c-9572b2.json create mode 100644 .maestro/tasks/contracts/c-9594b9.json create mode 100644 .maestro/tasks/contracts/c-960273.json create mode 100644 .maestro/tasks/contracts/c-97a670.json create mode 100644 .maestro/tasks/contracts/c-98539c.json create mode 100644 .maestro/tasks/contracts/c-9b461a.json create mode 100644 .maestro/tasks/contracts/c-9d2ff0.json create mode 100644 .maestro/tasks/contracts/c-9f4505.json create mode 100644 .maestro/tasks/contracts/c-a07b64.json create mode 100644 .maestro/tasks/contracts/c-a3cc1c.json create mode 100644 .maestro/tasks/contracts/c-a51e96.json create mode 100644 .maestro/tasks/contracts/c-abad9b.json create mode 100644 .maestro/tasks/contracts/c-ac1d03.json create mode 100644 .maestro/tasks/contracts/c-b00f37.json create mode 100644 .maestro/tasks/contracts/c-b3042e.json create mode 100644 .maestro/tasks/contracts/c-b3ea78.json create mode 100644 .maestro/tasks/contracts/c-b48bf0.json create mode 100644 .maestro/tasks/contracts/c-b4e562.json create mode 100644 .maestro/tasks/contracts/c-bb0c59.json create mode 100644 .maestro/tasks/contracts/c-bb2dad.json create mode 100644 .maestro/tasks/contracts/c-bb48d3.json create mode 100644 .maestro/tasks/contracts/c-bd9972.json create mode 100644 .maestro/tasks/contracts/c-be3c11.json create mode 100644 .maestro/tasks/contracts/c-bf7e36.json create mode 100644 .maestro/tasks/contracts/c-c1a71d.json create mode 100644 .maestro/tasks/contracts/c-c295a2.json create mode 100644 .maestro/tasks/contracts/c-c296e8.json create mode 100644 .maestro/tasks/contracts/c-c2c7ad.json create mode 100644 .maestro/tasks/contracts/c-c32380.json create mode 100644 .maestro/tasks/contracts/c-c44cae.json create mode 100644 .maestro/tasks/contracts/c-c44ef8.json create mode 100644 .maestro/tasks/contracts/c-c62743.json create mode 100644 .maestro/tasks/contracts/c-c68bf3.json create mode 100644 .maestro/tasks/contracts/c-c9b753.json create mode 100644 .maestro/tasks/contracts/c-c9f395.json create mode 100644 .maestro/tasks/contracts/c-ca3b4a.json create mode 100644 .maestro/tasks/contracts/c-cb766a.json create mode 100644 .maestro/tasks/contracts/c-cd7ec4.json create mode 100644 .maestro/tasks/contracts/c-cf44a1.json create mode 100644 .maestro/tasks/contracts/c-cfe3a5.json create mode 100644 .maestro/tasks/contracts/c-d0ac4c.json create mode 100644 .maestro/tasks/contracts/c-d16fdf.json create mode 100644 .maestro/tasks/contracts/c-d1f9fb.json create mode 100644 .maestro/tasks/contracts/c-d2023b.json create mode 100644 .maestro/tasks/contracts/c-d26a99.json create mode 100644 .maestro/tasks/contracts/c-d54700.json create mode 100644 .maestro/tasks/contracts/c-d71720.json create mode 100644 .maestro/tasks/contracts/c-d73d72.json create mode 100644 .maestro/tasks/contracts/c-d76698.json create mode 100644 .maestro/tasks/contracts/c-d82f5c.json create mode 100644 .maestro/tasks/contracts/c-d90e27.json create mode 100644 .maestro/tasks/contracts/c-d959a2.json create mode 100644 .maestro/tasks/contracts/c-dd89a6.json create mode 100644 .maestro/tasks/contracts/c-de4276.json create mode 100644 .maestro/tasks/contracts/c-de5028.json create mode 100644 .maestro/tasks/contracts/c-df7c71.json create mode 100644 .maestro/tasks/contracts/c-e0acee.json create mode 100644 .maestro/tasks/contracts/c-e1164a.json create mode 100644 .maestro/tasks/contracts/c-e1b585.json create mode 100644 .maestro/tasks/contracts/c-e5a1cd.json create mode 100644 .maestro/tasks/contracts/c-e8bbec.json create mode 100644 .maestro/tasks/contracts/c-e952b5.json create mode 100644 .maestro/tasks/contracts/c-ead934.json create mode 100644 .maestro/tasks/contracts/c-eb0e89.json create mode 100644 .maestro/tasks/contracts/c-ef88f6.json create mode 100644 .maestro/tasks/contracts/c-efb330.json create mode 100644 .maestro/tasks/contracts/c-f1bc8a.json create mode 100644 .maestro/tasks/contracts/c-f2a10d.json create mode 100644 .maestro/tasks/contracts/c-f4b245.json create mode 100644 .maestro/tasks/contracts/c-f6083b.json create mode 100644 .maestro/tasks/contracts/c-f62814.json create mode 100644 .maestro/tasks/contracts/c-f7c029.json create mode 100644 .maestro/tasks/contracts/c-f7e98d.json create mode 100644 .maestro/tasks/contracts/c-f8ad18.json create mode 100644 .maestro/tasks/contracts/c-f91953.json create mode 100644 .maestro/tasks/contracts/c-fafa10.json create mode 100644 .maestro/tasks/contracts/c-fd27d1.json create mode 100644 .maestro/verdicts/tsk-mv9xeqip-pyftp2/vrd-1792095960897-05f747.json create mode 100644 .maestro/verdicts/tsk-mv9zvfav-x5368h/vrd-1792100100814-45442b.json create mode 100644 .maestro/verdicts/tsk-mvaoq06i-s0i7vr/vrd-1792150235104-564363.json create mode 100644 .maestro/verdicts/tsk-mvaoq06i-s0i7vr/vrd-1792150263387-703eaf.json create mode 100644 .maestro/verdicts/tsk-mvaoq06i-s0i7vr/vrd-1792150299594-567e52.json create mode 100644 .maestro/verdicts/tsk-mvaw7lqp-9fes2a/vrd-1792150761543-e282f5.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..55cc3dd08 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.devenv +.direnv +target +target-ci-stable +.cargo-ci-stable +state +tmp +lcov.info +.roam +.serena \ No newline at end of file diff --git a/.maestro/contracts/tsk-mv9xeqip-axc0oj/v1.json b/.maestro/contracts/tsk-mv9xeqip-axc0oj/v1.json index f7afa4c03..10a7e46f2 100644 --- a/.maestro/contracts/tsk-mv9xeqip-axc0oj/v1.json +++ b/.maestro/contracts/tsk-mv9xeqip-axc0oj/v1.json @@ -1,69 +1,67 @@ { - "schemaVersion": 2, - "id": "c-7c3672", - "taskId": "tsk-mv9xeqip-axc0oj", - "repoRoot": ".", - "status": "locked", - "createdAt": "2026-10-15T21:35:19.766Z", - "lockedAt": "2026-10-15T21:35:19.766Z", - "intent": "W1: scorecard.sh --diff/--compare-targets/--report/--html; publish REPORT-0.7.0 and index.html", - "scope": { - "filesExpected": [ - "**/*" - ], - "filesForbidden": [] - }, - "doneWhen": [ - { - "id": "dw-655593", - "text": "docs/adrs/0012-platform-vision.md (status Accepted) supersedes the ADR 0010 vision statement, adds the \"platform\" tier (integration gates + reference-app consumer), and amends Rule 4 so CI-tested reference apps count as consumers; ADR 0010 gets a \"Superseded in part by 0012\" header line; README scope paragraph (lines 3-5) no longer says \"not a batteries-included web framework\".", - "kind": "manual" - }, - { - "id": "dw-f1af8c", - "text": "docs/scorecard/ contains METHOD.md, scorecard.json (moved from .maestro/specs/roadmap/scorecard/), results/v0.6.0.json and results/v0.7.0.json; scripts/scorecard.sh (bash + jq, Requires header, exit 1 FAIL / 2 misuse) implements METHOD §5 and §5.1; `scripts/scorecard.sh --self-test` reproduces every number in METHOD §5.2 exactly (one-decimal string compare).", - "kind": "manual" - }, - { - "id": "dw-b7a521", - "text": "scorecard.sh subcommands work - `--version ` (indices + KPIs), `--targets ` (planned indices), `--diff ` (row deltas, exit 1 on any row ≤ −5 without `adr` note), `--compare-targets ` (exit 1 if E < target − 1.0), `--report ` (writes REPORT-.md), `--html` (writes docs/scorecard/index.html from the JSON).", - "kind": "manual" - }, - { - "id": "dw-d3c065", - "text": "scorecard.json validates against docs/scorecard/scorecard.schema.json (row ids unique, 10 competitor scores, scores in 1..100, every group/track referenced exists); invalid fixtures under docs/scorecard/fixtures/ make the validator exit 1.", - "kind": "manual" - }, - { - "id": "dw-ad478d", - "text": "moon task `scorecard` and a CI step in ci.yml run `scorecard.sh --self-test` and schema validation on every PR.", - "kind": "manual" - }, - { - "id": "dw-2c86cc", - "text": ".maestro/specs/roadmap/PLAN-OF-PLANS.md links to docs/scorecard/ paths (no stale .maestro scorecard paths remain - grep returns 0).", - "kind": "manual" - }, - { - "id": "dw-b7e983", - "text": "Stale task tsk-mv799sew-y9wf9g (W5, migrate downstream to 0.6) is abandoned with reason \"superseded by PLAN-OF-PLANS E0.5 (downstream migration to 0.7+)\"; mission pln-mv799j4f-1r1u6b is closed.", - "kind": "manual" - }, - { - "id": "dw-35ee82", - "text": "Method v2 table-stakes checklists exist for all 82 rows in scorecard.json (a checks list with 4-8 entries each); scorecard.sh --method 2 recomputes 0.6.0 and 0.7.0; REPORT-method-v2.md shows v1 vs v2 side by side for both versions.", - "kind": "manual" - } - ], - "amendments": [], - "createdBy": "claude-opus", - "lockedBy": "claude-opus", - "configSnapshot": { - "strict": false, - "overlapPolicy": "annotate", - "rebaseFallback": "best-effort", - "staleReclaimContractPolicy": "inherit" - }, - "missionId": "pln-mv9xenpd-d2lpkn", - "riskClass": "high" + "schemaVersion": 2, + "id": "c-7c3672", + "taskId": "tsk-mv9xeqip-axc0oj", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-15T21:35:19.766Z", + "lockedAt": "2026-10-15T21:35:19.766Z", + "intent": "W1: scorecard.sh --diff/--compare-targets/--report/--html; publish REPORT-0.7.0 and index.html", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-655593", + "text": "docs/adrs/0012-platform-vision.md (status Accepted) supersedes the ADR 0010 vision statement, adds the \"platform\" tier (integration gates + reference-app consumer), and amends Rule 4 so CI-tested reference apps count as consumers; ADR 0010 gets a \"Superseded in part by 0012\" header line; README scope paragraph (lines 3-5) no longer says \"not a batteries-included web framework\".", + "kind": "manual" + }, + { + "id": "dw-f1af8c", + "text": "docs/scorecard/ contains METHOD.md, scorecard.json (moved from .maestro/specs/roadmap/scorecard/), results/v0.6.0.json and results/v0.7.0.json; scripts/scorecard.sh (bash + jq, Requires header, exit 1 FAIL / 2 misuse) implements METHOD §5 and §5.1; `scripts/scorecard.sh --self-test` reproduces every number in METHOD §5.2 exactly (one-decimal string compare).", + "kind": "manual" + }, + { + "id": "dw-b7a521", + "text": "scorecard.sh subcommands work - `--version ` (indices + KPIs), `--targets ` (planned indices), `--diff ` (row deltas, exit 1 on any row ≤ −5 without `adr` note), `--compare-targets ` (exit 1 if E < target − 1.0), `--report ` (writes REPORT-.md), `--html` (writes docs/scorecard/index.html from the JSON).", + "kind": "manual" + }, + { + "id": "dw-d3c065", + "text": "scorecard.json validates against docs/scorecard/scorecard.schema.json (row ids unique, 10 competitor scores, scores in 1..100, every group/track referenced exists); invalid fixtures under docs/scorecard/fixtures/ make the validator exit 1.", + "kind": "manual" + }, + { + "id": "dw-ad478d", + "text": "moon task `scorecard` and a CI step in ci.yml run `scorecard.sh --self-test` and schema validation on every PR.", + "kind": "manual" + }, + { + "id": "dw-2c86cc", + "text": ".maestro/specs/roadmap/PLAN-OF-PLANS.md links to docs/scorecard/ paths (no stale .maestro scorecard paths remain - grep returns 0).", + "kind": "manual" + }, + { + "id": "dw-b7e983", + "text": "Stale task tsk-mv799sew-y9wf9g (W5, migrate downstream to 0.6) is abandoned with reason \"superseded by PLAN-OF-PLANS E0.5 (downstream migration to 0.7+)\"; mission pln-mv799j4f-1r1u6b is closed.", + "kind": "manual" + }, + { + "id": "dw-35ee82", + "text": "Method v2 table-stakes checklists exist for all 82 rows in scorecard.json (a checks list with 4-8 entries each); scorecard.sh --method 2 recomputes 0.6.0 and 0.7.0; REPORT-method-v2.md shows v1 vs v2 side by side for both versions.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "claude-opus", + "lockedBy": "claude-opus", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mv9xenpd-d2lpkn", + "riskClass": "high" } diff --git a/.maestro/contracts/tsk-mv9xeqip-pixe4f/v1.json b/.maestro/contracts/tsk-mv9xeqip-pixe4f/v1.json index 7a9404bcd..4188c4ad2 100644 --- a/.maestro/contracts/tsk-mv9xeqip-pixe4f/v1.json +++ b/.maestro/contracts/tsk-mv9xeqip-pixe4f/v1.json @@ -1,69 +1,67 @@ { - "schemaVersion": 2, - "id": "c-521413", - "taskId": "tsk-mv9xeqip-pixe4f", - "repoRoot": ".", - "status": "locked", - "createdAt": "2026-10-15T21:35:19.936Z", - "lockedAt": "2026-10-15T21:35:19.936Z", - "intent": "W1: Method v2 table-stakes checklists for all 82 rows; --method 2; v1 vs v2 report", - "scope": { - "filesExpected": [ - "**/*" - ], - "filesForbidden": [] - }, - "doneWhen": [ - { - "id": "dw-3229f5", - "text": "docs/adrs/0012-platform-vision.md (status Accepted) supersedes the ADR 0010 vision statement, adds the \"platform\" tier (integration gates + reference-app consumer), and amends Rule 4 so CI-tested reference apps count as consumers; ADR 0010 gets a \"Superseded in part by 0012\" header line; README scope paragraph (lines 3-5) no longer says \"not a batteries-included web framework\".", - "kind": "manual" - }, - { - "id": "dw-0c1f44", - "text": "docs/scorecard/ contains METHOD.md, scorecard.json (moved from .maestro/specs/roadmap/scorecard/), results/v0.6.0.json and results/v0.7.0.json; scripts/scorecard.sh (bash + jq, Requires header, exit 1 FAIL / 2 misuse) implements METHOD §5 and §5.1; `scripts/scorecard.sh --self-test` reproduces every number in METHOD §5.2 exactly (one-decimal string compare).", - "kind": "manual" - }, - { - "id": "dw-bd8cf8", - "text": "scorecard.sh subcommands work - `--version ` (indices + KPIs), `--targets ` (planned indices), `--diff ` (row deltas, exit 1 on any row ≤ −5 without `adr` note), `--compare-targets ` (exit 1 if E < target − 1.0), `--report ` (writes REPORT-.md), `--html` (writes docs/scorecard/index.html from the JSON).", - "kind": "manual" - }, - { - "id": "dw-75ab58", - "text": "scorecard.json validates against docs/scorecard/scorecard.schema.json (row ids unique, 10 competitor scores, scores in 1..100, every group/track referenced exists); invalid fixtures under docs/scorecard/fixtures/ make the validator exit 1.", - "kind": "manual" - }, - { - "id": "dw-11bc67", - "text": "moon task `scorecard` and a CI step in ci.yml run `scorecard.sh --self-test` and schema validation on every PR.", - "kind": "manual" - }, - { - "id": "dw-70419c", - "text": ".maestro/specs/roadmap/PLAN-OF-PLANS.md links to docs/scorecard/ paths (no stale .maestro scorecard paths remain - grep returns 0).", - "kind": "manual" - }, - { - "id": "dw-fdf6b1", - "text": "Stale task tsk-mv799sew-y9wf9g (W5, migrate downstream to 0.6) is abandoned with reason \"superseded by PLAN-OF-PLANS E0.5 (downstream migration to 0.7+)\"; mission pln-mv799j4f-1r1u6b is closed.", - "kind": "manual" - }, - { - "id": "dw-1de7f0", - "text": "Method v2 table-stakes checklists exist for all 82 rows in scorecard.json (a checks list with 4-8 entries each); scorecard.sh --method 2 recomputes 0.6.0 and 0.7.0; REPORT-method-v2.md shows v1 vs v2 side by side for both versions.", - "kind": "manual" - } - ], - "amendments": [], - "createdBy": "claude-opus", - "lockedBy": "claude-opus", - "configSnapshot": { - "strict": false, - "overlapPolicy": "annotate", - "rebaseFallback": "best-effort", - "staleReclaimContractPolicy": "inherit" - }, - "missionId": "pln-mv9xenpd-d2lpkn", - "riskClass": "high" + "schemaVersion": 2, + "id": "c-521413", + "taskId": "tsk-mv9xeqip-pixe4f", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-15T21:35:19.936Z", + "lockedAt": "2026-10-15T21:35:19.936Z", + "intent": "W1: Method v2 table-stakes checklists for all 82 rows; --method 2; v1 vs v2 report", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3229f5", + "text": "docs/adrs/0012-platform-vision.md (status Accepted) supersedes the ADR 0010 vision statement, adds the \"platform\" tier (integration gates + reference-app consumer), and amends Rule 4 so CI-tested reference apps count as consumers; ADR 0010 gets a \"Superseded in part by 0012\" header line; README scope paragraph (lines 3-5) no longer says \"not a batteries-included web framework\".", + "kind": "manual" + }, + { + "id": "dw-0c1f44", + "text": "docs/scorecard/ contains METHOD.md, scorecard.json (moved from .maestro/specs/roadmap/scorecard/), results/v0.6.0.json and results/v0.7.0.json; scripts/scorecard.sh (bash + jq, Requires header, exit 1 FAIL / 2 misuse) implements METHOD §5 and §5.1; `scripts/scorecard.sh --self-test` reproduces every number in METHOD §5.2 exactly (one-decimal string compare).", + "kind": "manual" + }, + { + "id": "dw-bd8cf8", + "text": "scorecard.sh subcommands work - `--version ` (indices + KPIs), `--targets ` (planned indices), `--diff ` (row deltas, exit 1 on any row ≤ −5 without `adr` note), `--compare-targets ` (exit 1 if E < target − 1.0), `--report ` (writes REPORT-.md), `--html` (writes docs/scorecard/index.html from the JSON).", + "kind": "manual" + }, + { + "id": "dw-75ab58", + "text": "scorecard.json validates against docs/scorecard/scorecard.schema.json (row ids unique, 10 competitor scores, scores in 1..100, every group/track referenced exists); invalid fixtures under docs/scorecard/fixtures/ make the validator exit 1.", + "kind": "manual" + }, + { + "id": "dw-11bc67", + "text": "moon task `scorecard` and a CI step in ci.yml run `scorecard.sh --self-test` and schema validation on every PR.", + "kind": "manual" + }, + { + "id": "dw-70419c", + "text": ".maestro/specs/roadmap/PLAN-OF-PLANS.md links to docs/scorecard/ paths (no stale .maestro scorecard paths remain - grep returns 0).", + "kind": "manual" + }, + { + "id": "dw-fdf6b1", + "text": "Stale task tsk-mv799sew-y9wf9g (W5, migrate downstream to 0.6) is abandoned with reason \"superseded by PLAN-OF-PLANS E0.5 (downstream migration to 0.7+)\"; mission pln-mv799j4f-1r1u6b is closed.", + "kind": "manual" + }, + { + "id": "dw-1de7f0", + "text": "Method v2 table-stakes checklists exist for all 82 rows in scorecard.json (a checks list with 4-8 entries each); scorecard.sh --method 2 recomputes 0.6.0 and 0.7.0; REPORT-method-v2.md shows v1 vs v2 side by side for both versions.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "claude-opus", + "lockedBy": "claude-opus", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mv9xenpd-d2lpkn", + "riskClass": "high" } diff --git a/.maestro/contracts/tsk-mva026ub-1u86pn/v1.json b/.maestro/contracts/tsk-mva026ub-1u86pn/v1.json new file mode 100644 index 000000000..bfbfa7184 --- /dev/null +++ b/.maestro/contracts/tsk-mva026ub-1u86pn/v1.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": 2, + "id": "c-13f98e", + "taskId": "tsk-mva026ub-1u86pn", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-15T20:37:09.561Z", + "lockedAt": "2026-10-15T20:37:09.561Z", + "intent": "http-showcase DDD rewrite", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-21218a", + "text": "src/ is layer-first: domain/, application/, infrastructure/, interface/http/, bootstrap/; lib.rs and every mod.rs only declare modules and re-export.", + "kind": "manual" + }, + { + "id": "dw-b7a162", + "text": "One construct per file: every struct, enum, trait, type alias, free function (use case, handler, layer constructor) has its own file; a type's inherent impls live with it; unit tests stay in a #[cfg(test)] block of the file they test; test doubles live in infrastructure/doubles/ under cfg(test).", + "kind": "manual" + }, + { + "id": "dw-0b9246", + "text": "Domain: TodoId and Title are brands/value objects; Todo holds a Title (not String) and owns its behaviour (rename, complete, reopen, apply(TodoChanges)); domain types derive EffectData where they qualify; no axum/sql types in domain/.", + "kind": "manual" + }, + { + "id": "dw-40f8db", + "text": "Errors are typed per use case (Or<..> of domain errors and the store error); HTTP status mapping lives only in interface/http; SqlError mapping lives only in infrastructure/postgres.", + "kind": "manual" + }, + { + "id": "dw-f02a1f", + "text": "Request bodies decode through #[derive(Schema)], reporting every bad field in one 422; the wire format and every status code of the README tables are unchanged.", + "kind": "manual" + }, + { + "id": "dw-2d97b1", + "text": "Store selection is layer composition (in-memory vs scoped Postgres provider that migrates on acquire), not a hand-written match; id_effect_resilience CircuitBreaker wraps store calls alongside the Schedule retry + timeout; id_effect_logger emits a structured startup line (bind address, store kind).", + "kind": "manual" + }, + { + "id": "dw-8947b4", + "text": "Public API kept as re-exports used by tests/http_pg.rs and the README, or the callers updated in the same change.", + "kind": "manual" + }, + { + "id": "dw-278f98", + "text": "Gates green: cargo clippy -p http_showcase --all-targets --all-features -- -D warnings; #![deny(missing_docs)]; cargo nextest run -p http_showcase with DATABASE_URL against devenv Postgres and ID_EFFECT_REQUIRE_PG=1; cargo test --doc -p http_showcase.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvaoq06i-s0i7vr/v1.json b/.maestro/contracts/tsk-mvaoq06i-s0i7vr/v1.json new file mode 100644 index 000000000..b3dbf2225 --- /dev/null +++ b/.maestro/contracts/tsk-mvaoq06i-s0i7vr/v1.json @@ -0,0 +1,56 @@ +{ + "schemaVersion": 2, + "id": "c-53f8c8", + "taskId": "tsk-mvaoq06i-s0i7vr", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T08:07:30.864Z", + "lockedAt": "2026-10-16T08:07:30.864Z", + "intent": "Scorecard method re-base to 2.0", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f3e7b8", + "text": "Step 1 (alignment): every competitor v2 level/met cell is re-judged with one rule: a check met only through a third-party library counts at production level only if the framework's own docs recommend that library, otherwise the row is scored at labs (or without that check, whichever scores higher). The per-check classification is committed as docs/scorecard/alignment-2.0.json.", + "kind": "manual" + }, + { + "id": "dw-77c1a9", + "text": "Step 2 (method 1.1): every id_effect v1 score above the cap of its v2 level is lowered to that cap; the v1 numbers are kept per row under rows[].v1 {competitors, id_effect, targets} and remain reproducible with scripts/scorecard.sh --method 1.", + "kind": "manual" + }, + { + "id": "dw-a364a5", + "text": "Step 3 (method 2.0): method_version is \"2.0\"; rows[].competitors and rows[].id_effect hold the checklist scores and --validate fails when a stored score differs from its checklist score; rows[].targets are re-based to the v2 scale by one documented rule; --method 2 is the default.", + "kind": "manual" + }, + { + "id": "dw-a0ae06", + "text": "METHOD.md §5.2 and §7, PLAN-OF-PLANS §1 and §6, results/v0.6.0.json, results/v0.7.0.json, REPORT-0.7.0.md, REPORT-method-v2.md and index.html are regenerated or re-stated with 2.0 numbers; the v1 numbers are kept in a history table.", + "kind": "manual" + }, + { + "id": "dw-6e75f3", + "text": "bash scripts/scorecard.sh --validate --self-test passes, including a fixture that fails on a stored score that differs from its checklist score; the CI scorecard step passes unchanged or is updated in the same change.", + "kind": "manual" + }, + { + "id": "dw-a593ac", + "text": "Step 4: the owner accepts the re-based numbers before 0.8 scope selection (§7.1.3) uses them.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvaw7lqp-9fes2a/v1.json b/.maestro/contracts/tsk-mvaw7lqp-9fes2a/v1.json new file mode 100644 index 000000000..cbfef53e3 --- /dev/null +++ b/.maestro/contracts/tsk-mvaw7lqp-9fes2a/v1.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-2c1844", + "taskId": "tsk-mvaw7lqp-9fes2a", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T11:37:09.169Z", + "lockedAt": "2026-10-16T11:37:09.169Z", + "intent": "Scorecard method 2.1", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-59b3b7", + "text": "Rows sec.supply, test.qa, eco.stability, eco.backing, eco.community, eco.production and eco.license carry \"process\": true; on them every framework level is production when it meets at least one check and absent otherwise; --validate fails otherwise (fixture bad-process-level).", + "kind": "manual" + }, + { + "id": "dw-5e239f", + "text": "method_version is \"2.1\"; METHOD §7.4 states the rule, §5.2 and §8 are updated; self-test passes with the new numbers.", + "kind": "manual" + }, + { + "id": "dw-965cf6", + "text": "data.jsonschema 0.8.0 target is 50 (owner decision D1, 2026-10-16); PLAN-OF-PLANS §1/§6 and the 0.8 release spec carry the recomputed targets.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvawkapy-fwcf2f/v1.json b/.maestro/contracts/tsk-mvawkapy-fwcf2f/v1.json new file mode 100644 index 000000000..362557e5b --- /dev/null +++ b/.maestro/contracts/tsk-mvawkapy-fwcf2f/v1.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 2, + "id": "c-cb766a", + "taskId": "tsk-mvawkapy-fwcf2f", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T11:47:23.670Z", + "lockedAt": "2026-10-16T11:47:23.670Z", + "intent": "0.8 E2.1h hygiene", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-0585dc", + "text": "FoldChannel::from_sink (streaming/channel.rs:306) and Sink::zip (streaming/sink.rs:121-126) no longer panic: a FoldSink newtype (or Result-returning API) makes a non-fold sink a compile error or a typed error; the two should_panic tests in tests/stream_channel.rs are replaced; rg \"\\.expect\\(\" crates/id_effect/src/streaming returns nothing; MIGRATION-0.8 entry.", + "kind": "manual" + }, + { + "id": "dw-abdcf7", + "text": "Queue::offer/take (coordination/queue.rs:239-313) take a synchronous fast path when there is room/an item and fall back to the async path only when full/empty; take avoids the Vec allocation.", + "kind": "manual" + }, + { + "id": "dw-167af3", + "text": "retry_loop (scheduling/schedule.rs:501-525) no longer forces a fiber per run for count-only schedules (no clock read unless the schedule needs time), or the profile shows another dominant cost and that is fixed.", + "kind": "manual" + }, + { + "id": "dw-762e56", + "text": "bench-compare against docs/benchmarks/v0.7.json: coord/queue_bounded_10k and coord/channel_spsc_10k <= 1.25x v0.6, phase0/bench_schedule_retry_overhead <= 2x v0.6; the three ids added as budget gates in docs/benchmarks/gates.toml; bench-compare.sh --self-test passes; findings in docs/benchmarks/REPORT-0.8.md (draft section).", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvawbp5i-g5m6ot", + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvawkapy-h9kogq/v1.json b/.maestro/contracts/tsk-mvawkapy-h9kogq/v1.json new file mode 100644 index 000000000..66db0a2b2 --- /dev/null +++ b/.maestro/contracts/tsk-mvawkapy-h9kogq/v1.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 2, + "id": "c-94ed0c", + "taskId": "tsk-mvawkapy-h9kogq", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T11:47:22.680Z", + "lockedAt": "2026-10-16T11:47:22.680Z", + "intent": "0.8 E0.3 semver-checks and stability policy", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-d0e894", + "text": "A semver job in .github/workflows/ci.yml runs cargo semver-checks for the published crates against the 0.7.0 registry baseline; intentional 0.8 breaks are allowed only when listed in docs/MIGRATION-0.8.md (the job compares, or is report-only until the 0.8 tag and enforcing after it — documented in the job); job listed in ci-pass.needs; moon task semver; cargo-semver-checks in devenv.nix.", + "kind": "manual" + }, + { + "id": "dw-3e136f", + "text": "docs/STABILITY.md (linked from README and QUALITY_BAR): deprecation rule (one-minor #[deprecated] shim + migration entry), semver policy before/after 1.0, MSRV policy stated as \"at most N-2 stable\" consistent with rust-version = 1.94 and the msrv CI job.", + "kind": "manual" + }, + { + "id": "dw-3b262a", + "text": "docs/MIGRATION-0.8.md created (header + sections that wave leaves append to).", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvawbp5i-g5m6ot", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvawkapy-lzvg52/v1.json b/.maestro/contracts/tsk-mvawkapy-lzvg52/v1.json new file mode 100644 index 000000000..d82780fd6 --- /dev/null +++ b/.maestro/contracts/tsk-mvawkapy-lzvg52/v1.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 2, + "id": "c-46ad51", + "taskId": "tsk-mvawkapy-lzvg52", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T18:57:30.578Z", + "lockedAt": "2026-10-16T18:57:30.578Z", + "intent": "0.8 E0.1 relicense to MIT", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-62e32f", + "text": "Precondition recorded as evidence before any change: consent from every non-owner copyright holder (Justin Carter, commit 8eb9b05 / #79) or those lines rewritten; owner confirms IDClear has no claim on the 4 commits made with the idclear.com address (#67, #71, #72, #73).", + "kind": "manual" + }, + { + "id": "dw-dd6be3", + "text": "LICENSE at repo root with the MIT text (head -1 = MIT License); license = \"MIT\" in Cargo.toml:32 [workspace.package], labs/Cargo.toml:23 and labs/id_effect_lint/Cargo.toml:7; cargo metadata shows license MIT for every publishable package in both workspaces.", + "kind": "manual" + }, + { + "id": "dw-935102", + "text": "README badge (README.md:19) and License footer (README.md:244-246) and labs/id_effect_lint/README.md:31-33 updated; git grep CC-BY-SA outside docs/scorecard, .maestro and CHANGELOG returns nothing; CHANGELOG notes that versions <= 0.7.0 remain CC-BY-SA-4.0.", + "kind": "manual" + }, + { + "id": "dw-64c6cc", + "text": "CONTRIBUTING.md exists with a DCO (Signed-off-by) requirement; cargo package -p id_effect --list succeeds.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvawbp5i-g5m6ot", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvawkapy-nam47g/v1.json b/.maestro/contracts/tsk-mvawkapy-nam47g/v1.json new file mode 100644 index 000000000..a89e77d0e --- /dev/null +++ b/.maestro/contracts/tsk-mvawkapy-nam47g/v1.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 2, + "id": "c-8d39d3", + "taskId": "tsk-mvawkapy-nam47g", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T11:47:22.464Z", + "lockedAt": "2026-10-16T11:47:22.464Z", + "intent": "0.8 E0.2 security process", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-4a3a66", + "text": "SECURITY.md: private reporting via GitHub security advisories, response times, supported-versions table and support window, advisory (GHSA/RUSTSEC) publication process.", + "kind": "manual" + }, + { + "id": "dw-21e257", + "text": "deny.toml (licenses allow-list derived from cargo deny check licenses on the main workspace, bans, sources; carries the RUSTSEC-2023-0071 ignore from .cargo/audit.toml); cargo deny check exits 0; a deny job in .github/workflows/ci.yml listed in ci-pass.needs; moon task deny; cargo-deny in devenv.nix.", + "kind": "manual" + }, + { + "id": "dw-186596", + "text": "publish.yml: a CycloneDX SBOM (cargo cyclonedx) per release attached to the GitHub release, and actions/attest-build-provenance over the packaged .crate files and SBOMs with permissions id-token: write, attestations: write scoped to that job; cargo-cyclonedx in devenv.nix; actionlint clean on changed workflows.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvawbp5i-g5m6ot", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvawkapy-xdespa/v1.json b/.maestro/contracts/tsk-mvawkapy-xdespa/v1.json new file mode 100644 index 000000000..26df34b3e --- /dev/null +++ b/.maestro/contracts/tsk-mvawkapy-xdespa/v1.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 2, + "id": "c-df7c71", + "taskId": "tsk-mvawkapy-xdespa", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T11:47:22.822Z", + "lockedAt": "2026-10-16T11:47:22.822Z", + "intent": "0.8 E0.4 governance", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3c4753", + "text": "GOVERNANCE.md: current maintainers (truthfully: one), decision process, ADR process, path to co-maintainer with merge rights, how releases are cut against PLAN-OF-PLANS; .github/CODEOWNERS.", + "kind": "manual" + }, + { + "id": "dw-7878a1", + "text": ".github/FUNDING.yml only if the owner confirms an existing GitHub Sponsors or OpenCollective account (otherwise omitted and noted).", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvawbp5i-g5m6ot", + "riskClass": "low" +} diff --git a/.maestro/contracts/tsk-mvb0jm49-3ytgwg/v1.json b/.maestro/contracts/tsk-mvb0jm49-3ytgwg/v1.json new file mode 100644 index 000000000..6ddcb611b --- /dev/null +++ b/.maestro/contracts/tsk-mvb0jm49-3ytgwg/v1.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-322722", + "taskId": "tsk-mvb0jm49-3ytgwg", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T13:38:34.732Z", + "lockedAt": "2026-10-16T13:38:34.732Z", + "intent": "0.8 E2.1c+r — cron and resilience", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-351d3d", + "text": "msg.cron 2/8 -> 6/8: cron-expression schedules (#1) with timezone- and DST-aware next-fire computation (#3, via the existing jiff-based scheduling/datetime.rs), overlap/missed-run policy skip|catch-up (#5), run monitoring hooks: success/failure callbacks and run history (#7), declarative central registration of named cron jobs (#8); built on Schedule/fibers (effect-native), tested with TestExecutor virtual time including a DST transition and a missed-run case.", + "kind": "manual" + }, + { + "id": "dw-29cfb2", + "text": "fault.resilience 6/8 level tested -> 7/8 level documented: fallback policy (#7) in id_effect_resilience, plus #8 if cheap (policies compose into one reusable pipeline with telemetry); book chapter section and a runnable example; integration test.", + "kind": "manual" + }, + { + "id": "dw-d41ec8", + "text": "Book: cron chapter or section and resilience section; examples runnable; clippy -D warnings, docs, tests green for touched crates.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvb0jm8t-w9qmja/v1.json b/.maestro/contracts/tsk-mvb0jm8t-w9qmja/v1.json new file mode 100644 index 000000000..9adee165c --- /dev/null +++ b/.maestro/contracts/tsk-mvb0jm8t-w9qmja/v1.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 2, + "id": "c-d73d72", + "taskId": "tsk-mvb0jm8t-w9qmja", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T13:38:34.862Z", + "lockedAt": "2026-10-16T13:38:34.862Z", + "intent": "0.8 E2.1s — stream operators", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-cbadf2", + "text": "msg.streams 5/8 -> at least 7/8: zip / zip_with / combine_latest (#4), time-based throttle and debounce (#5) on the fiber clock (TestExecutor virtual-time tests), group_by key into substreams/partitions (#6) with bounded buffering and backpressure; all interruption-safe (finalizers run when the consumer stops early).", + "kind": "manual" + }, + { + "id": "dw-5f2bb2", + "text": "Book streams chapter updated with each operator; a runnable example; clippy -D warnings, docs, tests green for id_effect.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvb0jmcb-ajdki6/v1.json b/.maestro/contracts/tsk-mvb0jmcb-ajdki6/v1.json new file mode 100644 index 000000000..87e855969 --- /dev/null +++ b/.maestro/contracts/tsk-mvb0jmcb-ajdki6/v1.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-2a2a87", + "taskId": "tsk-mvb0jmcb-ajdki6", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T13:38:34.996Z", + "lockedAt": "2026-10-16T13:38:34.996Z", + "intent": "0.8 E2.3 — schema", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-4f6cc4", + "text": "data.jsonschema 0/8 -> 4/8, level tested: JSON Schema (2020-12) export for any Schema/HasSchema type (#1; first establish what crates/id_effect/src/schema/json_schema_export.rs already does and why the 0.7 scorecard did not credit it), the same schema value used for validation and export (#4), descriptions/examples/deprecation annotations emitted (#5), constraints reflected (minLength, pattern, enum, ranges) (#6); golden tests per schema kind; validated against a JSON Schema validator crate in dev-dependencies if one is already in the tree, else golden only.", + "kind": "manual" + }, + { + "id": "dw-9a2f2f", + "text": "data.schema 6/8 -> 7/8: struct/record decoding accumulates all field errors in one pass instead of fail-fast (#6, parse.rs ~:500), or built-in validators email/uuid/url/length/range/regex (#8) — do #6, add #8 if cheap; existing parse tests keep passing; MIGRATION-0.8 text if error shapes change.", + "kind": "manual" + }, + { + "id": "dw-319293", + "text": "Book schema chapter updated; clippy -D warnings, docs, tests green for id_effect.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvb0jmfu-jljtq9/v1.json b/.maestro/contracts/tsk-mvb0jmfu-jljtq9/v1.json new file mode 100644 index 000000000..8b8759ed8 --- /dev/null +++ b/.maestro/contracts/tsk-mvb0jmfu-jljtq9/v1.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-ac1d03", + "taskId": "tsk-mvb0jmfu-jljtq9", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T13:38:35.123Z", + "lockedAt": "2026-10-16T13:38:35.123Z", + "intent": "0.8 E2.2 — property testing and test helpers", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-957f29", + "text": "test.property absent 4/8 -> documented 7/8: a feature-gated id_effect::testing property module (feature proptest, on the proptest crate) with generators derived from Schema/HasSchema (#2), effect-aware properties that run framework effects under TestExecutor (#4), stateful/model-based testing (#5), shipped generators for framework domain types (Duration, Chunk, Exit/Cause, HList caps where sensible) (#8); keeps shrinking/replay from proptest; book chapter, runnable example, integration test.", + "kind": "manual" + }, + { + "id": "dw-9b0b7b", + "text": "test.unit 5/8 -> 7/8: first-party assertions for Exit/Cause (#3; the removed 0.6 assert_exit_* in a 0.8 form) and snapshot/golden testing support (#5; e.g. on insta behind a feature, or a small first-party golden helper).", + "kind": "manual" + }, + { + "id": "dw-212d2d", + "text": "MIGRATION-0.8/CHANGELOG text reported (feature returns); clippy -D warnings, docs, tests green for id_effect with and without the feature.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvb0jmj8-is6qdv/v1.json b/.maestro/contracts/tsk-mvb0jmj8-is6qdv/v1.json new file mode 100644 index 000000000..09ca1b399 --- /dev/null +++ b/.maestro/contracts/tsk-mvb0jmj8-is6qdv/v1.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-f91953", + "taskId": "tsk-mvb0jmj8-is6qdv", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T13:38:35.251Z", + "lockedAt": "2026-10-16T13:38:35.251Z", + "intent": "0.8 E2.4 + E1.8", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-368280", + "text": "test.determinism 6/8 -> 7/8: seeded exploration of task interleavings in TestExecutor (#7): a seed selects the order in which ready fibers run, a helper runs a test under N seeds and reports the failing seed, and re-running with that seed reproduces it; a test that finds a planted race; book section.", + "kind": "manual" + }, + { + "id": "dw-51d2ba", + "text": "core.ergonomics 5/8 -> 6/8: official idiom/style guide for application code (#8) in the book (when to use effect!, Layer vs provide, error modelling, retry factories, resource scopes, testing), every snippet compiled (mdbook test or doctest harness used by the book).", + "kind": "manual" + }, + { + "id": "dw-0585d7", + "text": "clippy -D warnings, docs, tests green for id_effect.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvb3399t-pysl4g/v1.json b/.maestro/contracts/tsk-mvb3399t-pysl4g/v1.json new file mode 100644 index 000000000..ebcf26b90 --- /dev/null +++ b/.maestro/contracts/tsk-mvb3399t-pysl4g/v1.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-6bfbf2", + "taskId": "tsk-mvb3399t-pysl4g", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T14:49:43.615Z", + "lockedAt": "2026-10-16T14:49:43.615Z", + "intent": "0.8 E2.3b — schema follow-ups", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-556071", + "text": "#[derive(Schema)] (crates/id_effect_proc_macro/src/derive_schema_parser.rs, built through Schema::make) attaches a SchemaAst via .with_ast(..) built from each field's HasSchema::schema().ast() (struct with field names, optional/nullable fields, enums/unions where the derive supports them), so json_schema_for::() is no longer {}; golden tests for a derived struct, a nested derived struct and an enum if supported; existing derive tests unchanged.", + "kind": "manual" + }, + { + "id": "dw-f0c6ca", + "text": "Real regular expressions for data.schema #8: a regex-backed validator (e.g. schema::validators::regex(pattern) -> Result<..>) using the regex crate as an optional or default dependency of id_effect (decide and justify; tier-conformance PASS), exporting the same pattern into JSON Schema; the existing caller-supplied-matcher pattern() stays.", + "kind": "manual" + }, + { + "id": "dw-676402", + "text": "Book schema chapter updated; cargo test/clippy -D warnings/doc for id_effect and id_effect_proc_macro green with default and all features.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvb8iicl-xlm756/v1.json b/.maestro/contracts/tsk-mvb8iicl-xlm756/v1.json new file mode 100644 index 000000000..2af26692f --- /dev/null +++ b/.maestro/contracts/tsk-mvb8iicl-xlm756/v1.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 2, + "id": "c-c2c7ad", + "taskId": "tsk-mvb8iicl-xlm756", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T17:21:36.129Z", + "lockedAt": "2026-10-16T17:21:36.129Z", + "intent": "0.8 W3-A — id_effect_kit", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-6efdde", + "text": "docs/adrs/0010-crate-tiers.md Amendment 4 names id_effect_kit (integration tier; moves to platform once examples/reference exists) and id_effect_tool (integration tier, publishes the id-effect binary), per ADR 0010 Rule 5 and ADR 0012.", + "kind": "manual" + }, + { + "id": "dw-d9f510", + "text": "crates/id_effect_kit: version.workspace, readme, tier = \"integration\", #![deny(missing_docs)], forbid(unsafe_code); features web (id_effect_axum), sql-pg (id_effect_sql/pg), otel (id_effect_opentelemetry), cluster (id_effect_node), full; a prelude (id_effect core items used across examples, plus per-feature items: EffectExecutor, execute, serve_until_shutdown, SecurityLayer, health/readiness routers, metrics_router, SqlClientService, SqlError, with_transaction, OtelConfig, install_from_config, run_main, RunMainConfig) and whole-crate re-exports (kit::axum, kit::sql, ...); examples/010_prelude.rs runs; README with a rust block; tests satisfy tier-conformance density.", + "kind": "manual" + }, + { + "id": "dw-000ab0", + "text": "Registration: root Cargo.toml members, .moon/workspace.yml + crate moon.yml, scripts/validate-crate-packaging.sh LEVEL/ORDER for id_effect_kit (last level, after all its deps) and a reserved slot for id_effect_tool after it; the ci.yml semver job excludes both new crates until they have a published baseline.", + "kind": "manual" + }, + { + "id": "dw-671675", + "text": "bash scripts/tier-conformance.sh id_effect_kit PASS; bash scripts/validate-crate-packaging.sh PASS and --print-order lists id_effect_kit last (id_effect_tool added by W3-B); cargo hack-style check of each feature alone and full (cargo check -p id_effect_kit --no-default-features --features X for X in web, sql-pg, otel, cluster, full); clippy -D warnings, rustdoc -D warnings.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvb8iifv-1kp3tn/v1.json b/.maestro/contracts/tsk-mvb8iifv-1kp3tn/v1.json new file mode 100644 index 000000000..15b24c275 --- /dev/null +++ b/.maestro/contracts/tsk-mvb8iifv-1kp3tn/v1.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 2, + "id": "c-8be221", + "taskId": "tsk-mvb8iifv-1kp3tn", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T17:21:36.254Z", + "lockedAt": "2026-10-16T17:21:36.254Z", + "intent": "0.8 W3-D — diagnostics gallery", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-35428f", + "text": "crates/id_effect/tests/ui/gallery/: the 20 most likely mistakes when writing id_effect application code (missing capability, wrong provide, non-Send body, generic runner unannotated, missing retry factory, Layer output missing, effect! misuse, Never/Or error mismatches, block_on in async, etc.), each a trybuild fail case with a blessed .stderr on the pinned rustc (ui.rs BLESSED_RUSTC), registered in tests/ui.rs; the existing 9 fail + 4 pass cases still pass.", + "kind": "manual" + }, + { + "id": "dw-a77d5b", + "text": "K4 rule (owner decision 2026-10-16): a case counts when its first error carries a note (ours via #[diagnostic::on_unimplemented] note=, or rustc's help) that states the concrete fix. Sweep the 7 existing on_unimplemented attributes (capability/hlist.rs, kernel/bind.rs, kernel/macro_support.rs, layer.rs) and proc-macro error texts so every gallery case meets the rule; all 20 meet it.", + "kind": "manual" + }, + { + "id": "dw-bf4c1e", + "text": "docs/diagnostics.md (or a book appendix page; report the SUMMARY line) indexes each case: the mistake, the error, the fix.", + "kind": "manual" + }, + { + "id": "dw-c31417", + "text": "cargo test -p id_effect --test ui on the pinned rustc green; clippy -D warnings and docs green.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvb8iij2-kzsw5d/v1.json b/.maestro/contracts/tsk-mvb8iij2-kzsw5d/v1.json new file mode 100644 index 000000000..9a97ba231 --- /dev/null +++ b/.maestro/contracts/tsk-mvb8iij2-kzsw5d/v1.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-12b6e9", + "taskId": "tsk-mvb8iij2-kzsw5d", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T17:21:36.375Z", + "lockedAt": "2026-10-16T17:21:36.375Z", + "intent": "0.8 W3-E — book snippets", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-4ebdfa", + "text": "Every chapter file under crates/id_effect/book/src that contains a rust fence is included in the examples/book-snippets harness (commit e7e7ac1: one module per part; #[cfg(doctest)] #[doc = include_str!(..)] struct per chapter), except the two already compiled by crates/id_effect/src/lib.rs (appendix-e-idiom-guide.md, part4/ch15-05-interleavings.md).", + "kind": "manual" + }, + { + "id": "dw-a36036", + "text": "At least 80 % of all rust fences in the book compile and run (or are no_run) via cargo test --doc -p book_snippets; snippets that describe code that no longer exists are corrected to the 0.8 API (the book must describe what the code does today); a snippet left rust,ignore has a one-line HTML comment reason directly above it.", + "kind": "manual" + }, + { + "id": "dw-955823", + "text": "Hidden setup lines (# lines) keep the rendered snippet readable; mdbook build still succeeds (moon run :book or mdbook build crates/id_effect/book).", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvb8sxid-wpkrtg/v1.json b/.maestro/contracts/tsk-mvb8sxid-wpkrtg/v1.json new file mode 100644 index 000000000..415797050 --- /dev/null +++ b/.maestro/contracts/tsk-mvb8sxid-wpkrtg/v1.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 2, + "id": "c-1e07fc", + "taskId": "tsk-mvb8sxid-wpkrtg", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T17:29:39.347Z", + "lockedAt": "2026-10-16T17:29:39.347Z", + "intent": "0.8 W3-B — id_effect_tool and `id-effect new`", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-bf45cd", + "text": "New crate crates/id_effect_tool (integration tier per ADR 0010 Amendment 4; version.workspace, readme, deny(missing_docs), forbid(unsafe_code)) publishing the binary id-effect ([[bin]] name = \"id-effect\", thin src/bin over a tested lib). No dependency on id_effect_kit. Registered: workspace member, moon project + moon.yml, validate-crate-packaging LEVEL/ORDER after id_effect_kit; tier-conformance PASS.", + "kind": "manual" + }, + { + "id": "dw-ac158f", + "text": "`id-effect new --template service|worker|cli|cluster [--dest DIR] [--db none|pg] [--otel|--no-otel] [--local-workspace PATH]` renders templates embedded in the binary (build.rs-generated include_str! table or explicit table with a test that it matches the template dir; {{name}}/{{crate_name}}/{{id_effect_version}} substitution; Cargo.toml.template and _github naming so cargo package keeps them), refuses a non-empty destination, and writes for every template: Cargo.toml depending on id_effect_kit (+ id_effect, since its macros expand to ::id_effect), src, tests, Dockerfile, compose.yaml, .github/workflows/ci.yml, .env.example, README. --local-workspace writes [patch.crates-io] paths to the local id_effect* crates.", + "kind": "manual" + }, + { + "id": "dw-6c4b03", + "text": "Templates: service = axum app with SecurityLayer::recommended, /health + /ready (HealthRegistry), /metrics + http_metrics_layer, OTel when --otel, SQL client + a migration when --db pg (default --db none so it boots with no services); worker = CronRegistry jobs with history; cli = run_main + completions (from examples/cli-minimal); cluster = id_effect_node mesh node. The orphaned crates/id_effect_cli/templates/deploy-kamal is moved into the tool (Dockerfile/Kamal) and removed from id_effect_cli.", + "kind": "manual" + }, + { + "id": "dw-cea6c3", + "text": "scripts/template-smoke.sh generates each template into a temp dir outside the repo with --local-workspace and runs cargo test there (and cargo build); a ci.yml `templates` job runs it as a matrix over the 4 templates and docker-builds the service template; job added to ci-pass.needs; actionlint clean. cargo package -p id_effect_tool --list includes every template file.", + "kind": "manual" + }, + { + "id": "dw-2f76cf", + "text": "Unit tests for rendering, substitution, refusal on non-empty dest, option handling; clippy -D warnings, rustdoc -D warnings; coverage: bin excluded via the coverage IGNORE mechanism with a justification, lib tested.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvb9dhfl-6cdwnk/v1.json b/.maestro/contracts/tsk-mvb9dhfl-6cdwnk/v1.json new file mode 100644 index 000000000..4c648607e --- /dev/null +++ b/.maestro/contracts/tsk-mvb9dhfl-6cdwnk/v1.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-d26a99", + "taskId": "tsk-mvb9dhfl-6cdwnk", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T17:45:38.735Z", + "lockedAt": "2026-10-16T17:45:38.735Z", + "intent": "0.8 W3-E4 — snippet dependencies", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-0d050d", + "text": "examples/book-snippets/Cargo.toml gains the crates the remaining dependency-blocked fences need: tokio (macros, rt, rt-multi-thread, signal), clap (derive), proptest, reqwest (json, no default TLS if it compiles), tower (util) + id_effect_axum feature tower, id_effect_logger, opentelemetry + opentelemetry_sdk (versions matching id_effect_opentelemetry), cli_minimal (path).", + "kind": "manual" + }, + { + "id": "dw-a707b4", + "text": "The hidden tokio stand-in in part1/ch01-01 and the hidden clap stand-in in cookbook/ch40-03 are replaced by the real crates; fences ignored only for \"needs dep\" reasons compile (no_run where they would block or need a network/collector); remaining ignores keep a reason (labs crates, type-definition copies, quick-reference list).", + "kind": "manual" + }, + { + "id": "dw-4f4154", + "text": "cargo test --doc -p book_snippets green; moon run :book green; report the new compiled/total count.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/contracts/tsk-mvb9wqu3-mo1a0u/v1.json b/.maestro/contracts/tsk-mvb9wqu3-mo1a0u/v1.json new file mode 100644 index 000000000..95e4e54a3 --- /dev/null +++ b/.maestro/contracts/tsk-mvb9wqu3-mo1a0u/v1.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-ead934", + "taskId": "tsk-mvb9wqu3-mo1a0u", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T18:00:37.198Z", + "lockedAt": "2026-10-16T18:00:37.198Z", + "intent": "0.8 W3-C — doctor and layer graph", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-23b7eb", + "text": "`id-effect doctor [--path DIR] [--json]` in crates/id_effect_tool/src/doctor/** reports pass/warn/fail per check and exits 1 on any fail: rustc >= the project rust-version (and the id_effect MSRV); every id_effect* crate in Cargo.lock / cargo metadata resolves to one version (no duplicate id_effect); keys listed in .env.example are set; DATABASE_URL (when present) is reachable over TCP; docker/compose present when the project has a Dockerfile/compose.yaml; the service port is free. Unit tests per check, and a fixture project with mixed id_effect versions that doctor fails.", + "kind": "manual" + }, + { + "id": "dw-c69c8f", + "text": "Layer graph (dx.diagnostics #7): crates/id_effect_kit/src/graph.rs lets an app register named layers (name + Layer, using the public TypeIds trait for In/Out capability lists) and renders the dependency edges (consumer In matched to producer Out) as text and DOT, flagging capabilities nobody provides; the service template exposes it with `--describe-layers` and `id-effect doctor --graph` runs that and prints it. Tests: a graph with a missing provider and a DOT golden.", + "kind": "manual" + }, + { + "id": "dw-847580", + "text": "Book: a doctor/graph section (report the SUMMARY line); clippy -D warnings, rustdoc -D warnings, tests for id_effect_tool and id_effect_kit; tier-conformance PASS for both; template-smoke still green for the service template.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbanfma-w9xz64/v1.json b/.maestro/contracts/tsk-mvbanfma-w9xz64/v1.json new file mode 100644 index 000000000..2775ba83e --- /dev/null +++ b/.maestro/contracts/tsk-mvbanfma-w9xz64/v1.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 2, + "id": "c-88e200", + "taskId": "tsk-mvbanfma-w9xz64", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T18:21:20.114Z", + "lockedAt": "2026-10-16T18:21:20.114Z", + "intent": "0.8 W3-F — tutorial and DX KPIs", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-c682e0", + "text": "Book page crates/id_effect/book/src/tutorial-zero-to-production.md (report the SUMMARY line; placed near the start of the book): install id-effect, `id-effect new` a service, run it, add a route with a typed error and a capability, add a test, add --db pg and a migration, run `id-effect doctor` and `--describe-layers`, build the Docker image, deploy config; every Rust step compiled via examples/book-snippets (or verified by a CI script that executes the tutorial steps).", + "kind": "manual" + }, + { + "id": "dw-c1c9b8", + "text": "scripts/dx-kpis.sh (bash, Requires header, exit 1 FAIL / 2 misuse, --self-test, --json) measures K1 (wall time from `id-effect new svc --template service --local-workspace` to GET /health = 200, cold build in a fresh target dir; budget 180 s for 0.8), K4 (gallery cases in crates/id_effect/tests/ui/gallery whose first error carries a fix note per docs/diagnostics.md — 17/20 expected, owner-accepted), K5 (compiled rust fences / all rust fences in the book, counting the book_snippets harness and the id_effect doctest includes; >= 80 %); writes docs/benchmarks/dx-kpis-0.8.json; a moon task dx-kpis (not in default CI path).", + "kind": "manual" + }, + { + "id": "dw-19e500", + "text": "Stale docs corrected: crates/id_effect/book/src/part6/ch34-00-dx-ship.md (no longer \"no id-effect new\", no deploy-kamal path), docs/platform/PHASE_E_CLI_PARITY.md (\"No app generator\" / id-effect-diagnose references); the orphaned crates/id_effect_cli/templates/admin-stub removed or documented as intentionally kept.", + "kind": "manual" + }, + { + "id": "dw-8a1b64", + "text": "cargo test --doc -p book_snippets green; moon run :book green; shellcheck and --self-test for dx-kpis.sh; measured K1/K4/K5 reported with machine info.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbbmmz0-vebdrd/v1.json b/.maestro/contracts/tsk-mvbbmmz0-vebdrd/v1.json new file mode 100644 index 000000000..f572e2b1a --- /dev/null +++ b/.maestro/contracts/tsk-mvbbmmz0-vebdrd/v1.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-48c6ea", + "taskId": "tsk-mvbbmmz0-vebdrd", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T18:48:42.656Z", + "lockedAt": "2026-10-16T18:48:42.656Z", + "intent": "0.8 E0.1a — remove the labs obix outbox adapter", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3a5918", + "text": "Owner decision 2026-10-16: instead of seeking consent for commit 8eb9b05 (#79, Justin Carter), remove the code that still carries it. labs/id_effect_jobs/src/obix_outbox.rs (ObixOutbox, JobsOutboxEvent) is deleted with its lib.rs mod/re-exports, its tests in tests/pg_integration.rs, and its use in examples (010/020); the obix feature keeps ObixInbox only (or is reshaped if the inbox depends on removed items); README, moon.yml and the book (part6 ch31) no longer mention the obix outbox adapter; the 0.10 jobs battery (PLAN-OF-PLANS E7) is noted as the place an outbox relay returns.", + "kind": "manual" + }, + { + "id": "dw-7bda45", + "text": "git blame -C -C -M over every tracked file in the working tree attributes 0 lines to Justin Carter (scripted check over labs/ and crates/id_effect/book/).", + "kind": "manual" + }, + { + "id": "dw-a987b0", + "text": "labs: cargo check --manifest-path labs/Cargo.toml --workspace --all-targets and with --features obix for id_effect_jobs; cargo test for id_effect_jobs (non-pg tests); clippy -D warnings for id_effect_jobs; book still builds (moon run :book).", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbdsp4f-0yq2ci/v1.json b/.maestro/contracts/tsk-mvbdsp4f-0yq2ci/v1.json new file mode 100644 index 000000000..973798eed --- /dev/null +++ b/.maestro/contracts/tsk-mvbdsp4f-0yq2ci/v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-f2a10d", + "taskId": "tsk-mvbdsp4f-0yq2ci", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:21:34.097Z", + "lockedAt": "2026-10-16T21:21:34.097Z", + "intent": "E3.2 migrations book chapter and example", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-9d5531", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-19cdd2", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-9d6d89", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-921365", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-55dcb9", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-4ff5c2", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-ff9835", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvbdsp4f-2m3dmu/v1.json b/.maestro/contracts/tsk-mvbdsp4f-2m3dmu/v1.json new file mode 100644 index 000000000..c63da96c9 --- /dev/null +++ b/.maestro/contracts/tsk-mvbdsp4f-2m3dmu/v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-d82f5c", + "taskId": "tsk-mvbdsp4f-2m3dmu", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:09:02.589Z", + "lockedAt": "2026-10-16T21:09:02.589Z", + "intent": "E3.2 id-effect migrate new/run/status/revert/check", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-a2fe47", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-93f51b", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-9e4a18", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-941b6f", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-62384a", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-6bf9ba", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-7c94e6", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvbdsp4f-5hrfbn/v1.json b/.maestro/contracts/tsk-mvbdsp4f-5hrfbn/v1.json new file mode 100644 index 000000000..0be8859b2 --- /dev/null +++ b/.maestro/contracts/tsk-mvbdsp4f-5hrfbn/v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-abad9b", + "taskId": "tsk-mvbdsp4f-5hrfbn", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:08:55.491Z", + "lockedAt": "2026-10-16T20:08:55.491Z", + "intent": "E5.3 route guards and authz testing", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-e06248", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-efbade", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-0a2e12", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-b6c458", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-059484", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-dcd274", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-7403e2", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvbdsp4f-a1hau9/v1.json b/.maestro/contracts/tsk-mvbdsp4f-a1hau9/v1.json new file mode 100644 index 000000000..b12093233 --- /dev/null +++ b/.maestro/contracts/tsk-mvbdsp4f-a1hau9/v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-851506", + "taskId": "tsk-mvbdsp4f-a1hau9", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:51:52.994Z", + "lockedAt": "2026-10-16T20:51:52.994Z", + "intent": "E3.2 id_effect_sql::migrate with advisory lock", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-5fc715", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-813d6e", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-3e6f1a", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-783d2a", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-f63f1d", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-1f7fac", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-76c9fc", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvbdsp4f-bcinb5/v1.json b/.maestro/contracts/tsk-mvbdsp4f-bcinb5/v1.json new file mode 100644 index 000000000..7617f0f51 --- /dev/null +++ b/.maestro/contracts/tsk-mvbdsp4f-bcinb5/v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-413616", + "taskId": "tsk-mvbdsp4f-bcinb5", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T19:53:30.031Z", + "lockedAt": "2026-10-16T19:53:30.031Z", + "intent": "E5.3 id_effect_authz policies as capabilities", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-7cb720", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-b450f3", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-443c04", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-c7bc53", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-ded337", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-a903d2", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-f7592f", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvbdsp4f-c6kk1f/v1.json b/.maestro/contracts/tsk-mvbdsp4f-c6kk1f/v1.json new file mode 100644 index 000000000..ff0c41918 --- /dev/null +++ b/.maestro/contracts/tsk-mvbdsp4f-c6kk1f/v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-d1f9fb", + "taskId": "tsk-mvbdsp4f-c6kk1f", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T19:53:30.158Z", + "lockedAt": "2026-10-16T19:53:30.158Z", + "intent": "E5.2a argon2 passwords and server-side sessions", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-bcaeff", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-d44130", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-305524", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-47a6fa", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-8b8696", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-e34ee3", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-906ad7", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvbdsp4f-cbg990/v1.json b/.maestro/contracts/tsk-mvbdsp4f-cbg990/v1.json new file mode 100644 index 000000000..608c414b8 --- /dev/null +++ b/.maestro/contracts/tsk-mvbdsp4f-cbg990/v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-b00f37", + "taskId": "tsk-mvbdsp4f-cbg990", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:04:05.203Z", + "lockedAt": "2026-10-16T20:04:05.203Z", + "intent": "E4.1 typed extractors with Problem+JSON errors", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-4b358a", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-9aa519", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-1f7ade", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-f6faab", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-259ff2", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-9c0675", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-85a826", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvbdsp4f-e7yo8j/v1.json b/.maestro/contracts/tsk-mvbdsp4f-e7yo8j/v1.json new file mode 100644 index 000000000..46e605cf5 --- /dev/null +++ b/.maestro/contracts/tsk-mvbdsp4f-e7yo8j/v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-de5028", + "taskId": "tsk-mvbdsp4f-e7yo8j", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:51:52.663Z", + "lockedAt": "2026-10-16T20:51:52.663Z", + "intent": "E3.1a derive(FromRow)", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-81ae45", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-50e230", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-de1e13", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-78eb61", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-c09ca4", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-e0d59c", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-9a16ed", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvbdsp4f-ex9rcm/v1.json b/.maestro/contracts/tsk-mvbdsp4f-ex9rcm/v1.json new file mode 100644 index 000000000..603dac9ba --- /dev/null +++ b/.maestro/contracts/tsk-mvbdsp4f-ex9rcm/v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-334ee9", + "taskId": "tsk-mvbdsp4f-ex9rcm", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T19:49:47.039Z", + "lockedAt": "2026-10-16T19:49:47.039Z", + "intent": "E4.1 typed route descriptor and registry", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-2a74bb", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-1dd4b7", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-c629e9", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-0947d7", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-9c9bba", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-8d29b2", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-8477d7", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvbdsp4f-i8nl5p/v1.json b/.maestro/contracts/tsk-mvbdsp4f-i8nl5p/v1.json new file mode 100644 index 000000000..99b199701 --- /dev/null +++ b/.maestro/contracts/tsk-mvbdsp4f-i8nl5p/v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-1a0e33", + "taskId": "tsk-mvbdsp4f-i8nl5p", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:31:58.880Z", + "lockedAt": "2026-10-16T20:31:58.880Z", + "intent": "E5.2a credentials and JWT", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-7f69fc", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-8c4cbc", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-272680", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-be0f8f", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-24ef03", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-1fe3df", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-951c86", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvbdsp4f-p6l6nv/v1.json b/.maestro/contracts/tsk-mvbdsp4f-p6l6nv/v1.json new file mode 100644 index 000000000..0086ebc1f --- /dev/null +++ b/.maestro/contracts/tsk-mvbdsp4f-p6l6nv/v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-3628a6", + "taskId": "tsk-mvbdsp4f-p6l6nv", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T19:49:47.164Z", + "lockedAt": "2026-10-16T19:49:47.164Z", + "intent": "E5.4 config profiles", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-8ee7ba", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-608073", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-ac4057", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-846105", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-472ad6", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-61f658", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-0c5a9a", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvbdsp4f-souo6g/v1.json b/.maestro/contracts/tsk-mvbdsp4f-souo6g/v1.json new file mode 100644 index 000000000..b8f08fe9e --- /dev/null +++ b/.maestro/contracts/tsk-mvbdsp4f-souo6g/v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-4a73f5", + "taskId": "tsk-mvbdsp4f-souo6g", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:03:16.658Z", + "lockedAt": "2026-10-16T20:03:16.658Z", + "intent": "E5.4 Vault secret provider (feature vault)", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f6be43", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-11728d", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-9765a6", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-fc0f8f", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-441f75", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-bf1528", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-a01e61", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvbdsp4f-spcif4/v1.json b/.maestro/contracts/tsk-mvbdsp4f-spcif4/v1.json new file mode 100644 index 000000000..2bd2ecca1 --- /dev/null +++ b/.maestro/contracts/tsk-mvbdsp4f-spcif4/v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-24f7a7", + "taskId": "tsk-mvbdsp4f-spcif4", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:51:52.834Z", + "lockedAt": "2026-10-16T20:51:52.834Z", + "intent": "E3.1a savepoints, isolation levels, query_stream", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3565d4", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-3dfa3d", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-66b34e", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-02f334", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-91385a", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-a09b90", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-e5c8d6", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvbdsp4f-ua6x6x/v1.json b/.maestro/contracts/tsk-mvbdsp4f-ua6x6x/v1.json new file mode 100644 index 000000000..07dba8166 --- /dev/null +++ b/.maestro/contracts/tsk-mvbdsp4f-ua6x6x/v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-3254b7", + "taskId": "tsk-mvbdsp4f-ua6x6x", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:39:31.238Z", + "lockedAt": "2026-10-16T20:39:31.238Z", + "intent": "E3.1a ripple compile fixes and typed-SQL book chapter", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-143b3f", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-d7b871", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-4cb182", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-cd469e", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-6b5f39", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-0bd153", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-3e7941", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvbdsp4f-wd3fai/v1.json b/.maestro/contracts/tsk-mvbdsp4f-wd3fai/v1.json new file mode 100644 index 000000000..7b513a907 --- /dev/null +++ b/.maestro/contracts/tsk-mvbdsp4f-wd3fai/v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-8c758a", + "taskId": "tsk-mvbdsp4f-wd3fai", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T19:49:46.913Z", + "lockedAt": "2026-10-16T19:49:46.913Z", + "intent": "E3.1a SqlValue and typed SqlRow (design review first)", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-9208c4", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-99371c", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-efbae7", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-1d8f83", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-c33ada", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-eab2e0", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-ca4207", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvbdsp4f-xmojqz/v1.json b/.maestro/contracts/tsk-mvbdsp4f-xmojqz/v1.json new file mode 100644 index 000000000..bef78117e --- /dev/null +++ b/.maestro/contracts/tsk-mvbdsp4f-xmojqz/v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-64de72", + "taskId": "tsk-mvbdsp4f-xmojqz", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T19:49:46.762Z", + "lockedAt": "2026-10-16T19:49:46.762Z", + "intent": "T0 ADR 0010 amendment 5, new crate skeletons, packaging/publish order", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-ee9da0", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-1cc3ec", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-acfd24", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-26f03d", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-3f4b24", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-de775e", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-5c6fb8", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvbdsp4f-y1xx0b/v1.json b/.maestro/contracts/tsk-mvbdsp4f-y1xx0b/v1.json new file mode 100644 index 000000000..71ad2bdf2 --- /dev/null +++ b/.maestro/contracts/tsk-mvbdsp4f-y1xx0b/v1.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-8541de", + "taskId": "tsk-mvbdsp4f-y1xx0b", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:31:59.204Z", + "lockedAt": "2026-10-16T20:31:59.204Z", + "intent": "E4.1 typed routes book chapter and example", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-08adda", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-1678b6", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-249bb0", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-f541f9", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-99f190", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-f4be57", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-b9ec6a", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvbg796n-xpvbrc/v1.json b/.maestro/contracts/tsk-mvbg796n-xpvbrc/v1.json new file mode 100644 index 000000000..478e2d925 --- /dev/null +++ b/.maestro/contracts/tsk-mvbg796n-xpvbrc/v1.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 2, + "id": "c-7b91e1", + "taskId": "tsk-mvbg796n-xpvbrc", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:56:47.644Z", + "lockedAt": "2026-10-16T20:56:47.644Z", + "intent": "PgSessionStore", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f7bb0b", + "text": "id_effect_auth feature `pg`: `PgSessionStore` implements `SessionStore` (save as upsert, load, remove, remove_user, purge_expired) over `id_effect_sql::SqlClient` with `SqlValue` params.", + "kind": "manual" + }, + { + "id": "dw-66d76a", + "text": "The table stores SHA-256 of the session id, never the raw id; ships a migration (`migrations/` in the crate, usable with id_effect_sql::migrate) creating `id_effect_sessions` with an index on user_id and on absolute_expires_at.", + "kind": "manual" + }, + { + "id": "dw-cec605", + "text": "Live Postgres test (ID_EFFECT_REQUIRE_PG=1) runs the same store behaviour suite as MemorySessionStore; book ch30-03 documents the store.", + "kind": "manual" + }, + { + "id": "dw-c3b89c", + "text": "Completes sec.authn #2 (persistent session store).", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbh3xq5-gl0mv7/v1.json b/.maestro/contracts/tsk-mvbh3xq5-gl0mv7/v1.json new file mode 100644 index 000000000..35ba2361b --- /dev/null +++ b/.maestro/contracts/tsk-mvbh3xq5-gl0mv7/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-c9b753", + "taskId": "tsk-mvbh3xq5-gl0mv7", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:22:20.804Z", + "lockedAt": "2026-10-16T21:22:20.804Z", + "intent": "r09-e3-1b-conformance-routing", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-92e18a", + "text": "One generic test module instantiated per backend (#6 evidence); `RoutingSqlClient{primary, replicas}` sends `query` to replicas and `execute`/`begin` to primary; test `reads_go_to_replica_writes_to_primary`. Book section in ch28-01.", + "kind": "manual" + }, + { + "id": "dw-b87778", + "text": "Gate: all backend features", + "kind": "manual" + }, + { + "id": "dw-d3d744", + "text": "Owns: `crates/id_effect_sql/tests/conformance/**`, `src/routing.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbh3xu3-3dzjef/v1.json b/.maestro/contracts/tsk-mvbh3xu3-3dzjef/v1.json new file mode 100644 index 000000000..80639d8ea --- /dev/null +++ b/.maestro/contracts/tsk-mvbh3xu3-3dzjef/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-d71720", + "taskId": "tsk-mvbh3xu3-3dzjef", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T22:13:18.490Z", + "lockedAt": "2026-10-16T22:13:18.490Z", + "intent": "r09-e3-1b-mysql", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-1e52f5", + "text": "Conformance suite green on MySQL 8 (testcontainers).", + "kind": "manual" + }, + { + "id": "dw-e25967", + "text": "Gate: `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p id_effect_sql --features mysql`", + "kind": "manual" + }, + { + "id": "dw-5c469c", + "text": "Owns: `crates/id_effect_sql/src/mysql/**`, feature `mysql`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbh3xxf-emsxrb/v1.json b/.maestro/contracts/tsk-mvbh3xxf-emsxrb/v1.json new file mode 100644 index 000000000..7fd2fa596 --- /dev/null +++ b/.maestro/contracts/tsk-mvbh3xxf-emsxrb/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-678e4b", + "taskId": "tsk-mvbh3xxf-emsxrb", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:22:20.628Z", + "lockedAt": "2026-10-16T21:22:20.628Z", + "intent": "r09-e3-1b-sqlite", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-e46f49", + "text": "Same conformance suite as pg runs green on SQLite in-memory and file DBs.", + "kind": "manual" + }, + { + "id": "dw-0fa362", + "text": "Gate: `cargo nextest run -p id_effect_sql --features sqlite`", + "kind": "manual" + }, + { + "id": "dw-587a13", + "text": "Owns: `crates/id_effect_sql/src/sqlite/**`, feature `sqlite`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbh3y0u-n3qtug/v1.json b/.maestro/contracts/tsk-mvbh3y0u-n3qtug/v1.json new file mode 100644 index 000000000..0fa91cdbb --- /dev/null +++ b/.maestro/contracts/tsk-mvbh3y0u-n3qtug/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-7068f8", + "taskId": "tsk-mvbh3y0u-n3qtug", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T23:01:54.630Z", + "lockedAt": "2026-10-16T23:01:54.630Z", + "intent": "r09-e3-3-docs", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-e25238", + "text": "Chapter + example.", + "kind": "manual" + }, + { + "id": "dw-c303d0", + "text": "Gate: book-snippets doctest", + "kind": "manual" + }, + { + "id": "dw-7e6532", + "text": "Owns: `book/src/part6/ch28-03-repositories.md`, `crates/id_effect_sql/examples/040_repository.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbh3y45-3zte0e/v1.json b/.maestro/contracts/tsk-mvbh3y45-3zte0e/v1.json new file mode 100644 index 000000000..63ff532cf --- /dev/null +++ b/.maestro/contracts/tsk-mvbh3y45-3zte0e/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-3bc0d9", + "taskId": "tsk-mvbh3y45-3zte0e", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T22:13:17.969Z", + "lockedAt": "2026-10-16T22:13:17.969Z", + "intent": "r09-e3-3-repository", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-7aa410", + "text": "`#[derive(Entity)]` (table/columns, #1) and `Repository` CRUD; `#[entity(version)]` column → update fails with `SqlError::StaleVersion` (#5); `#[entity(scope = …)]` global filters (soft delete, tenant/owner) applied to every generated query (#8). Tests `stale_version_is_rejected`, `global_filter_hides_soft_deleted`, `tenant_filter_scopes_every_query`.", + "kind": "manual" + }, + { + "id": "dw-744785", + "text": "Gate: pg + sqlite nextest", + "kind": "manual" + }, + { + "id": "dw-67e0a3", + "text": "Owns: `crates/id_effect_proc_macro/src/repository.rs` + derive line, `crates/id_effect_sql/src/repository.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbh3y7e-zu7pb4/v1.json b/.maestro/contracts/tsk-mvbh3y7e-zu7pb4/v1.json new file mode 100644 index 000000000..268e6b07c --- /dev/null +++ b/.maestro/contracts/tsk-mvbh3y7e-zu7pb4/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-bd9972", + "taskId": "tsk-mvbh3y7e-zu7pb4", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:22:20.927Z", + "lockedAt": "2026-10-16T21:22:20.927Z", + "intent": "r09-e3-3-sea-query", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-26bf6a", + "text": "`select/insert/update/delete` builders from sea-query rendered per backend dialect; values bridged to `SqlValue` (D3); `query_as::(raw_sql)` (#6).", + "kind": "manual" + }, + { + "id": "dw-05014a", + "text": "Gate: `cargo nextest run -p id_effect_sql --all-features`", + "kind": "manual" + }, + { + "id": "dw-2c7894", + "text": "Owns: `crates/id_effect_sql/src/query/**`, feature `query`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbh3yap-l09oxk/v1.json b/.maestro/contracts/tsk-mvbh3yap-l09oxk/v1.json new file mode 100644 index 000000000..5c195232f --- /dev/null +++ b/.maestro/contracts/tsk-mvbh3yap-l09oxk/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-9d2ff0", + "taskId": "tsk-mvbh3yap-l09oxk", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T22:13:18.860Z", + "lockedAt": "2026-10-16T22:13:18.860Z", + "intent": "r09-e3-4-redis", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-ea0212", + "text": "`RedisLayer` (pooled connection manager); typed KV/hash/JSON store API (D6); `CacheBackend` implemented by the in-process cache and by Redis so `Cache` code runs on either (#2); `RedisSessionStore: SessionStore`; rate-limit store for E4.1b; pub/sub as `Stream`. Tests against a Redis container. Book `ch28-04-redis.md`, example.", + "kind": "manual" + }, + { + "id": "dw-2ae36a", + "text": "Gate: `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p id_effect_redis`; tier-conformance", + "kind": "manual" + }, + { + "id": "dw-7fb113", + "text": "Owns: `crates/id_effect_redis/**`; `crates/id_effect/src/resource/cache.rs` (additive `CacheBackend` trait)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbh3ye3-0dqyir/v1.json b/.maestro/contracts/tsk-mvbh3ye3-0dqyir/v1.json new file mode 100644 index 000000000..0c890512b --- /dev/null +++ b/.maestro/contracts/tsk-mvbh3ye3-0dqyir/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-b3042e", + "taskId": "tsk-mvbh3ye3-0dqyir", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:22:21.172Z", + "lockedAt": "2026-10-16T21:22:21.172Z", + "intent": "r09-e4-1b-layers", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-b0609e", + "text": "First-party `CompressionLayer`, `RequestIdLayer` (propagates `x-request-id`, opens a tracing span with method/route/id), `TimeoutLayer` (→ 503 Problem), `BodyLimitLayer` (→ 413), `RateLimitLayer` over `id_effect_resilience::RateLimiter` (→ 429, store pluggable for Redis); `SecurityLayer::recommended` unchanged. One test per layer.", + "kind": "manual" + }, + { + "id": "dw-59997e", + "text": "Gate: `cargo nextest run -p id_effect_axum --all-features`", + "kind": "manual" + }, + { + "id": "dw-a01810", + "text": "Owns: `crates/id_effect_axum/src/middleware/{compression.rs,request_id.rs,limits.rs,rate_limit.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbh3yhd-mlyvgg/v1.json b/.maestro/contracts/tsk-mvbh3yhd-mlyvgg/v1.json new file mode 100644 index 000000000..ab6037c75 --- /dev/null +++ b/.maestro/contracts/tsk-mvbh3yhd-mlyvgg/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-fafa10", + "taskId": "tsk-mvbh3yhd-mlyvgg", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:46:06.885Z", + "lockedAt": "2026-10-16T21:46:06.885Z", + "intent": "r09-e4-1b-static-cache", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-a080c8", + "text": "`serve_dir` with ETag / Last-Modified / 304 (web.server#7); `HttpCacheLayer` sets Cache-Control and answers `If-None-Match` for GET responses (data.cache#7). Tests `conditional_get_returns_304`, `etag_changes_with_body`. Book `ch29-03-middleware.md`, example `090_middleware.rs`.", + "kind": "manual" + }, + { + "id": "dw-898fcb", + "text": "Gate: same + book-snippets", + "kind": "manual" + }, + { + "id": "dw-cbbeec", + "text": "Owns: `crates/id_effect_axum/src/server/static_files.rs`, `src/middleware/http_cache.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbh3yki-lxt7mh/v1.json b/.maestro/contracts/tsk-mvbh3yki-lxt7mh/v1.json new file mode 100644 index 000000000..94848bd24 --- /dev/null +++ b/.maestro/contracts/tsk-mvbh3yki-lxt7mh/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-a51e96", + "taskId": "tsk-mvbh3yki-lxt7mh", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:22:21.047Z", + "lockedAt": "2026-10-16T21:22:21.047Z", + "intent": "r09-e4-2-openapi-doc", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3c7863", + "text": "`OpenApi::from_router(&router)` builds a 3.1 document whose component schemas are `to_json_schema` of the same Schemas the extractors decode with (#4); `securitySchemes` from declared auth (bearer JWT, cookie session, OIDC) (#7). Test validates the output against the OpenAPI 3.1 meta-schema and asserts schema identity with `json_schema_for::()`.", + "kind": "manual" + }, + { + "id": "dw-633d13", + "text": "Gate: `cargo nextest run -p id_effect_axum --features openapi`", + "kind": "manual" + }, + { + "id": "dw-e6b318", + "text": "Owns: `crates/id_effect_axum/src/openapi/**` (feature `openapi`)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbh3yns-8zm2bm/v1.json b/.maestro/contracts/tsk-mvbh3yns-8zm2bm/v1.json new file mode 100644 index 000000000..43032b40f --- /dev/null +++ b/.maestro/contracts/tsk-mvbh3yns-8zm2bm/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-767817", + "taskId": "tsk-mvbh3yns-8zm2bm", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T22:13:17.444Z", + "lockedAt": "2026-10-16T22:13:17.444Z", + "intent": "r09-e4-2-ui-routes", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-be5b5f", + "text": "`/openapi.json`, `/docs` (Scalar, D11), `/_routes` route table (method, path, handler name, schemas) behind a feature and an opt-in mount (web.server#8). Book `ch29-02-openapi.md`, example `085_openapi.rs`, integration test over a socket.", + "kind": "manual" + }, + { + "id": "dw-6dafde", + "text": "Gate: same + book-snippets; level must reach `documented` (0.8 miss cause)", + "kind": "manual" + }, + { + "id": "dw-00ad5a", + "text": "Owns: `crates/id_effect_axum/src/openapi/ui.rs`, `src/route/introspect.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbh3yr2-bsovx7/v1.json b/.maestro/contracts/tsk-mvbh3yr2-bsovx7/v1.json new file mode 100644 index 000000000..34a28397d --- /dev/null +++ b/.maestro/contracts/tsk-mvbh3yr2-bsovx7/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-2a710f", + "taskId": "tsk-mvbh3yr2-bsovx7", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:22:21.291Z", + "lockedAt": "2026-10-16T21:22:21.291Z", + "intent": "r09-e5-2b-oidc", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-ea850d", + "text": "Discovery, authorization-code + PKCE, state/nonce in the session, ID-token validation, callback handler; `OAuthClient` implemented for it. Integration test logs in against a Keycloak container (D5).", + "kind": "manual" + }, + { + "id": "dw-229005", + "text": "Gate: `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p id_effect_auth --features oidc`", + "kind": "manual" + }, + { + "id": "dw-6e9c48", + "text": "Owns: `crates/id_effect_auth/src/oidc.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbh3yue-83n9vp/v1.json b/.maestro/contracts/tsk-mvbh3yue-83n9vp/v1.json new file mode 100644 index 000000000..7e071e600 --- /dev/null +++ b/.maestro/contracts/tsk-mvbh3yue-83n9vp/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-003692", + "taskId": "tsk-mvbh3yue-83n9vp", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:46:07.533Z", + "lockedAt": "2026-10-16T21:46:07.533Z", + "intent": "r09-e5-2b-totp", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-5c6679", + "text": "Enrol (secret + otpauth URI), verify with ±1 step skew under the executor clock (TestExecutor-testable), recovery codes hashed with argon2.", + "kind": "manual" + }, + { + "id": "dw-d59366", + "text": "Gate: `cargo nextest run -p id_effect_auth --all-features`", + "kind": "manual" + }, + { + "id": "dw-26c6b5", + "text": "Owns: `crates/id_effect_auth/src/totp.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbh3yxp-p9pp0e/v1.json b/.maestro/contracts/tsk-mvbh3yxp-p9pp0e/v1.json new file mode 100644 index 000000000..e05a7e439 --- /dev/null +++ b/.maestro/contracts/tsk-mvbh3yxp-p9pp0e/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-6e0faf", + "taskId": "tsk-mvbh3yxp-p9pp0e", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:46:08.061Z", + "lockedAt": "2026-10-16T21:46:08.061Z", + "intent": "r09-e5-5-audit", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3d4785", + "text": "`AuditLog` Layer appends `{actor, tenant, action, entity, before, after, at}` inside the caller's transaction; each record stores `sha256(prev_hash ‖ record)`; `verify_chain()` reports the first broken link. Tests `tampered_row_breaks_chain`, `audit_rolls_back_with_tx`.", + "kind": "manual" + }, + { + "id": "dw-93e735", + "text": "Gate: pg nextest `--features audit`", + "kind": "manual" + }, + { + "id": "dw-2e22be", + "text": "Owns: `crates/id_effect_sql/src/audit.rs` (feature `audit`), its migration", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbh3z0z-819ef0/v1.json b/.maestro/contracts/tsk-mvbh3z0z-819ef0/v1.json new file mode 100644 index 000000000..9197e1f7a --- /dev/null +++ b/.maestro/contracts/tsk-mvbh3z0z-819ef0/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-7b35a3", + "taskId": "tsk-mvbh3z0z-819ef0", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:46:08.459Z", + "lockedAt": "2026-10-16T21:46:08.459Z", + "intent": "r09-e5-5-tenancy-doc", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-a7ef51", + "text": "Documented row-level tenant pattern: CapOverrideLayer tenant → Repository global filter → audit actor/tenant.", + "kind": "manual" + }, + { + "id": "dw-81b875", + "text": "Gate: book-snippets doctest", + "kind": "manual" + }, + { + "id": "dw-5c340d", + "text": "Owns: `book/src/part6/ch30-04-audit-tenancy.md`, example", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbh3z49-hbvk3j/v1.json b/.maestro/contracts/tsk-mvbh3z49-hbvk3j/v1.json new file mode 100644 index 000000000..3538c9876 --- /dev/null +++ b/.maestro/contracts/tsk-mvbh3z49-hbvk3j/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-9594b9", + "taskId": "tsk-mvbh3z49-hbvk3j", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:22:21.413Z", + "lockedAt": "2026-10-16T21:22:21.413Z", + "intent": "r09-e6-5-containers", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-e22be4", + "text": "Effect-native `Postgres/MySql/Redis/Keycloak/Vault` containers as scoped Layers (testcontainers), torn down on scope exit or interrupt (#2).", + "kind": "manual" + }, + { + "id": "dw-0ddb85", + "text": "Gate: `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p id_effect_test`", + "kind": "manual" + }, + { + "id": "dw-e43c4c", + "text": "Owns: `crates/id_effect_test/src/containers/**`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbh3z7s-2ol5gk/v1.json b/.maestro/contracts/tsk-mvbh3z7s-2ol5gk/v1.json new file mode 100644 index 000000000..9104dcc01 --- /dev/null +++ b/.maestro/contracts/tsk-mvbh3z7s-2ol5gk/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-be3c11", + "taskId": "tsk-mvbh3z7s-2ol5gk", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T22:36:34.505Z", + "lockedAt": "2026-10-16T22:36:34.505Z", + "intent": "r09-e6-5-isolation-fixtures", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-8a9dbf", + "text": "`isolated_db()` per test: transaction rolled back at end, or template-database clone for tests that commit (#3); `Fixture`/factory trait with `FromRow` + Schema-arbitrary defaults (#4); re-export of the in-process HTTP client (#1). Book `part4` integration-testing chapter, example.", + "kind": "manual" + }, + { + "id": "dw-3b92ff", + "text": "Gate: same + tier-conformance id_effect_test", + "kind": "manual" + }, + { + "id": "dw-7e6a7a", + "text": "Owns: `crates/id_effect_test/src/{db.rs,fixtures.rs,http.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbl55h5-z4i872/v1.json b/.maestro/contracts/tsk-mvbl55h5-z4i872/v1.json new file mode 100644 index 000000000..d6ffa867c --- /dev/null +++ b/.maestro/contracts/tsk-mvbl55h5-z4i872/v1.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 2, + "id": "c-0aa267", + "taskId": "tsk-mvbl55h5-z4i872", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T23:15:06.300Z", + "lockedAt": "2026-10-16T23:15:06.300Z", + "intent": "r09-sql-tls", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-868de5", + "text": "sqlx is built with rustls TLS for the pg and mysql features (aws-lc-rs provider, matching the rustls / jsonwebtoken already in the tree; no OpenSSL). `sslmode` (pg) and `ssl-mode` (mysql) in the URL are honoured, including verify-full / VERIFY_IDENTITY with a custom root CA.", + "kind": "manual" + }, + { + "id": "dw-4770c9", + "text": "PgPoolConfig / MySqlPoolConfig expose the TLS mode and root certificate explicitly (not only through the URL), with a documented default. The default must not silently downgrade: document what `prefer` means and recommend `require` / verify modes for production.", + "kind": "manual" + }, + { + "id": "dw-cddf16", + "text": "Live tests against TLS-enabled PostgreSQL and MySQL containers (self-signed CA generated in the test): a verify-full connection succeeds, a wrong CA fails with a typed error, and a server without TLS fails under `require`. SKIP without Docker; fail under ID_EFFECT_REQUIRE_DOCKER=1.", + "kind": "manual" + }, + { + "id": "dw-d5c631", + "text": "Book ch28-01 Backends: the plaintext note is replaced by a TLS section; CHANGELOG; MSRV 1.94; licences MIT/Apache/ISC.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvblcbcl-ojq8t0/v1.json b/.maestro/contracts/tsk-mvblcbcl-ojq8t0/v1.json new file mode 100644 index 000000000..f6307ae20 --- /dev/null +++ b/.maestro/contracts/tsk-mvblcbcl-ojq8t0/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-eb0e89", + "taskId": "tsk-mvblcbcl-ojq8t0", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T23:20:44.197Z", + "lockedAt": "2026-10-16T23:20:44.197Z", + "intent": "r09-w3-kit-template", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-dc7a24", + "text": "Kit features `sql-sqlite`, `sql-mysql`, `migrate`, `openapi`, `auth`, `authz`, `test` (+`redis`); `full` updated; service template replaces the `include_str!` migration (`db.rs.template:10,26`) with `migrate::run`.", + "kind": "manual" + }, + { + "id": "dw-da2584", + "text": "Gate: kit tests; CI template matrix (`ci.yml` template job)", + "kind": "manual" + }, + { + "id": "dw-a66179", + "text": "Owns: `crates/id_effect_kit/Cargo.toml` + `src/prelude.rs`, `crates/id_effect_tool/templates/service/**`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvblcbgd-lktftw/v1.json b/.maestro/contracts/tsk-mvblcbgd-lktftw/v1.json new file mode 100644 index 000000000..cce03dc16 --- /dev/null +++ b/.maestro/contracts/tsk-mvblcbgd-lktftw/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-246560", + "taskId": "tsk-mvblcbgd-lktftw", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T23:39:20.074Z", + "lockedAt": "2026-10-16T23:39:20.074Z", + "intent": "r09-w3-ledger", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-cf4ad0", + "text": "See below.", + "kind": "manual" + }, + { + "id": "dw-cf30e0", + "text": "Gate: `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p ledger`; `bash scripts/tier-conformance.sh` (all PASS)", + "kind": "manual" + }, + { + "id": "dw-0e883b", + "text": "Owns: `examples/reference/ledger/**`, root `Cargo.toml` member, `.github/workflows/ci.yml` (`reference-ledger` job), tier flips to `platform` in the new crates' manifests", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvblcbjl-ijn35n/v1.json b/.maestro/contracts/tsk-mvblcbjl-ijn35n/v1.json new file mode 100644 index 000000000..4b03a49e6 --- /dev/null +++ b/.maestro/contracts/tsk-mvblcbjl-ijn35n/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-2dbc7a", + "taskId": "tsk-mvblcbjl-ijn35n", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T00:58:35.210Z", + "lockedAt": "2026-10-17T00:58:35.210Z", + "intent": "r09-w3-k3", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-06884e", + "text": "K3 = added non-blank lines of the scripted \"invoice\" resource diff (model, migration, routes, policy, test) applied to Ledger, recorded.", + "kind": "manual" + }, + { + "id": "dw-ef62e5", + "text": "Gate: `bash scripts/dx-kpis.sh --skip-k1` prints K3", + "kind": "manual" + }, + { + "id": "dw-7aaf3c", + "text": "Owns: `scripts/dx-kpis.sh` (K3), the 0.9 dx results file", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvblcbms-o8fsc1/v1.json b/.maestro/contracts/tsk-mvblcbms-o8fsc1/v1.json new file mode 100644 index 000000000..897ef6b0d --- /dev/null +++ b/.maestro/contracts/tsk-mvblcbms-o8fsc1/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-fd27d1", + "taskId": "tsk-mvblcbms-o8fsc1", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T00:58:35.328Z", + "lockedAt": "2026-10-17T00:58:35.328Z", + "intent": "r09-w3-level-audit", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-8740a1", + "text": "For each of the 15 rows: every planned check id maps to an existing path at HEAD and the row has book + example + integration test; list gaps as tasks.", + "kind": "manual" + }, + { + "id": "dw-a683ad", + "text": "Gate: `python3 -I .tmp/plan09/gaps.py v` compared against the evidence map", + "kind": "manual" + }, + { + "id": "dw-1e9c01", + "text": "Owns: none (read-only)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvbm09jg-c92j9a/v1.json b/.maestro/contracts/tsk-mvbm09jg-c92j9a/v1.json new file mode 100644 index 000000000..7df69050a --- /dev/null +++ b/.maestro/contracts/tsk-mvbm09jg-c92j9a/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-02ad50", + "taskId": "tsk-mvbm09jg-c92j9a", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T23:39:19.663Z", + "lockedAt": "2026-10-16T23:39:19.663Z", + "intent": "r09-w3-tutorial", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-461aad", + "text": "book/src/tutorial-zero-to-production.md follows the 0.9 service template: migrations through id_effect_sql::migrate (migrations/ applied on start, id-effect migrate new / status / check), no include_str! migration constants; the typed route and Problem+JSON answer; the middleware stack.", + "kind": "manual" + }, + { + "id": "dw-980ba1", + "text": "Every rust block still compiles in examples/book-snippets (cargo test -p book_snippets --doc); the tutorial text matches the generated project file by file (verify by generating one with id-effect new and comparing).", + "kind": "manual" + }, + { + "id": "dw-89d99a", + "text": "dx KPI K1 (time to a running service) is unaffected; scripts/dx-kpis.sh still passes its tutorial-related steps if any.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/contracts/tsk-mvbp968a-2m4p9i/v1.json b/.maestro/contracts/tsk-mvbp968a-2m4p9i/v1.json new file mode 100644 index 000000000..42c951f64 --- /dev/null +++ b/.maestro/contracts/tsk-mvbp968a-2m4p9i/v1.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-19cd27", + "taskId": "tsk-mvbp968a-2m4p9i", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T01:10:16.716Z", + "lockedAt": "2026-10-17T01:10:16.716Z", + "intent": "r09-w3-gaps", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-76b702", + "text": "R1: book ch30-02-authorization.md gains a \"data-layer scoping\" section (Repository::for_current_tenant, the tenant filter on every statement, fail-closed) with a compiling snippet; cites the Ledger test organisations_never_see_each_other.", + "kind": "manual" + }, + { + "id": "dw-202003", + "text": "R2: ch28-03-repositories.md shows a join, ordering and paging with sea-query + Page; crates/id_effect_sql/tests/query.rs gains a join test.", + "kind": "manual" + }, + { + "id": "dw-35f9d5", + "text": "R3: ch28-04-redis.md describes RedisKv / RedisHash / RedisJson as a typed key-value and JSON document store, with a compiling snippet.", + "kind": "manual" + }, + { + "id": "dw-18b05d", + "text": "H4: crates/id_effect_sql/examples/020_typed_rows.rs (runs without a database: TestSqlClient or SQLite) showing SqlValue, params!, typed get, FromRow; [[example]] entry.", + "kind": "manual" + }, + { + "id": "dw-00a30e", + "text": "G2 (eco.tenancy #7): a RateLimitKey keyed by the request tenant (from RequestCaps / a header / a closure over request extensions — whatever fits without id_effect_axum depending on id_effect_sql), with a test; ch30-07 no longer says per-tenant limits are not provided; ch29-03 mentions it.", + "kind": "manual" + }, + { + "id": "dw-437bf0", + "text": "G4 (test.integration #8): a contract-testing helper in id_effect_test (feature http + openapi-aware: assert a TestResponse conforms to the response schema an OpenApi document declares for that operation and status), a test, a section in ch15-08, and use in Ledger tests/http.rs.", + "kind": "manual" + }, + { + "id": "dw-a93d6c", + "text": "All touched crates: nextest, clippy -D warnings, fmt; book_snippets doctests pass.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/contracts/tsk-mvcjv2xv-k2omgn/v1.json b/.maestro/contracts/tsk-mvcjv2xv-k2omgn/v1.json new file mode 100644 index 000000000..f662bd716 --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv2xv-k2omgn/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-788775", + "taskId": "tsk-mvcjv2xv-k2omgn", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:28:18.570Z", + "lockedAt": "2026-10-17T15:28:18.570Z", + "intent": "r10-co-schema-errors", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-b1d10f", + "text": "Derived schemas accumulate all field errors like combinator schemas (`decode_unknown_all`); trybuild/ui snapshots updated. Test `derived_schema_reports_every_invalid_field`.", + "kind": "manual" + }, + { + "id": "dw-105655", + "text": "Gate: `cargo nextest run -p id_effect_proc_macro -p id_effect`", + "kind": "manual" + }, + { + "id": "dw-08aa45", + "text": "Owns: `crates/id_effect_proc_macro/src/derive_schema_parser.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcjv31o-sevooz/v1.json b/.maestro/contracts/tsk-mvcjv31o-sevooz/v1.json new file mode 100644 index 000000000..a5a254e42 --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv31o-sevooz/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-bb48d3", + "taskId": "tsk-mvcjv31o-sevooz", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:40:33.264Z", + "lockedAt": "2026-10-17T15:40:33.264Z", + "intent": "r10-co-sql-errors", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-593ddd", + "text": "`SqlError` gains `#[non_exhaustive]` variants for connection and TLS failures and a typed SQLSTATE on plain `query`/`execute` (unique violation detectable without message parsing — used by E7.1 and E7.2); `PgAdvisoryLease::from_pool_config` honours explicit TLS settings. Tests `unique_violation_has_sqlstate_23505`, `tls_required_against_plain_server_is_tls_error`, `advisory_lease_uses_pool_tls`.", + "kind": "manual" + }, + { + "id": "dw-b75a6f", + "text": "Gate: ` -p id_effect_sql -p id_effect_node --all-features`", + "kind": "manual" + }, + { + "id": "dw-116078", + "text": "Owns: `crates/id_effect_sql/src/{error.rs,pg/error.rs,pg/client.rs}`, `crates/id_effect_node/src/singleton.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcjv35b-3d8syc/v1.json b/.maestro/contracts/tsk-mvcjv35b-3d8syc/v1.json new file mode 100644 index 000000000..fa87b0ff0 --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv35b-3d8syc/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-8ec8e1", + "taskId": "tsk-mvcjv35b-3d8syc", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:40:03.075Z", + "lockedAt": "2026-10-17T15:40:03.075Z", + "intent": "r10-e4-3-client-defaults", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f337ae", + "text": "`RetryingHttpClient` decorator over a core `Schedule` per D10, installed by the provider layer (`:388-391`) and configured once (#4); `default_client` sets connect/total timeouts. Tests (wiremock) `retries_idempotent_get_then_succeeds`, `post_not_retried_by_default`, `retry_after_is_honoured`, `default_timeout_applies`.", + "kind": "manual" + }, + { + "id": "dw-6ac0b2", + "text": "Gate: `cargo nextest run -p id_effect_platform --all-features`", + "kind": "manual" + }, + { + "id": "dw-0de9ce", + "text": "Owns: `crates/id_effect_platform/src/http/{mod.rs,retry.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcjv38n-qmey5i/v1.json b/.maestro/contracts/tsk-mvcjv38n-qmey5i/v1.json new file mode 100644 index 000000000..1cedbd46d --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv38n-qmey5i/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-8e6ced", + "taskId": "tsk-mvcjv38n-qmey5i", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T17:07:33.829Z", + "lockedAt": "2026-10-17T17:07:33.829Z", + "intent": "r10-e4-3-tracing", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-5183ec", + "text": "`OtelConfig.sampler` (always/never/ratio/parent-based) and `OTEL_TRACES_SAMPLER(_ARG)` (#7); `TracedHttpClient` (feature `platform`) opens a client span and calls `inject_into_http_request` (web.client#6, obs.tracing#6); `id_effect_sql` emits a `db.query` span with `db.system` and a parameter-free statement, bridged by `OtelSpanHook` (#6); `axum.rs` uses `.instrument(span)` instead of `span.enter()` across `.await`; `metrics.rs` doc links build without `pg`. Tests with the in-memory exporter `client_span_is_child_and_sends_traceparent`, `db_query_creates_span`, `ratio_sampler_from_env`, `server_span_not_held_across_await`. Book section in `ch27-00-observability.md`.", + "kind": "manual" + }, + { + "id": "dw-28dc2a", + "text": "Gate: `cargo nextest run -p id_effect_opentelemetry -p id_effect_sql --all-features`; `cargo doc -p id_effect_sql --no-deps` with `RUSTDOCFLAGS=-D warnings`", + "kind": "manual" + }, + { + "id": "dw-73c991", + "text": "Owns: `crates/id_effect_opentelemetry/src/{config.rs,otlp.rs,client.rs,axum.rs,span_bridge.rs}`, `crates/id_effect_opentelemetry/Cargo.toml`, `crates/id_effect_sql/src/metrics.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcjv3bz-jrd70e/v1.json b/.maestro/contracts/tsk-mvcjv3bz-jrd70e/v1.json new file mode 100644 index 000000000..62d9415b0 --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv3bz-jrd70e/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-3e3e19", + "taskId": "tsk-mvcjv3bz-jrd70e", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T17:57:38.522Z", + "lockedAt": "2026-10-17T17:57:38.522Z", + "intent": "r10-e4-4-container-docs", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-1087b2", + "text": "S3-compatible container pinned per D11; the conformance tests run against it; chapter + example.", + "kind": "manual" + }, + { + "id": "dw-bfff75", + "text": "Gate: ` -p id_effect_storage -p id_effect_test --all-features`; `bash scripts/tier-conformance.sh id_effect_storage`", + "kind": "manual" + }, + { + "id": "dw-cbfa8a", + "text": "Owns: `crates/id_effect_test/src/containers/s3.rs` (+ one `mod` line), `crates/id_effect_storage/tests/s3.rs`, `book/src/part6/ch28-05-storage.md`, `crates/id_effect_storage/examples/010_storage.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcjv3fd-36woc8/v1.json b/.maestro/contracts/tsk-mvcjv3fd-36woc8/v1.json new file mode 100644 index 000000000..2e952ef3e --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv3fd-36woc8/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-9572b2", + "taskId": "tsk-mvcjv3fd-36woc8", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:41:54.618Z", + "lockedAt": "2026-10-17T15:41:54.618Z", + "intent": "r10-e4-4-storage", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-1280e8", + "text": "`ObjectStore` capability over object_store (put/get/head/delete/list); streaming upload/download as `Stream` without buffering the object (#5); S3 backend (#3); presigned URLs for S3 and HMAC-signed expiring URLs for the local backend (#7); local FS and in-memory backends. `cargo deny check` green for object_store's tree. Tests `stream_upload_never_buffers_whole_object`, `signed_url_expires`, `memory_and_local_conform`.", + "kind": "manual" + }, + { + "id": "dw-5f042a", + "text": "Gate: `cargo nextest run -p id_effect_storage --all-features`; `cargo deny check`", + "kind": "manual" + }, + { + "id": "dw-dff6f1", + "text": "Owns: `crates/id_effect_storage/src/{lib.rs,store.rs,local.rs,memory.rs,s3.rs,signed.rs}`, `crates/id_effect_storage/Cargo.toml`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcjv3iq-y7wzar/v1.json b/.maestro/contracts/tsk-mvcjv3iq-y7wzar/v1.json new file mode 100644 index 000000000..f83681a15 --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv3iq-y7wzar/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-7f89b4", + "taskId": "tsk-mvcjv3iq-y7wzar", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:41:54.476Z", + "lockedAt": "2026-10-17T15:41:54.476Z", + "intent": "r10-e6-2-fiber-registry", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-7c9c5f", + "text": "D8; `fiber_dump()` returns id, parent id, state, current span name, age for live fibers; entries vanish when the fiber ends. Tests `dump_lists_running_and_drops_finished`, `parent_links_follow_fork`. Bench gates and `kernel_alloc` green with the feature off and on.", + "kind": "manual" + }, + { + "id": "dw-c22aee", + "text": "Gate: `cargo nextest run -p id_effect --features fiber-registry`; `cargo nextest run -p id_effect --test kernel_alloc`; `scripts/bench-compare.sh` (no budget fail)", + "kind": "manual" + }, + { + "id": "dw-34dfaa", + "text": "Owns: `crates/id_effect/src/runtime/{fiber_context.rs,fiber_registry.rs}`, `crates/id_effect/Cargo.toml` (feature `fiber-registry`)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvcjv3m4-28cr81/v1.json b/.maestro/contracts/tsk-mvcjv3m4-28cr81/v1.json new file mode 100644 index 000000000..7dc93fca4 --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv3m4-28cr81/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-436b66", + "taskId": "tsk-mvcjv3m4-28cr81", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:28:18.695Z", + "lockedAt": "2026-10-17T15:28:18.695Z", + "intent": "r10-e6-4-redaction-sampling", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-abbeeb", + "text": "`RedactionPolicy` (field names, globs, `Secret` values) applied once in `emit_record` before fan-out (#7); `SamplingBackend` (ratio per level + token-bucket rate limit, counts dropped lines) (#8); `LogRecord` gains `target` and becomes `#[non_exhaustive]` (MIGRATION-0.10). First integration tests `redacted_field_never_reaches_any_backend`, `rate_limit_drops_and_counts`.", + "kind": "manual" + }, + { + "id": "dw-a31e39", + "text": "Gate: `cargo nextest run -p id_effect_logger --all-features`", + "kind": "manual" + }, + { + "id": "dw-6e847b", + "text": "Owns: `crates/id_effect_logger/src/{lib.rs,pipeline.rs,redact.rs,sample.rs}`, `crates/id_effect_logger/tests/pipeline.rs`, `book/src/part6/ch27-00-observability.md` (section)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcjv3pj-tef3al/v1.json b/.maestro/contracts/tsk-mvcjv3pj-tef3al/v1.json new file mode 100644 index 000000000..689f0b9f5 --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv3pj-tef3al/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-c295a2", + "taskId": "tsk-mvcjv3pj-tef3al", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T17:07:34.000Z", + "lockedAt": "2026-10-17T17:07:34.000Z", + "intent": "r10-e6-5b-faults", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-19452d", + "text": "`Fault::{delay, fail, timeout}` applied to `SqlClient` and `HttpClient` doubles; delays run on the virtual clock (`TestExecutor::advance`) (#2); `FaultPlan::seeded(seed)` gives the same schedule for the same seed (#7). Tests `same_seed_same_fault_schedule`, `delay_advances_virtual_clock_only`, `retry_policy_survives_injected_latency`.", + "kind": "manual" + }, + { + "id": "dw-1c8539", + "text": "Gate: `cargo nextest run -p id_effect_test -p id_effect_sql --features id_effect_test/fault`", + "kind": "manual" + }, + { + "id": "dw-db942f", + "text": "Owns: `crates/id_effect_test/src/fault/**` (feature `fault`), `crates/id_effect_test/Cargo.toml`, `crates/id_effect_sql/src/client.rs` (`TestSqlClient` delay only)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcjv3sy-7786hr/v1.json b/.maestro/contracts/tsk-mvcjv3sy-7786hr/v1.json new file mode 100644 index 000000000..98c790b28 --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv3sy-7786hr/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-8f8103", + "taskId": "tsk-mvcjv3sy-7786hr", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T17:17:46.502Z", + "lockedAt": "2026-10-17T17:17:46.502Z", + "intent": "r10-e6-5b-guide", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-fa4b48", + "text": "Guide (#8) covering latency, seeded plans, partitions and kills (existing #3 #4); example; integration test.", + "kind": "manual" + }, + { + "id": "dw-c6b451", + "text": "Gate: `cargo test --doc -p book_snippets`; same nextest", + "kind": "manual" + }, + { + "id": "dw-ed36da", + "text": "Owns: `book/src/part4/ch15-09-fault-injection.md` + `SUMMARY.md` line, `crates/id_effect_test/examples/020_faults.rs`, `crates/id_effect_test/tests/faults.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcjv3wa-40y1j9/v1.json b/.maestro/contracts/tsk-mvcjv3wa-40y1j9/v1.json new file mode 100644 index 000000000..b77931d4a --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv3wa-40y1j9/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-66b5da", + "taskId": "tsk-mvcjv3wa-40y1j9", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:41:54.327Z", + "lockedAt": "2026-10-17T15:41:54.327Z", + "intent": "r10-e7-1-queue-core", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-870b81", + "text": "`Job` trait (Schema-encoded payload, name, queue, priority, max_attempts, unique_key, run_at); `JobQueue` capability with `PgJobQueue` and `MemoryJobQueue`; `enqueue` and `enqueue_in(&SqlTransaction)` (#8); dequeue `FOR UPDATE SKIP LOCKED` ordered by priority then run_at (#4, #5); states queued/running/succeeded/failed/dead, next attempt from a core `Schedule`, `dead` after max_attempts (#2); partial unique index on `(queue, unique_key)` where state is queued/running (#3); `Worker` with per-queue concurrency (#6), heartbeat + visibility timeout re-queues jobs of a crashed worker (#1), drains on shutdown. Tests `skip_locked_two_workers_never_double_run`, `retry_backoff_then_dead`, `unique_key_dedups_while_pending`, `priority_order_within_queue`, `run_at_respected_under_test_clock`, `enqueue_in_rolls_back_with_tx`, `per_queue_concurrency_limit`, `crashed_worker_job_is_requeued`. **API + schema reviewed in a design PR before E7.3/E1.3r start.**", + "kind": "manual" + }, + { + "id": "dw-da6792", + "text": "Gate: ` -p id_effect_jobs --all-features`", + "kind": "manual" + }, + { + "id": "dw-adb24a", + "text": "Owns: `crates/id_effect_jobs/src/{job.rs,queue.rs,pg.rs,memory.rs,worker.rs}`, `crates/id_effect_jobs/migrations/`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvcjv3zi-qxn0mq/v1.json b/.maestro/contracts/tsk-mvcjv3zi-qxn0mq/v1.json new file mode 100644 index 000000000..57a699ace --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv3zi-qxn0mq/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-d959a2", + "taskId": "tsk-mvcjv3zi-qxn0mq", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T17:07:34.285Z", + "lockedAt": "2026-10-17T17:07:34.285Z", + "intent": "r10-e7-4-promote", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-800e2e", + "text": "Moved to `crates/`; `id_effect_graph` and `es-entity` dependencies and the `es_entity/` module removed; `SqlJournal` ported to `SqlClient`/`SqlValue` (pg + sqlite); `append_expected(stream, expected_version, events)`; command/query handlers and projections return `Effect` (no `run_sync` in public paths); live tests fail under `ID_EFFECT_REQUIRE_PG`. Tests `expected_version_conflict`, `cqrs_dispatch_is_an_effect`.", + "kind": "manual" + }, + { + "id": "dw-f8ba4f", + "text": "Gate: ` -p id_effect_events --all-features`", + "kind": "manual" + }, + { + "id": "dw-d6677f", + "text": "Owns: `labs/id_effect_events/**` → `crates/id_effect_events/**`, `labs/Cargo.toml`, root `Cargo.toml` member line", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcjv42t-2myvtg/v1.json b/.maestro/contracts/tsk-mvcjv42t-2myvtg/v1.json new file mode 100644 index 000000000..6375c3aa0 --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv42t-2myvtg/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-24b4dd", + "taskId": "tsk-mvcjv42t-2myvtg", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T17:36:25.209Z", + "lockedAt": "2026-10-17T17:36:25.209Z", + "intent": "r10-e7-4-snapshots-subscriptions", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f3425f", + "text": "`SnapshotStore` + aggregate load = snapshot + tail (#6); global position column and `CatchUpSubscription` with a stored checkpoint, resuming after restart (#8). Tests `aggregate_loads_from_snapshot_plus_tail`, `subscription_resumes_from_checkpoint`. Chapter + example + integration test.", + "kind": "manual" + }, + { + "id": "dw-ab6e05", + "text": "Gate: same + `cargo test --doc -p book_snippets`; `bash scripts/tier-conformance.sh id_effect_events`", + "kind": "manual" + }, + { + "id": "dw-ccad75", + "text": "Owns: `crates/id_effect_events/src/{snapshot.rs,subscription.rs}`, `book/src/part5/ch23-00-events-and-projections.md` (de-labs, `SUMMARY.md:151`), `crates/id_effect_events/examples/010_account.rs`, `crates/id_effect_events/tests/pg_events.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcjv466-ezgto8/v1.json b/.maestro/contracts/tsk-mvcjv466-ezgto8/v1.json new file mode 100644 index 000000000..dcff88c25 --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv466-ezgto8/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-6dd4b5", + "taskId": "tsk-mvcjv466-ezgto8", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T17:07:34.121Z", + "lockedAt": "2026-10-17T17:07:34.121Z", + "intent": "r10-reload-config", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-566b5f", + "text": "Profile files polled by mtime; a change re-parses and publishes through a `SubscriptionRef`; an invalid change keeps the previous value and reports the errors (#5; also the strict reading of sec.config#8, G1). Tests `profile_file_change_notifies_subscribers`, `invalid_change_keeps_previous` (under the test clock).", + "kind": "manual" + }, + { + "id": "dw-5367cf", + "text": "Gate: `cargo nextest run -p id_effect_config --all-features`", + "kind": "manual" + }, + { + "id": "dw-068d4c", + "text": "Owns: `crates/id_effect_config/src/{profile.rs,watch.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcjv49j-j19atg/v1.json b/.maestro/contracts/tsk-mvcjv49j-j19atg/v1.json new file mode 100644 index 000000000..e621ea430 --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv49j-j19atg/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-7a66d1", + "taskId": "tsk-mvcjv49j-j19atg", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T17:13:33.574Z", + "lockedAt": "2026-10-17T17:13:33.574Z", + "intent": "r10-reload-rolling", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-6c6e9f", + "text": "Kamal template sets the proxy healthcheck to `/ready` and a drain timeout ≥ the server's shutdown grace; test asserts the rendered values; chapter documents the rolling sequence, citing `on_sigterm_ready_turns_503_while_an_in_flight_request_completes` (`crates/id_effect_axum/tests/health.rs:216`) as the evidence (#4).", + "kind": "manual" + }, + { + "id": "dw-493121", + "text": "Gate: `cargo nextest run -p id_effect_tool`; `cargo test --doc -p book_snippets`", + "kind": "manual" + }, + { + "id": "dw-5e6a68", + "text": "Owns: `crates/id_effect_tool/templates/service/config/deploy.yml.template`, `crates/id_effect_tool/tests/bin.rs` (one test), `book/src/part6/ch34-00-dx-ship.md` (rolling-drain section)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcjv4cx-u59do2/v1.json b/.maestro/contracts/tsk-mvcjv4cx-u59do2/v1.json new file mode 100644 index 000000000..92aba2574 --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv4cx-u59do2/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-cd7ec4", + "taskId": "tsk-mvcjv4cx-u59do2", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:43:18.054Z", + "lockedAt": "2026-10-17T15:43:18.054Z", + "intent": "r10-sqlt-mysql-query-ci", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-06a37d", + "text": "MySQL `DATETIME`/`TIMESTAMP`/`DATE`/`TIME` decode from the binary protocol into jiff; the sea-query bridge uses `with-jiff` only; `cargo tree -p id_effect_kit --all-features -e features -i sqlx` shows no `feature \"time\"` and `cargo tree -p id_effect_sql --all-features -e normal -i time` finds no match (CI step fails otherwise); MIGRATION-0.10 section \"Data: sqlx's `time` feature is off again\".", + "kind": "manual" + }, + { + "id": "dw-e1ad07", + "text": "Gate: ` -p id_effect_sql --all-features` (MySQL conformance); the CI step", + "kind": "manual" + }, + { + "id": "dw-9b4cc3", + "text": "Owns: `crates/id_effect_sql/src/mysql/value.rs`, `src/query/time_conv.rs`, `crates/id_effect_sql/Cargo.toml` (sqlx `time` `:78`, `time` dep `:68`, `query` feature `:36`, sea-query `with-time` `:93`), `.github/workflows/ci.yml` (one check step), `docs/MIGRATION-0.10.md` (section draft)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvcjv4gc-nokr7e/v1.json b/.maestro/contracts/tsk-mvcjv4gc-nokr7e/v1.json new file mode 100644 index 000000000..128c4407d --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv4gc-nokr7e/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-10ff0e", + "taskId": "tsk-mvcjv4gc-nokr7e", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:28:18.449Z", + "lockedAt": "2026-10-17T15:28:18.449Z", + "intent": "r10-sqlt-pg-wire", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-26a1ce", + "text": "`TIMESTAMPTZ`/`TIMESTAMP`/`DATE`/`TIME` bind and decode through own sqlx `Type`/`Encode`/`Decode` impls on the binary wire format (i64 µs / i32 days since 2000-01-01) into jiff; `±infinity` and out-of-range values map to `SqlError` (no panic); the conformance suite date-time cases pass unchanged; new tests `pg_wire_datetime_round_trip_bounds`, `pg_infinity_is_an_error`.", + "kind": "manual" + }, + { + "id": "dw-c6788c", + "text": "Gate: ` -p id_effect_sql --all-features`", + "kind": "manual" + }, + { + "id": "dw-a51969", + "text": "Owns: `crates/id_effect_sql/src/pg/{value.rs,datetime.rs}`, `src/value/time_conv.rs` (deleted or pg-only replacement)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvcjv4jp-snazrp/v1.json b/.maestro/contracts/tsk-mvcjv4jp-snazrp/v1.json new file mode 100644 index 000000000..017447d2d --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv4jp-snazrp/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-1c636c", + "taskId": "tsk-mvcjv4jp-snazrp", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:28:18.324Z", + "lockedAt": "2026-10-17T15:28:18.324Z", + "intent": "r10-t0-adr-skeletons", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-31c76d", + "text": "Amendment 6 names the six crates + tier (D5); each skeleton has README with a `rust` fence, one example, `#![deny(missing_docs)]`, one test, tier `integration`; `--print-order` lists `id_effect_events`, `id_effect_messaging`, `id_effect_storage` after `id_effect_sql`/`id_effect_platform`, and `id_effect_jobs` (after `id_effect_node`), `id_effect_mail` (after jobs, auth), `id_effect_console` (after jobs, authz) before `id_effect_kit`; crates.io availability of the six names recorded in the leaf evidence; `sec.authn.targets[\"0.10.0\"] == 88`.", + "kind": "manual" + }, + { + "id": "dw-094e8c", + "text": "Gate: `bash scripts/tier-conformance.sh id_effect_jobs id_effect_messaging id_effect_mail id_effect_console id_effect_storage` PASS; `bash scripts/validate-crate-packaging.sh`; `bash scripts/scorecard.sh --targets 0.10.0` prints E 74.1", + "kind": "manual" + }, + { + "id": "dw-248cb3", + "text": "Owns: `docs/adrs/0010-crate-tiers.md` (Amendment 6), root `Cargo.toml` members, `crates/id_effect_{jobs,messaging,mail,console,storage}/` skeletons, `scripts/validate-crate-packaging.sh` (`LEVEL`, `ORDER`), `.github/workflows/ci.yml` (`-p` for the new crates in the pg job at `:203` and the containers job at `:229`), `deny.toml`, `docs/scorecard/scorecard.json` (D6 target line only)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcjv4mz-pvmh4b/v1.json b/.maestro/contracts/tsk-mvcjv4mz-pvmh4b/v1.json new file mode 100644 index 000000000..d8bc9df8b --- /dev/null +++ b/.maestro/contracts/tsk-mvcjv4mz-pvmh4b/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-545f94", + "taskId": "tsk-mvcjv4mz-pvmh4b", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T19:17:11.614Z", + "lockedAt": "2026-10-17T19:17:11.614Z", + "intent": "r10-t0-k2-k6", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-a2672c", + "text": "`dx-kpis.sh` measures K2 = median incremental `cargo build` after editing one handler of a generated `service` project (method of `scripts/compile-time.sh`: wrappers unset, 3 runs) with `K2_BUDGET_S` default 8 (fails above), and K6 = cold `cargo build --release` of the same project (recorded; the budget value is written into the README per PLAN-OF-PLANS §8); `DX_KPIS_RELEASE` default 0.10; self-test covers K2/K6 parsing. Run once in wave 1 to get the baseline before the kit grows.", + "kind": "manual" + }, + { + "id": "dw-3b5283", + "text": "Gate: `bash scripts/dx-kpis.sh --self-test`; `DX_KPIS_RELEASE=0.10 bash scripts/dx-kpis.sh` prints K2", + "kind": "manual" + }, + { + "id": "dw-c8c495", + "text": "Owns: `scripts/dx-kpis.sh`, `docs/benchmarks/README.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcpexet-7d5r2f/v1.json b/.maestro/contracts/tsk-mvcpexet-7d5r2f/v1.json new file mode 100644 index 000000000..a395561be --- /dev/null +++ b/.maestro/contracts/tsk-mvcpexet-7d5r2f/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-380a8e", + "taskId": "tsk-mvcpexet-7d5r2f", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:14:19.227Z", + "lockedAt": "2026-10-17T18:14:19.227Z", + "intent": "r10-e7-1-cron", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3da639", + "text": "`CronRegistry` entries enqueue a job per tick with `unique_key = name@tick`, so N schedulers enqueue once; leader election by `ClusterSingleton` over `PgAdvisoryLease`. Tests `cron_tick_enqueues_once_across_two_schedulers`, `lease_loss_hands_over_schedule`.", + "kind": "manual" + }, + { + "id": "dw-d5d927", + "text": "Gate: ` -p id_effect_jobs --features cron`", + "kind": "manual" + }, + { + "id": "dw-891930", + "text": "Owns: `crates/id_effect_jobs/src/cron.rs` (feature `cron`, dep `id_effect_node/pg`)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcpexhy-k9wn2c/v1.json b/.maestro/contracts/tsk-mvcpexhy-k9wn2c/v1.json new file mode 100644 index 000000000..9b9a7539c --- /dev/null +++ b/.maestro/contracts/tsk-mvcpexhy-k9wn2c/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-4efb50", + "taskId": "tsk-mvcpexhy-k9wn2c", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:33:47.449Z", + "lockedAt": "2026-10-17T18:33:47.449Z", + "intent": "r10-e7-1-docs-labs", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-88112a", + "text": "Chapter + example + integration test cover #2–#6 #8; labs crate removed (no apalis/obix/job/rdkafka anywhere in `cargo metadata` of the root and labs workspaces); `grep -rniw stub crates/id_effect_jobs crates/id_effect_messaging` finds nothing; the labs CI job runs without `--exclude`. **Blocked by `r10-e7-2-outbox-inbox`** (msg.brokers#8 must exist before ObixInbox goes).", + "kind": "manual" + }, + { + "id": "dw-2faa34", + "text": "Gate: `cargo test --doc -p book_snippets`; `bash scripts/tier-conformance.sh id_effect_jobs`; labs CI job", + "kind": "manual" + }, + { + "id": "dw-170057", + "text": "Owns: `book/src/part6/ch31-00-async-messaging.md` (de-labs), `book/src/part6/ch31-01-jobs.md`, `SUMMARY.md` line, `crates/id_effect_jobs/examples/010_jobs.rs`, `crates/id_effect_jobs/tests/pg_queue.rs`, `labs/id_effect_jobs/**` (removed), `labs/Cargo.toml`, `.github/workflows/ci.yml` labs job (`:301-306`)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcpexl4-f87btw/v1.json b/.maestro/contracts/tsk-mvcpexl4-f87btw/v1.json new file mode 100644 index 000000000..50350a1a8 --- /dev/null +++ b/.maestro/contracts/tsk-mvcpexl4-f87btw/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-66740d", + "taskId": "tsk-mvcpexl4-f87btw", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:04:41.386Z", + "lockedAt": "2026-10-17T18:04:41.386Z", + "intent": "r10-e7-2-broker-api", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-525054", + "text": "`MessageBroker` per D7 (#4); `MemoryBroker` with consumer groups, redelivery and a dead-letter topic after `max_deliveries` (#5), bounded prefetch + concurrency (#6). Tests `nack_redelivers_then_dead_letters`, `prefetch_bounds_in_flight`, `two_groups_each_get_every_message`.", + "kind": "manual" + }, + { + "id": "dw-35518f", + "text": "Gate: `cargo nextest run -p id_effect_messaging`", + "kind": "manual" + }, + { + "id": "dw-68d7d3", + "text": "Owns: `crates/id_effect_messaging/src/{broker.rs,delivery.rs,memory.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcpexoh-4d52ro/v1.json b/.maestro/contracts/tsk-mvcpexoh-4d52ro/v1.json new file mode 100644 index 000000000..6c0b33a39 --- /dev/null +++ b/.maestro/contracts/tsk-mvcpexoh-4d52ro/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-5a67b2", + "taskId": "tsk-mvcpexoh-4d52ro", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:17:35.685Z", + "lockedAt": "2026-10-17T18:17:35.685Z", + "intent": "r10-e7-2-outbox-inbox", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-90e368", + "text": "`Outbox::stage(&SqlTransaction, msg)` (#7); relay claims rows with `SKIP LOCKED`, publishes, marks sent, retries with `Schedule`; `Inbox::once(&SqlTransaction, consumer, message_id, f)` dedups inside the handler's transaction (#8). Tests `outbox_row_rolls_back_with_tx`, `relay_publishes_each_row_once_across_two_relays`, `inbox_skips_duplicate_delivery`.", + "kind": "manual" + }, + { + "id": "dw-f26ff2", + "text": "Gate: ` -p id_effect_messaging --all-features`", + "kind": "manual" + }, + { + "id": "dw-7aaf2c", + "text": "Owns: `crates/id_effect_messaging/src/{outbox.rs,inbox.rs,relay.rs}`, `crates/id_effect_messaging/migrations/`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcpexrp-ffsx4n/v1.json b/.maestro/contracts/tsk-mvcpexrp-ffsx4n/v1.json new file mode 100644 index 000000000..7b73caa86 --- /dev/null +++ b/.maestro/contracts/tsk-mvcpexrp-ffsx4n/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-65b606", + "taskId": "tsk-mvcpexrp-ffsx4n", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:33:47.323Z", + "lockedAt": "2026-10-17T18:33:47.323Z", + "intent": "r10-e7-2-redis-streams", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-6866bb", + "text": "`RedisStreamsBroker: MessageBroker` — `XADD`, `XREADGROUP COUNT = prefetch`, `XACK`, `XAUTOCLAIM` of idle pending entries (redelivery), DLQ stream after `max_deliveries` (#3 #5 #6). Container tests `consumer_group_ack_and_redelivery`, `poison_message_moves_to_dlq`, `outbox_relay_to_streams_end_to_end`. Chapter + example.", + "kind": "manual" + }, + { + "id": "dw-7238f2", + "text": "Gate: ` -p id_effect_redis --features streams`; `bash scripts/tier-conformance.sh id_effect_messaging`", + "kind": "manual" + }, + { + "id": "dw-94c49f", + "text": "Owns: `crates/id_effect_redis/src/streams.rs`, `crates/id_effect_redis/Cargo.toml` (feature `streams`), `book/src/part6/ch31-02-brokers.md`, `crates/id_effect_redis/examples/030_streams.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcpexut-0na9la/v1.json b/.maestro/contracts/tsk-mvcpexut-0na9la/v1.json new file mode 100644 index 000000000..b49496084 --- /dev/null +++ b/.maestro/contracts/tsk-mvcpexut-0na9la/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-4930bc", + "taskId": "tsk-mvcpexut-0na9la", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:02:39.004Z", + "lockedAt": "2026-10-17T18:02:39.004Z", + "intent": "r10-e7-3-transports", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-44f265", + "text": "`Mailer` capability; `SmtpMailer` (lettre per D9, pooled, rustls) and `SendGridMailer` over `HttpClient` (#1); attachments and inline `cid:` images (#8); `TestMailbox` with `assert_sent_to/subject/contains` (#4). Tests: wiremock SendGrid request shape and error mapping; MIME structure of attachments/inline; `cargo deny check` green.", + "kind": "manual" + }, + { + "id": "dw-d44b73", + "text": "Gate: `cargo nextest run -p id_effect_mail --all-features`; `cargo deny check`", + "kind": "manual" + }, + { + "id": "dw-a8b5bb", + "text": "Owns: `crates/id_effect_mail/src/{message.rs,mailer.rs,smtp.rs,sendgrid.rs,testing.rs}`, `crates/id_effect_mail/Cargo.toml`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcpexxx-4ohdhl/v1.json b/.maestro/contracts/tsk-mvcpexxx-4ohdhl/v1.json new file mode 100644 index 000000000..d999d78de --- /dev/null +++ b/.maestro/contracts/tsk-mvcpexxx-4ohdhl/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-e1b585", + "taskId": "tsk-mvcpexxx-4ohdhl", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:15:45.419Z", + "lockedAt": "2026-10-17T18:15:45.419Z", + "intent": "r10-e7-3-templates-preview", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-0aa4e2", + "text": "minijinja HTML + text templates with layouts and partials (#2); `MailPreviews` router at `/_mail/previews` lists and renders registered previews, re-reading templates from disk in the dev profile (#3); Mailpit container (`axllent/mailpit` pinned, SMTP 1025, API 8025). Tests `layout_wraps_html_and_text`, `preview_index_lists_registered_mails`, container test `smtp_delivery_reaches_mailpit`.", + "kind": "manual" + }, + { + "id": "dw-2ba355", + "text": "Gate: ` -p id_effect_mail -p id_effect_test --all-features`", + "kind": "manual" + }, + { + "id": "dw-8e2cca", + "text": "Owns: `crates/id_effect_mail/src/{template.rs,preview.rs}`, `crates/id_effect_test/src/containers/mailpit.rs` (+ one `mod` line)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcpey14-2k0wis/v1.json b/.maestro/contracts/tsk-mvcpey14-2k0wis/v1.json new file mode 100644 index 000000000..2b6d3ffb2 --- /dev/null +++ b/.maestro/contracts/tsk-mvcpey14-2k0wis/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-bf7e36", + "taskId": "tsk-mvcpey14-2k0wis", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:34:51.859Z", + "lockedAt": "2026-10-17T18:34:51.859Z", + "intent": "r10-e7-3-deliver-later", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-49265e", + "text": "`deliver_later(mail)` / `deliver_later_in(&SqlTransaction, mail)` enqueue a `SendMail` job in `id_effect_jobs` with retries (#5). Test `deliver_later_sends_after_commit_only`. Chapter + example + integration test.", + "kind": "manual" + }, + { + "id": "dw-220067", + "text": "Gate: ` -p id_effect_mail --all-features`; `bash scripts/tier-conformance.sh id_effect_mail`", + "kind": "manual" + }, + { + "id": "dw-bde893", + "text": "Owns: `crates/id_effect_mail/src/later.rs` (feature `jobs`), `book/src/part6/ch31-03-mail.md`, `crates/id_effect_mail/examples/010_mail.rs`, `crates/id_effect_mail/tests/mail.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcpey4b-b89h8y/v1.json b/.maestro/contracts/tsk-mvcpey4b-b89h8y/v1.json new file mode 100644 index 000000000..3628d6db2 --- /dev/null +++ b/.maestro/contracts/tsk-mvcpey4b-b89h8y/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-58c9b3", + "taskId": "tsk-mvcpey4b-b89h8y", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T19:00:59.831Z", + "lockedAt": "2026-10-17T19:00:59.831Z", + "intent": "r10-auth8-accounts", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-285b7f", + "text": "Registration, email verification, forgot/reset password; single-use expiring tokens stored as SHA-256, compared in constant time (pattern of `src/apikey.rs`); reset re-hashes through `PasswordHasher` and revokes sessions; forgot-password answers identically for unknown accounts; `AccountNotifier` trait implemented by `id_effect_mail`. Tests `reset_token_is_single_use`, `expired_token_rejected`, `token_stored_hashed`, `forgot_password_response_is_uniform`, `reset_revokes_sessions`.", + "kind": "manual" + }, + { + "id": "dw-831261", + "text": "Gate: ` -p id_effect_auth --features accounts`; `-p id_effect_mail --features auth`", + "kind": "manual" + }, + { + "id": "dw-b5cbe7", + "text": "Owns: `crates/id_effect_auth/src/account/**` (feature `accounts` = `pg` + `axum`), `crates/id_effect_auth/migrations/` (token table), `crates/id_effect_mail/src/auth.rs` (feature `auth`), `book/src/part6/ch30-03-passwords-sessions.md` (section)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcpey7z-zm5f97/v1.json b/.maestro/contracts/tsk-mvcpey7z-zm5f97/v1.json new file mode 100644 index 000000000..2fbd73c13 --- /dev/null +++ b/.maestro/contracts/tsk-mvcpey7z-zm5f97/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-9b461a", + "taskId": "tsk-mvcpey7z-zm5f97", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:06:10.523Z", + "lockedAt": "2026-10-17T18:06:10.523Z", + "intent": "r10-e6-2-console-api", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-c62a60", + "text": "`Console::router(guard: Guard)` — no unguarded constructor; JSON under `/_console/api/`: `fibers` (#1), `supervisors` from `which_children` + `TreeNodeView` (#2), `metrics`, `health`, `log-level` GET/PUT changing the `EnvFilter` through a reload layer and the `EffectLogger` minimum level (#6). Tests `unauthorised_is_403`, `fiber_dump_lists_running_fiber`, `supervisor_tree_matches_which_children`, `log_level_change_takes_effect_without_restart`.", + "kind": "manual" + }, + { + "id": "dw-1a6409", + "text": "Gate: `cargo nextest run -p id_effect_console -p id_effect_opentelemetry --all-features`", + "kind": "manual" + }, + { + "id": "dw-2682b8", + "text": "Owns: `crates/id_effect_console/src/{lib.rs,api.rs,supervisors.rs,log_level.rs}`, `crates/id_effect_opentelemetry/src/subscriber.rs` (reload handle)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcpeyb9-t3b4bu/v1.json b/.maestro/contracts/tsk-mvcpeyb9-t3b4bu/v1.json new file mode 100644 index 000000000..fc9cb5fda --- /dev/null +++ b/.maestro/contracts/tsk-mvcpeyb9-t3b4bu/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-3f3d66", + "taskId": "tsk-mvcpeyb9-t3b4bu", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:18:37.969Z", + "lockedAt": "2026-10-17T18:18:37.969Z", + "intent": "r10-e6-2-dashboard", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-beea03", + "text": "Static page (`include_str!`, no JS toolchain, L12) polling the JSON API: fibers, supervisor tree, metrics, health, log-level form (#3). Integration test over a socket fetches `/_console` and every API route with and without the guard. Chapter + example.", + "kind": "manual" + }, + { + "id": "dw-aa7dc9", + "text": "Gate: same + `cargo test --doc -p book_snippets`; `bash scripts/tier-conformance.sh id_effect_console`", + "kind": "manual" + }, + { + "id": "dw-3b6c2d", + "text": "Owns: `crates/id_effect_console/src/ui/{index.html,mod.rs}`, `book/src/part6/ch27-03-console.md`, `crates/id_effect_console/examples/010_console.rs`, `crates/id_effect_console/tests/console.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcpeyei-jtr1wv/v1.json b/.maestro/contracts/tsk-mvcpeyei-jtr1wv/v1.json new file mode 100644 index 000000000..018d616b1 --- /dev/null +++ b/.maestro/contracts/tsk-mvcpeyei-jtr1wv/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-bb2dad", + "taskId": "tsk-mvcpeyei-jtr1wv", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:27:33.978Z", + "lockedAt": "2026-10-17T18:27:33.978Z", + "intent": "r10-e1-5-dev", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-866e78", + "text": "`id-effect dev [--services] [--watch PATH…]`: polls mtimes (D4), `cargo build`, restarts the child with `ID_EFFECT_PROFILE=dev` after SIGTERM + drain (#2, #3, fault.reload#1); a failed build keeps the old child and prints the errors; `--services` runs `docker compose up -d` once. Tests `edit_triggers_rebuild_and_restart`, `build_error_keeps_previous_child`.", + "kind": "manual" + }, + { + "id": "dw-8c287b", + "text": "Gate: `cargo nextest run -p id_effect_tool`", + "kind": "manual" + }, + { + "id": "dw-0fe81c", + "text": "Owns: `crates/id_effect_tool/src/dev.rs`, `src/cli.rs` (`Dev` variant), `crates/id_effect_tool/tests/dev.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcpeyhp-jmyzba/v1.json b/.maestro/contracts/tsk-mvcpeyhp-jmyzba/v1.json new file mode 100644 index 000000000..6308c0ec8 --- /dev/null +++ b/.maestro/contracts/tsk-mvcpeyhp-jmyzba/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-2dfbca", + "taskId": "tsk-mvcpeyhp-jmyzba", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:36:56.581Z", + "lockedAt": "2026-10-17T18:36:56.581Z", + "intent": "r10-e1-5-devcontainer", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-00ec6d", + "text": "Generated projects ship `.devcontainer/devcontainer.json` (+ compose services) (#8); template smoke builds it with `devcontainer read-configuration`-free JSON validation in the test; chapter.", + "kind": "manual" + }, + { + "id": "dw-1a8996", + "text": "Gate: `cargo nextest run -p id_effect_tool`; `bash scripts/template-smoke.sh`", + "kind": "manual" + }, + { + "id": "dw-f2aedc", + "text": "Owns: `crates/id_effect_tool/templates/common/_devcontainer/**`, `src/template.rs` (`FILES`), `crates/id_effect_tool/tests/bin.rs`, `book/src/part6/ch34-02-dev.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcpeyky-peqniz/v1.json b/.maestro/contracts/tsk-mvcpeyky-peqniz/v1.json new file mode 100644 index 000000000..cb69c2ddf --- /dev/null +++ b/.maestro/contracts/tsk-mvcpeyky-peqniz/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-d90e27", + "taskId": "tsk-mvcpeyky-peqniz", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:43:41.556Z", + "lockedAt": "2026-10-17T18:43:41.556Z", + "intent": "r10-e1-3-generate-job", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-9e086b", + "text": "`id-effect generate job [--queue Q] [--cron EXPR]` writes the `Job` impl, its registration and a test into a service project; template smoke generates one into a fresh `service` and runs `cargo test`.", + "kind": "manual" + }, + { + "id": "dw-8bcc07", + "text": "Gate: `cargo nextest run -p id_effect_tool`; `bash scripts/template-smoke.sh`", + "kind": "manual" + }, + { + "id": "dw-7300a9", + "text": "Owns: `crates/id_effect_tool/src/generate/**`, `crates/id_effect_tool/templates/generate/job/**`, `src/cli.rs` (`Generate` variant, rebased after E1.5), `scripts/template-smoke.sh`, `book/src/part6/ch34-03-generators.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcrl0u6-q95oms/v1.json b/.maestro/contracts/tsk-mvcrl0u6-q95oms/v1.json new file mode 100644 index 000000000..3783d5615 --- /dev/null +++ b/.maestro/contracts/tsk-mvcrl0u6-q95oms/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-5dd9e6", + "taskId": "tsk-mvcrl0u6-q95oms", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T19:03:11.869Z", + "lockedAt": "2026-10-17T19:03:11.869Z", + "intent": "r10-w3-kit-template", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3bc69d", + "text": "Kit features `jobs`, `jobs-cron`, `messaging`, `redis-streams`, `mail`, `console`, `storage`, `events`, `auth-accounts`; `full` updated; the service template gains opt-in (off by default) jobs + console wiring so K2 stays lean; `id_effect_kit` tier → `platform`.", + "kind": "manual" + }, + { + "id": "dw-eaaf0b", + "text": "Gate: kit tests; CI template matrix; `bash scripts/tier-conformance.sh id_effect_kit`", + "kind": "manual" + }, + { + "id": "dw-83e626", + "text": "Owns: `crates/id_effect_kit/Cargo.toml` + `src/prelude.rs`, `crates/id_effect_tool/templates/service/**`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcrl0xj-ldfumo/v1.json b/.maestro/contracts/tsk-mvcrl0xj-ldfumo/v1.json new file mode 100644 index 000000000..2fa55b83e --- /dev/null +++ b/.maestro/contracts/tsk-mvcrl0xj-ldfumo/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-ca3b4a", + "taskId": "tsk-mvcrl0xj-ldfumo", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T19:38:13.787Z", + "lockedAt": "2026-10-17T19:38:13.787Z", + "intent": "r10-w3-ledger", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-a3c058", + "text": "See below. Tier flips to `platform` for jobs, messaging, mail, console, storage.", + "kind": "manual" + }, + { + "id": "dw-fb6daa", + "text": "Gate: ` -p ledger`; `bash scripts/tier-conformance.sh` (all PASS)", + "kind": "manual" + }, + { + "id": "dw-3a5db0", + "text": "Owns: `examples/reference/ledger/**`, `.github/workflows/ci.yml` (`reference-ledger` job services: Mailpit, Redis, S3 image), tier lines of the new crates", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcrl10t-m63g70/v1.json b/.maestro/contracts/tsk-mvcrl10t-m63g70/v1.json new file mode 100644 index 000000000..6c5869c39 --- /dev/null +++ b/.maestro/contracts/tsk-mvcrl10t-m63g70/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-97a670", + "taskId": "tsk-mvcrl10t-m63g70", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T22:28:30.551Z", + "lockedAt": "2026-10-17T22:28:30.551Z", + "intent": "r10-w3-kpis", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-68eb93", + "text": "K2 ≤ 8 s, K6 recorded, K3 re-measured with the job (§8 definition), K1 on an idle machine (0.9 follow-up).", + "kind": "manual" + }, + { + "id": "dw-89eaa5", + "text": "Gate: `DX_KPIS_RELEASE=0.10 bash scripts/dx-kpis.sh`", + "kind": "manual" + }, + { + "id": "dw-b5180c", + "text": "Owns: `docs/benchmarks/dx-kpis-0.10.json`, `docs/benchmarks/k3/invoice.patch` (adds the job)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvcrl146-fp6v3u/v1.json b/.maestro/contracts/tsk-mvcrl146-fp6v3u/v1.json new file mode 100644 index 000000000..9ebb277c9 --- /dev/null +++ b/.maestro/contracts/tsk-mvcrl146-fp6v3u/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-d16fdf", + "taskId": "tsk-mvcrl146-fp6v3u", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T19:38:13.909Z", + "lockedAt": "2026-10-17T19:38:13.909Z", + "intent": "r10-w3-level-audit", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-cefb30", + "text": "For each of the 14 rows + sec.authn: every planned check id maps to an existing path at HEAD and the row has book + example + integration test; gaps become tasks.", + "kind": "manual" + }, + { + "id": "dw-b7b551", + "text": "Gate: `python3 -I .tmp/plan10/gaps.py v` compared against the evidence map", + "kind": "manual" + }, + { + "id": "dw-368489", + "text": "Owns: none (read-only)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvd9mln9-g1f798/v1.json b/.maestro/contracts/tsk-mvd9mln9-g1f798/v1.json new file mode 100644 index 000000000..e3f4e169c --- /dev/null +++ b/.maestro/contracts/tsk-mvd9mln9-g1f798/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-f8ad18", + "taskId": "tsk-mvd9mln9-g1f798", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T03:28:19.597Z", + "lockedAt": "2026-10-18T03:28:19.597Z", + "intent": "r101-http", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-c60fc4", + "text": "Closes H1, H2 and M1 as specified in the Findings table of .maestro/specs/releases/0.10.1.md, within the patch-release rules there (additive API only; stricter defaults with a named dangerous/insecure opt-out). One regression test per finding, named after it (for example h1_per_ip_limit_sees_the_peer).", + "kind": "manual" + }, + { + "id": "dw-13a0ef", + "text": "Gate: cargo test -p id_effect_axum --all-features; cargo test -p ledger (examples/reference/ledger, needs Postgres); cargo clippy -p id_effect_axum --all-features --all-targets -- -D warnings", + "kind": "manual" + }, + { + "id": "dw-145842", + "text": "Owns: crates/id_effect_axum/src/server/**, crates/id_effect_axum/src/middleware/rate_limit.rs, crates/id_effect_axum/src/client_ip.rs, crates/id_effect_axum/src/lib.rs (re-export only), crates/id_effect_axum/Cargo.toml (hyper-util features only), crates/id_effect_axum/tests/**, examples/reference/ledger/src/app.rs, examples/reference/ledger/config/**", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvd9mlqi-lnd416/v1.json b/.maestro/contracts/tsk-mvd9mlqi-lnd416/v1.json new file mode 100644 index 000000000..1c8ab528d --- /dev/null +++ b/.maestro/contracts/tsk-mvd9mlqi-lnd416/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-dd89a6", + "taskId": "tsk-mvd9mlqi-lnd416", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T03:28:19.715Z", + "lockedAt": "2026-10-18T03:28:19.715Z", + "intent": "r101-mesh", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-9efa54", + "text": "Closes H3, H4 and M9 as specified in the Findings table of .maestro/specs/releases/0.10.1.md, within the patch-release rules there (additive API only; stricter defaults with a named dangerous/insecure opt-out). One regression test per finding, named after it (for example h1_per_ip_limit_sees_the_peer).", + "kind": "manual" + }, + { + "id": "dw-efea42", + "text": "Gate: cargo test -p id_effect_node --all-features; cargo clippy -p id_effect_node --all-features --all-targets -- -D warnings", + "kind": "manual" + }, + { + "id": "dw-2976c6", + "text": "Owns: crates/id_effect_node/src/transport/**, crates/id_effect_node/src/handshake.rs, crates/id_effect_node/src/session.rs, crates/id_effect_node/src/config*.rs, crates/id_effect_node/tests/**, crates/id_effect_node/Cargo.toml (rustls features only)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvd9mltr-5gll5q/v1.json b/.maestro/contracts/tsk-mvd9mltr-5gll5q/v1.json new file mode 100644 index 000000000..180dff356 --- /dev/null +++ b/.maestro/contracts/tsk-mvd9mltr-5gll5q/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-6cf47e", + "taskId": "tsk-mvd9mltr-5gll5q", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T03:28:19.829Z", + "lockedAt": "2026-10-18T03:28:19.829Z", + "intent": "r101-auth", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-016618", + "text": "Closes H5, H6, H7, M15, M20 and L4 as specified in the Findings table of .maestro/specs/releases/0.10.1.md, within the patch-release rules there (additive API only; stricter defaults with a named dangerous/insecure opt-out). One regression test per finding, named after it (for example h1_per_ip_limit_sees_the_peer).", + "kind": "manual" + }, + { + "id": "dw-f10b15", + "text": "Gate: cargo test -p id_effect_auth --all-features (accounts tests need Postgres); cargo clippy -p id_effect_auth --all-features --all-targets -- -D warnings", + "kind": "manual" + }, + { + "id": "dw-c35a74", + "text": "Owns: crates/id_effect_auth/src/basic.rs, totp.rs, jwt.rs, jwks.rs, password.rs, new throttle.rs, account/** (login wiring only), lib.rs (re-exports only), crates/id_effect_auth/tests/**", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvd9mlx1-w6ietq/v1.json b/.maestro/contracts/tsk-mvd9mlx1-w6ietq/v1.json new file mode 100644 index 000000000..b0c209766 --- /dev/null +++ b/.maestro/contracts/tsk-mvd9mlx1-w6ietq/v1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-2618bf", + "taskId": "tsk-mvd9mlx1-w6ietq", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T03:28:19.947Z", + "lockedAt": "2026-10-18T03:28:19.947Z", + "intent": "r101-client", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-8d9eb4", + "text": "Closes M2 as specified in the Findings table of .maestro/specs/releases/0.10.1.md, within the patch-release rules there (additive API only; stricter defaults with a named dangerous/insecure opt-out). One regression test per finding, named after it (for example h1_per_ip_limit_sees_the_peer).", + "kind": "manual" + }, + { + "id": "dw-502f56", + "text": "Gate: cargo test -p id_effect_platform --all-features; cargo clippy -p id_effect_platform --all-features --all-targets -- -D warnings", + "kind": "manual" + }, + { + "id": "dw-dc8861", + "text": "Owns: crates/id_effect_platform/src/http/**, crates/id_effect_platform/tests/**", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvdf5hvl-qkcn8e/v1.json b/.maestro/contracts/tsk-mvdf5hvl-qkcn8e/v1.json new file mode 100644 index 000000000..7bfe16968 --- /dev/null +++ b/.maestro/contracts/tsk-mvdf5hvl-qkcn8e/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-101f8f", + "taskId": "tsk-mvdf5hvl-qkcn8e", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:02:53.657Z", + "lockedAt": "2026-10-18T06:02:53.657Z", + "intent": "r11-sec-adr0013", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-38ce77", + "text": "docs/adrs/0013-secure-by-default.md (status Proposed; the owner moves it to Accepted, 0.11.0.md D22) states the ten rules R1–R10 exactly as the 0.11.0.md table \"ADR 0013 — the ten rules\", each with the findings it closes, its enforcement point(s) and the leaf that implements it; it defines \"closed\" for a finding as \"the default path is safe and every escape is a named, recorded dangerous_*/insecure_* opt-out that fails the prod posture check\" (pending the owner's answer to Open question 1).", + "kind": "manual" + }, + { + "id": "dw-c66c50", + "text": "id_effect::observability::posture: record_dangerous(id: &'static str, reopens: &'static str) records an opt-out once per id, process-wide; dangerous_opt_outs() lists them; no cost on paths that never call it; documented as the hook every dangerous_*/insecure_* API in the workspace calls.", + "kind": "manual" + }, + { + "id": "dw-35f848", + "text": "Tests `posture_is_empty_by_default`, `posture_records_each_opt_out_once`, `posture_lists_reason_text`.", + "kind": "manual" + }, + { + "id": "dw-da3eb0", + "text": "Gate: `cargo nextest run -p id_effect --test posture`; `cargo nextest run -p id_effect --test kernel_alloc`", + "kind": "manual" + }, + { + "id": "dw-f1b0b8", + "text": "Owns: `docs/adrs/0013-secure-by-default.md`, `crates/id_effect/src/observability/{posture.rs,mod.rs}`, `crates/id_effect/tests/posture.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvdfbkr7-8wkwg5/v1.json b/.maestro/contracts/tsk-mvdfbkr7-8wkwg5/v1.json new file mode 100644 index 000000000..6446b31ef --- /dev/null +++ b/.maestro/contracts/tsk-mvdfbkr7-8wkwg5/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-e952b5", + "taskId": "tsk-mvdfbkr7-8wkwg5", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:07:37.440Z", + "lockedAt": "2026-10-18T06:07:37.440Z", + "intent": "r11-t0-skeletons", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-cd36c7", + "text": "ADR 0010 Amendment 7 (after Amendment 6, docs/adrs/0010-crate-tiers.md:182) names: id_effect_workflow (published, tier integration; replaces labs/id_effect_workflow, which r11-e7-5-sagas-promote deletes); STM moving from id_effect_fp (maintenance) into id_effect core with a one-minor deprecated re-export in id_effect_fp (L9, 0.11.0.md D5); the unpublished test crate tests/security_compile_fail (publish = false).", + "kind": "manual" + }, + { + "id": "dw-f69948", + "text": "Root Cargo.toml members gain crates/id_effect_workflow and tests/security_compile_fail; scripts/validate-crate-packaging.sh LEVEL/ORDER: `--print-order` lists id_effect_workflow after id_effect_sql and id_effect_jobs and before id_effect_kit; the security test crate is never published.", + "kind": "manual" + }, + { + "id": "dw-9e7680", + "text": "crates/id_effect_workflow skeleton: Cargo.toml on the workspace version, README with a `rust` fence, `#![deny(missing_docs)]`, one example, one test, `[package.metadata.id_effect] tier = \"integration\"`; crates.io availability of `id_effect_workflow` recorded in the leaf evidence (generic User-Agent, no personal data).", + "kind": "manual" + }, + { + "id": "dw-d266e1", + "text": "docs/MIGRATION-0.11.md created with one heading per planned break (H8 routing, Sql/RawSql, process Next/Info/SendError, RedactionPolicy::new, Secret and its equality, STM path, SSRF default, plaintext TCP feature, CSRF masked token format, mesh protocol version); each owning leaf fills its section.", + "kind": "manual" + }, + { + "id": "dw-e76ae5", + "text": "Gate: `bash scripts/tier-conformance.sh id_effect_workflow`; `bash scripts/validate-crate-packaging.sh`; `cargo nextest run -p id_effect_workflow -p security_compile_fail`", + "kind": "manual" + }, + { + "id": "dw-e8ab71", + "text": "Owns: `docs/adrs/0010-crate-tiers.md`, root `Cargo.toml` (members), `crates/id_effect_workflow/**` (skeleton only; r11-e7-5-engine owns it next), `tests/security_compile_fail/**` (skeleton only; r11-e11-4-compile-fail owns it in wave 3), `scripts/validate-crate-packaging.sh`, `docs/MIGRATION-0.11.md` (headings)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvdfoc36-i9tzlz/v1.json b/.maestro/contracts/tsk-mvdfoc36-i9tzlz/v1.json new file mode 100644 index 000000000..28ec248fa --- /dev/null +++ b/.maestro/contracts/tsk-mvdfoc36-i9tzlz/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-de4276", + "taskId": "tsk-mvdfoc36-i9tzlz", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:17:32.599Z", + "lockedAt": "2026-10-18T06:17:32.599Z", + "intent": "r11-sec-logging", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-c8eebb", + "text": "M11: a pipeline with no explicit policy applies RedactionPolicy::recommended() — case-insensitive fields password, passwd, secret, token, access_token, refresh_token, id_token, api_key, apikey, authorization, cookie, set-cookie, session, csrf, private_key, client_secret and globs *_token, *_secret, *password*; RedactionPolicy::new() (crates/id_effect_logger/src/redact.rs:59-67, redacts nothing) is deprecated for RedactionPolicy::empty(); RedactionPolicy::dangerous_none() records a posture opt-out. MIGRATION-0.11 section.", + "kind": "manual" + }, + { + "id": "dw-9ec617", + "text": "M12: the tracing-line formatter (pipeline.rs:168-181) escapes CR, LF, other control characters and ANSI escapes in messages and values, escapes spaces and = in keys and quotes values containing spaces; the structured backend (pipeline.rs:294-309) escapes keys.", + "kind": "manual" + }, + { + "id": "dw-3c0aba", + "text": "Regression tests `m11_default_pipeline_redacts_common_secret_fields`, `m11_empty_policy_is_explicit`, `m12_newline_in_message_cannot_forge_a_line`, `m12_annotation_key_cannot_inject_fields`, `m12_control_and_ansi_sequences_are_escaped`.", + "kind": "manual" + }, + { + "id": "dw-65d956", + "text": "Gate: `cargo nextest run -p id_effect_logger --all-features`", + "kind": "manual" + }, + { + "id": "dw-08cd86", + "text": "Owns: `crates/id_effect_logger/src/{redact.rs,pipeline.rs,lib.rs}`, `crates/id_effect_logger/tests/{pipeline.rs,security.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvdfsdm7-rk163d/v1.json b/.maestro/contracts/tsk-mvdfsdm7-rk163d/v1.json new file mode 100644 index 000000000..cf45b169b --- /dev/null +++ b/.maestro/contracts/tsk-mvdfsdm7-rk163d/v1.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 2, + "id": "c-6fdef8", + "taskId": "tsk-mvdfsdm7-rk163d", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:20:41.239Z", + "lockedAt": "2026-10-18T06:20:41.239Z", + "intent": "r11-m22-rebase", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-9cdbaf", + "text": "Step 1 (non-goals leave the score, every framework): METHOD.md §3.3 rewritten — a `non-goal` status is set only by a locked decision; a non-goal row gets weight 0 and leaves its group mean, U, E, RT and the row KPIs; a non-goal check leaves its row checklist and the row scores clamp(1, cap(L), round(100·m/(n−k))). scorecard.json marks row `web.graphql` (`non_goal: {decision: \"L15\"}`) and checks `fault.reload#2`, `#6`, `#7`, `dx.compile#3`, `dx.repl#3` (L8), `web.ui#3`, `#4` (L7), `data.backends#4`, `#5` (L6). `conc.safety#8` and `obs.tracing#8` stay scored. scorecard.schema.json and `scripts/scorecard.sh --validate` accept the field and reject a non-goal without a decision id.", + "kind": "manual" + }, + { + "id": "dw-35872c", + "text": "Step 2 (.NET scope): frameworks[dotnet].scope adds Orleans and the Durable Task Framework as first-party Microsoft; every .NET cell that alignment-2.0.json classifies `recommended` for Orleans/Durable Task becomes first-party; every fault, dist, conc and msg row is re-checked for checks Orleans/DTF meet that the snapshot did not credit, scored by the third-party rule (PLAN-OF-PLANS §7.0 step 1); results with per-cell evidence in docs/scorecard/alignment-2.2.json; METHOD §8 logs it as a scope change (snapshot date unchanged); the leaf evidence states .NET U/E/RT before and after.", + "kind": "manual" + }, + { + "id": "dw-67128c", + "text": "Step 3 (bump to 2.2): method_version \"2.2\"; every stored version 0.6.0–0.10.0 re-scored; docs/scorecard/results/*.json, REPORT-method-v2.md and index.html regenerated; METHOD §5.2 restated with the 2.2 table and the 2.1 table kept as history; new fixtures fixtures/non-goal-row.json and fixtures/non-goal-check.json with expected outputs, run by --self-test.", + "kind": "manual" + }, + { + "id": "dw-2a200c", + "text": "Targets: web.rpc and web.realtime 0.11.0 targets move to 0.12.0 (L15); bonus 0.11.0 targets conc.stm 89, sec.supply 100, test.qa 100 (0.11.0.md D1); web.graphql targets removed.", + "kind": "manual" + }, + { + "id": "dw-0b47e5", + "text": "PLAN-OF-PLANS: §1 north-star rows and every \"re-based\" §6 cell restated from `scripts/scorecard.sh --targets ` under 2.2; the §6 note \"E 73.3 at 0.11\" replaced; the dx.repl#3 effect (id_effect 75 → 71 on dx.repl, 0.11.0.md D3) recorded next to the numbers; the 0.11.0.md frontmatter `scorecard:` block and its \"Capacity and index effect\" table replaced by tool output.", + "kind": "manual" + }, + { + "id": "dw-72dac0", + "text": "Step 4: the owner acceptance of the re-based numbers is recorded in PLAN-OF-PLANS §11 with a date. Until it is recorded the leaf is done-pending-acceptance and 0.11.0.md keeps `provisional: true`; this leaf never records acceptance on the owner's behalf.", + "kind": "manual" + }, + { + "id": "dw-817b32", + "text": "Gate: `bash scripts/scorecard.sh --validate --self-test`; `bash scripts/scorecard.sh --method-compare && git diff --exit-code docs/scorecard/REPORT-method-v2.md`; `bash scripts/scorecard.sh --version 0.10.0`; `bash scripts/scorecard.sh --targets 0.11.0` prints the numbers written into 0.11.0.md", + "kind": "manual" + }, + { + "id": "dw-2e6ac9", + "text": "Owns: `docs/scorecard/{METHOD.md,scorecard.json,scorecard.schema.json,alignment-2.2.json,fixtures/**,results/*.json,REPORT-method-v2.md,index.html}`, `scripts/scorecard.sh`, `.maestro/specs/roadmap/PLAN-OF-PLANS.md` (§1, §6, §11), `.maestro/specs/releases/0.11.0.md` (frontmatter `scorecard:` block and the \"Capacity and index effect\" table only)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvdftqvl-77wo59/v1.json b/.maestro/contracts/tsk-mvdftqvl-77wo59/v1.json new file mode 100644 index 000000000..4cdffb198 --- /dev/null +++ b/.maestro/contracts/tsk-mvdftqvl-77wo59/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-1ba8bb", + "taskId": "tsk-mvdftqvl-77wo59", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:21:45.103Z", + "lockedAt": "2026-10-18T06:21:45.103Z", + "intent": "r11-e8-1-mailbox", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-184026", + "text": "M10 (0.11.0.md D17): every Mailbox is bounded (crates/id_effect_process/src/mailbox.rs:41 today says \"Unbounded\"), default 10,000 user messages, configurable per spawn; system messages (exit signals, monitor downs, kill) bypass the bound; a full mailbox makes local sends fail with SendError::Full (SendError becomes #[non_exhaustive], msg.rs:114) and Addr::send_now return false (addr.rs:66-71); remote delivery to a full mailbox (crates/id_effect_node/src/registry.rs:208,220,234) faults the remote call and drops + counts a remote cast.", + "kind": "manual" + }, + { + "id": "dw-0f4501", + "text": "fault.actors#7 selective receive: ProcessCtx::receive(matcher, timeout) returns the first message the matcher accepts and keeps every skipped message in arrival order for the normal loop; the scan is bounded by the mailbox capacity; a timeout yields a typed error.", + "kind": "manual" + }, + { + "id": "dw-4d1950", + "text": "Regression tests `m10_bounded_mailbox_rejects_when_full`, `m10_remote_send_to_full_mailbox_faults`, `m10_system_messages_bypass_bound`; feature tests `selective_receive_keeps_order_of_skipped_messages`, `selective_receive_times_out`. MIGRATION-0.11 section for SendError.", + "kind": "manual" + }, + { + "id": "dw-e90c4e", + "text": "Gate: `cargo nextest run -p id_effect_process -p id_effect_node --all-features`; `cargo clippy -p id_effect_process --all-targets --all-features -- -D warnings`", + "kind": "manual" + }, + { + "id": "dw-6217ef", + "text": "Owns: `crates/id_effect_process/src/{mailbox.rs,addr.rs,msg.rs,node.rs,lib.rs}`, `crates/id_effect_node/src/registry.rs`, `crates/id_effect_process/tests/{mailbox_bounds.rs,selective_receive.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvdfwlxi-vkqc20/v1.json b/.maestro/contracts/tsk-mvdfwlxi-vkqc20/v1.json new file mode 100644 index 000000000..c776ce481 --- /dev/null +++ b/.maestro/contracts/tsk-mvdfwlxi-vkqc20/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-bb0c59", + "taskId": "tsk-mvdfwlxi-vkqc20", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:23:59.183Z", + "lockedAt": "2026-10-18T06:23:59.183Z", + "intent": "r11-e2-5-stm-core", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-ae2d4b", + "text": "id_effect::stm exposes Stm, TRef, TMap, TQueue, TSemaphore, Txn, Outcome, atomically and commit with unchanged semantics (moved from crates/id_effect_fp/src/stm/mod.rs, which depends only on id_effect::{Effect, from_async} at :38 and std); always compiled, no new dependencies (0.11.0.md D5); the global commit lock (:50) is unchanged.", + "kind": "manual" + }, + { + "id": "dw-b7abda", + "text": "id_effect_fp::stm::* re-exports the core items with #[deprecated(note = \"use id_effect::stm\")] for one minor (L9); the re-export at crates/id_effect_fp/src/lib.rs:77 is kept and deprecated; the core crate doc (crates/id_effect/src/lib.rs:30-31) no longer says STM lives in id_effect_fp.", + "kind": "manual" + }, + { + "id": "dw-8ed8c2", + "text": "Examples 077-086_stm_*.rs and tests/stm_contention.rs move to crates/id_effect and run there; book part4/ch12-00..03 point at id_effect::stm; MIGRATION-0.11 section \"STM path\".", + "kind": "manual" + }, + { + "id": "dw-791fc1", + "text": "The kernel is unchanged: crates/id_effect/tests/kernel_alloc.rs and the core bench gates pass.", + "kind": "manual" + }, + { + "id": "dw-5992ea", + "text": "Gate: `cargo nextest run -p id_effect -p id_effect_fp --all-features`; `cargo nextest run -p id_effect --test kernel_alloc`; `cargo test -p id_effect --examples`; `bash scripts/bench-compare.sh` (core gates)", + "kind": "manual" + }, + { + "id": "dw-5de298", + "text": "Owns: `crates/id_effect/src/stm/**`, `crates/id_effect/src/lib.rs`, `crates/id_effect/Cargo.toml`, `crates/id_effect/examples/077-086_stm_*.rs`, `crates/id_effect/tests/stm_contention.rs`, `crates/id_effect_fp/src/{stm/**,lib.rs}`, `crates/id_effect_fp/examples/077-086_stm_*.rs` (deleted), `crates/id_effect_fp/tests/stm_contention.rs` (deleted), `crates/id_effect/book/src/part4/ch12-0{0,1,2,3}-*.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvdgoa0t-18vdc1/v1.json b/.maestro/contracts/tsk-mvdgoa0t-18vdc1/v1.json new file mode 100644 index 000000000..08b4c4c35 --- /dev/null +++ b/.maestro/contracts/tsk-mvdgoa0t-18vdc1/v1.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 2, + "id": "c-616b6d", + "taskId": "tsk-mvdgoa0t-18vdc1", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:45:30.008Z", + "lockedAt": "2026-10-18T06:45:30.008Z", + "intent": "r11-sec-tenancy", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-a8613c", + "text": "M19: when the request has a current tenant, resource policies built for a Tenanted resource (crates/id_effect_authz/src/resource.rs, Tenanted/same_tenant are opt-in today) include the tenant match automatically; crossing tenants needs cross_tenant_dangerous(reason), which records a posture opt-out; resources that are not Tenanted are unaffected.", + "kind": "manual" + }, + { + "id": "dw-9ad85d", + "text": "Regression tests `m19_tenanted_resource_requires_tenant_match_by_default`, `m19_cross_tenant_requires_named_opt_out`, `m19_untenanted_resource_unaffected`; book part6/ch30-07-audit-tenancy.md section.", + "kind": "manual" + }, + { + "id": "dw-3e33af", + "text": "Gate: `cargo nextest run -p id_effect_authz --all-features`", + "kind": "manual" + }, + { + "id": "dw-cb5208", + "text": "Owns: `crates/id_effect_authz/src/{resource.rs,policy.rs,lib.rs}`, `crates/id_effect_authz/tests/tenancy.rs`, `crates/id_effect/book/src/part6/ch30-07-audit-tenancy.md` (section)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvdgtk7t-ly5lul/v1.json b/.maestro/contracts/tsk-mvdgtk7t-ly5lul/v1.json new file mode 100644 index 000000000..657b25f1d --- /dev/null +++ b/.maestro/contracts/tsk-mvdgtk7t-ly5lul/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-2136ca", + "taskId": "tsk-mvdgtk7t-ly5lul", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:49:36.067Z", + "lockedAt": "2026-10-18T06:49:36.067Z", + "intent": "r11-sec-sql-redis", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-046de0", + "text": "M16 (0.11.0.md D9): the effect-level query and execute (crates/id_effect_sql/src/client.rs:195, :207, today impl Into) take impl Into, where Sql: From<&'static str>; string literals still compile and a String does not; query_raw and execute_raw take RawSql and need RawSqlCapability in R, provided only by RawSqlCapability::layer_dangerous() (records a posture opt-out; the capability has no public constructor). Internal callers that build SQL (repository, migrate, query builder) use a crate-private trusted constructor. The SqlClient trait's &str methods stay as the driver boundary. MIGRATION-0.11 section \"Sql/RawSql\".", + "kind": "manual" + }, + { + "id": "dw-6575fc", + "text": "M17: clear() on a Redis store whose namespace is empty (crates/id_effect_redis/src/client.rs:355-367 turns \"\" into the glob \"*\"; store.rs:90-91, :287, :423, :569) returns RedisError::EmptyNamespace; the explicit flush is clear_all_dangerous() (records a posture opt-out).", + "kind": "manual" + }, + { + "id": "dw-f1ed0d", + "text": "Regression tests `m16_query_with_string_does_not_compile` (trybuild, crates/id_effect_sql/tests/ui), `m16_query_raw_requires_capability` (trybuild), `m16_literal_query_still_compiles`, `m17_clear_with_empty_namespace_is_an_error`, `m17_clear_only_deletes_namespace_keys` (Docker Redis).", + "kind": "manual" + }, + { + "id": "dw-d85695", + "text": "Gate: `cargo nextest run -p id_effect_sql --test ui`; `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p id_effect_sql --all-features` (Postgres required); `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p id_effect_redis --all-features` (Docker required); `cargo build --workspace --all-targets`", + "kind": "manual" + }, + { + "id": "dw-57b143", + "text": "Owns: `crates/id_effect_sql/**` (incl. new `src/raw.rs` and `tests/ui/m16_*`), `crates/id_effect_redis/src/{store.rs,client.rs}`, `crates/id_effect_redis/tests/store_clear.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvdgvhpd-9mxdxa/v1.json b/.maestro/contracts/tsk-mvdgvhpd-9mxdxa/v1.json new file mode 100644 index 000000000..254e7c875 --- /dev/null +++ b/.maestro/contracts/tsk-mvdgvhpd-9mxdxa/v1.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 2, + "id": "c-868c33", + "taskId": "tsk-mvdgvhpd-9mxdxa", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:51:06.076Z", + "lockedAt": "2026-10-18T06:51:06.076Z", + "intent": "r11-e7-5-engine", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-17a5ee", + "text": "Design PR first: the Workflow trait, WorkflowContext and the Postgres schema are reviewed before r11-e7-5-activities-timers starts (the PR link is the first evidence item).", + "kind": "manual" + }, + { + "id": "dw-630b84", + "text": "Workflow trait (Schema-encoded input and output, name, a version string recorded on each instance); WorkflowContext::step(name, effect) journals each result and on resume returns the journaled result without re-running the step (fault.workflows#2); ctx.now() and ctx.random() are journaled (0.11.0.md D7); a replay whose step name or order differs from the history fails with a typed NonDeterministic error.", + "kind": "manual" + }, + { + "id": "dw-fef57b", + "text": "Postgres schema (migrations/0001): workflow_instances (id, name, status, input, output, version, lease_owner, lease_expires_at, fencing_token) and workflow_history (instance_id, seq, kind, payload, unique (instance_id, seq)); state survives a process restart (#1).", + "kind": "manual" + }, + { + "id": "dw-f210e2", + "text": "Multi-writer (D7): N engines run against one database; an instance advances only under its lease; every history append checks the fencing token, so a stale writer whose lease expired cannot append; an expired lease is taken over and the instance resumes from history on another engine.", + "kind": "manual" + }, + { + "id": "dw-2476e8", + "text": "Built on id_effect_sql SqlClient/SqlTransaction (0.11.0.md D6; no duroxide, no SQLite store); MemoryWorkflowStore for unit tests; start, status and result APIs.", + "kind": "manual" + }, + { + "id": "dw-90859f", + "text": "Tests `replay_returns_journaled_results_without_rerunning_steps`, `two_engines_never_advance_one_instance_concurrently`, `stale_writer_append_is_rejected_by_fencing`, `crashed_engine_instance_resumes_elsewhere`, `nondeterministic_replay_is_detected`.", + "kind": "manual" + }, + { + "id": "dw-a435dd", + "text": "Gate: `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p id_effect_workflow --all-features` (Postgres required)", + "kind": "manual" + }, + { + "id": "dw-ad5818", + "text": "Owns: `crates/id_effect_workflow/Cargo.toml`, `crates/id_effect_workflow/src/{lib,workflow,engine,history,lease,memory,pg,error}.rs`, `crates/id_effect_workflow/migrations/0001_*`, `crates/id_effect_workflow/tests/{engine_pg.rs,replay.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvdh79dx-xb500e/v1.json b/.maestro/contracts/tsk-mvdh79dx-xb500e/v1.json new file mode 100644 index 000000000..33d3447bb --- /dev/null +++ b/.maestro/contracts/tsk-mvdh79dx-xb500e/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-84f283", + "taskId": "tsk-mvdh79dx-xb500e", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T07:00:15.424Z", + "lockedAt": "2026-10-18T07:00:15.424Z", + "intent": "r11-fuzz-ci", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-a27384", + "text": "A fuzz/ cargo-fuzz workspace (outside the main workspace, one pinned nightly date, 0.11.0.md D20) with at least 8 targets over public decoders whose input formats do not change in 0.11, chosen from: Schema decode from JSON (id_effect), typed-route path and query decode and form-body decode (id_effect_axum route/decode.rs), cron expression parse (id_effect scheduling), config profile TOML merge (id_effect_config), JWT header/claims decode (id_effect_auth jwt), JWKS document parse (id_effect_auth jwks), otpauth URI parse (id_effect_auth totp). The implementer confirms each target reaches only public API.", + "kind": "manual" + }, + { + "id": "dw-5390d6", + "text": "Seed corpora committed; .clusterfuzzlite/ (Dockerfile, build.sh, project.yaml); .github/workflows/cflite_pr.yml (PR mode, 5 minutes per target, fails on a new crash) and cflite_batch.yml (nightly batch with stored corpus), SHA-pinned with top-level permissions read; docs/security/fuzzing.md (how to run, how a crash becomes an issue and a regression test).", + "kind": "manual" + }, + { + "id": "dw-7740c2", + "text": "KPI: `ls fuzz/fuzz_targets/*.rs | wc -l` ≥ 8 at this leaf (≥ 10 after r11-e8-3-reliable-delivery adds mesh_frame and mesh_envelope); no open crash at exit.", + "kind": "manual" + }, + { + "id": "dw-d37e3f", + "text": "Gate: `cd fuzz && cargo +nightly- fuzz build`; `for t in $(cargo +nightly- fuzz list); do cargo +nightly- fuzz run $t -- -max_total_time=60; done` with no crash; `bash scripts/check-workflows.sh`", + "kind": "manual" + }, + { + "id": "dw-493192", + "text": "Owns: `fuzz/**`, `.clusterfuzzlite/**`, `.github/workflows/{cflite_pr.yml,cflite_batch.yml}`, `docs/security/fuzzing.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvdh8or6-37dwa9/v1.json b/.maestro/contracts/tsk-mvdh8or6-37dwa9/v1.json new file mode 100644 index 000000000..570146203 --- /dev/null +++ b/.maestro/contracts/tsk-mvdh8or6-37dwa9/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-f62814", + "taskId": "tsk-mvdh8or6-37dwa9", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T07:01:21.740Z", + "lockedAt": "2026-10-18T07:01:21.740Z", + "intent": "r11-e8-3-global-registry", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-942742", + "text": "dist.placement#5: GlobalRegistry with register(name, pid), whereis(name), unregister(name), resolvable from every node (today registry.rs:150 registers behaviours, not processes); an entry is removed when its process exits or its node goes down; registering a taken name fails with AlreadyRegistered.", + "kind": "manual" + }, + { + "id": "dw-5b34b4", + "text": "After a partition heals, conflicting registrations resolve deterministically to one owner (documented rule, e.g. lowest (incarnation, node id)); the losing process receives an exit signal with reason NameConflict; an Addr can be obtained by global name.", + "kind": "manual" + }, + { + "id": "dw-5705ea", + "text": "Tests `global_name_resolves_from_every_node`, `name_released_on_process_exit`, `name_released_on_node_down`, `conflicting_registration_after_heal_keeps_one`, `register_twice_fails_with_already_registered`; example 135_global_names.rs; book part7/ch39-01-global-registry.md.", + "kind": "manual" + }, + { + "id": "dw-4109cf", + "text": "Gate: `cargo nextest run -p id_effect_node --all-features --test global_registry`; `cargo nextest run -p id_effect_node --all-features`", + "kind": "manual" + }, + { + "id": "dw-844e19", + "text": "Owns: `crates/id_effect_node/src/{global_registry.rs,lib.rs,envelope.rs,mesh.rs}`, `crates/id_effect_node/tests/global_registry.rs`, `crates/id_effect_node/examples/135_global_names.rs`, `crates/id_effect/book/src/part7/ch39-01-global-registry.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvdhyh5g-5yq01o/v1.json b/.maestro/contracts/tsk-mvdhyh5g-5yq01o/v1.json new file mode 100644 index 000000000..431660ea9 --- /dev/null +++ b/.maestro/contracts/tsk-mvdhyh5g-5yq01o/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-d54700", + "taskId": "tsk-mvdhyh5g-5yq01o", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T07:21:25.991Z", + "lockedAt": "2026-10-18T07:21:25.991Z", + "intent": "r11-sc-sast-scorecard", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-d412d1", + "text": "S4 (SAST, 0.11.0.md D14): .github/workflows/codeql.yml runs CodeQL for Rust and GitHub Actions on push, pull request and weekly, uploads SARIF, SHA-pinned, top-level permissions read; no open high-severity alert at exit.", + "kind": "manual" + }, + { + "id": "dw-029502", + "text": ".github/workflows/scorecard.yml runs ossf/scorecard-action weekly and on push to main with publish_results: true; the README shows the Scorecard badge.", + "kind": "manual" + }, + { + "id": "dw-9de37b", + "text": "docs/security/openssf.md lists every Scorecard check with its expected score, the evidence, and the owner actions it needs (Branch-Protection, Code-Review, Signed-Releases, Token-Permissions; 0.11.0.md D15), adding up to ≥ 7.5; it also holds the drafted OpenSSF Best Practices (bestpractices.dev) Passing answers with evidence links. The owner submits the questionnaire (D16); the badge goes in the README once Passing.", + "kind": "manual" + }, + { + "id": "dw-eecce6", + "text": "Gate: `bash scripts/check-workflows.sh`; `actionlint .github/workflows/codeql.yml .github/workflows/scorecard.yml`; the Scorecard score from the workflow run (or `scorecard --repo=github.com/Industrial/id_effect`) recorded at exit, ≥ 7.5", + "kind": "manual" + }, + { + "id": "dw-af71ed", + "text": "Owns: `.github/workflows/{codeql.yml,scorecard.yml}`, `README.md` (badges line), `docs/security/openssf.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvdhzjr6-6xpm3i/v1.json b/.maestro/contracts/tsk-mvdhzjr6-6xpm3i/v1.json new file mode 100644 index 000000000..6b5aa2dc8 --- /dev/null +++ b/.maestro/contracts/tsk-mvdhzjr6-6xpm3i/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-5e5f1b", + "taskId": "tsk-mvdhzjr6-6xpm3i", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T07:22:15.120Z", + "lockedAt": "2026-10-18T07:22:15.120Z", + "intent": "r11-e1-9-dylint", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f5122b", + "text": "labs/id_effect_lint (publish = false at Cargo.toml:2, nightly rustc_private at :22) moves to lints/id_effect_lints, outside the main workspace with its own rust-toolchain.toml (pinned nightly); the rustc lint plugins are ported to dylint_linting; the ast-grep rules stay usable (sgconfig.yml).", + "kind": "manual" + }, + { + "id": "dw-a848aa", + "text": "dx.lint#3: installable as a package — published to crates.io as id_effect_lints (r11-sc-publish adds the publish step; crate name availability recorded) and loadable through `[workspace.metadata.dylint] libraries = [{ git = ..., tag = ... }]`; #4: runs through `cargo dylint` (the clippy-style driver); #5: at least three lints emit MachineApplicable suggestions that `cargo dylint --fix` applies.", + "kind": "manual" + }, + { + "id": "dw-654636", + "text": "New lint raw_sql_non_literal warns when a SqlClient query/execute (&str) receives a non-literal (0.11.0.md D9, ADR 0013 R5 second enforcement point).", + "kind": "manual" + }, + { + "id": "dw-f3be79", + "text": "dylint_testing UI tests per lint; .github/workflows/lint.yml runs `cargo dylint --all` on the workspace (SHA-pinned, permissions read); book appendix-d-workspace-tooling.md documents install and use; level labs → documented (5/8 + #5 = 6/8 planned, target 63).", + "kind": "manual" + }, + { + "id": "dw-7a35e1", + "text": "Gate: `cd lints/id_effect_lints && cargo test`; `cargo dylint --path lints/id_effect_lints --all -- --workspace`; `bash scripts/check-workflows.sh`", + "kind": "manual" + }, + { + "id": "dw-5855e3", + "text": "Owns: `lints/id_effect_lints/**`, `labs/id_effect_lint/**` (deleted), `.github/workflows/lint.yml`, `crates/id_effect/book/src/appendix-d-workspace-tooling.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvdi4g74-udxxs3/v1.json b/.maestro/contracts/tsk-mvdi4g74-udxxs3/v1.json new file mode 100644 index 000000000..f7666c17b --- /dev/null +++ b/.maestro/contracts/tsk-mvdi4g74-udxxs3/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-c68bf3", + "taskId": "tsk-mvdi4g74-udxxs3", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T07:26:04.112Z", + "lockedAt": "2026-10-18T07:26:04.112Z", + "intent": "r11-e7-5-activities-timers", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-ba9a39", + "text": "fault.workflows#7: activities run as id_effect_jobs jobs (crates/id_effect_jobs/src/job.rs:26 Job, queue.rs:342 JobQueue) on a named queue, retried by a core Schedule, executed at least once by workers on any node, with idempotency key (instance id, seq) so a duplicate completion is ignored; the result is appended to the history.", + "kind": "manual" + }, + { + "id": "dw-e2aabc", + "text": "fault.workflows#4: ctx.sleep(d) and ctx.sleep_until(t) create durable timers fired through the job queue run_at (one job per timer, unique key); a timer survives an engine restart; waits of days complete under the test clock.", + "kind": "manual" + }, + { + "id": "dw-d6a61a", + "text": "fault.workflows#5: WorkflowClient::signal(instance, name, payload) stores the signal; ctx.wait_signal(name) receives signals in arrival order; a signal sent before the wait is buffered.", + "kind": "manual" + }, + { + "id": "dw-ab9f20", + "text": "Tests `activity_retried_until_success_then_journaled`, `activity_runs_at_least_once_after_worker_crash`, `activity_result_dedup_by_idempotency_key`, `durable_timer_survives_engine_restart`, `timer_of_days_under_test_clock`, `signal_before_wait_is_buffered`, `signals_delivered_in_order`.", + "kind": "manual" + }, + { + "id": "dw-664853", + "text": "Gate: `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p id_effect_workflow --all-features` (Postgres required)", + "kind": "manual" + }, + { + "id": "dw-4c4811", + "text": "Owns: `crates/id_effect_workflow/src/{activity,timer,signal,lib}.rs`, `crates/id_effect_workflow/Cargo.toml` (id_effect_jobs dependency), `crates/id_effect_workflow/migrations/0002_*`, `crates/id_effect_workflow/tests/{activities_pg,timers_pg,signals_pg}.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mveorm3d-m113ii/v1.json b/.maestro/contracts/tsk-mveorm3d-m113ii/v1.json new file mode 100644 index 000000000..7e47b2c95 --- /dev/null +++ b/.maestro/contracts/tsk-mveorm3d-m113ii/v1.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-8d23e8", + "taskId": "tsk-mveorm3d-m113ii", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:19:53.422Z", + "lockedAt": "2026-10-19T03:19:53.422Z", + "intent": "r11-sec-csrf-headers", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-023ab9", + "text": "M4 (layer part): the CSRF layer reads a form body only for form content types, up to a configurable CsrfConfig::form_limit (default 64 KiB instead of FORM_LIMIT 2 MiB at crates/id_effect_axum/src/server/security/csrf.rs:40) and under a read timeout (default 10 s) at :225, independent of the order of other layers.", + "kind": "manual" + }, + { + "id": "dw-d2250b", + "text": "L1 (BREACH): the token handed to pages and headers is masked per response (one-time pad XOR, base64url) and unmasked before the constant-time compare; MIGRATION-0.11 section \"masked CSRF token format\".", + "kind": "manual" + }, + { + "id": "dw-ac0408", + "text": "L5 (API): CsrfToken::rotate issues a new token and invalidates the old one; r11-sec-auth-state calls it at login.", + "kind": "manual" + }, + { + "id": "dw-122ebc", + "text": "L2 (headers): SecurityHeaders::recommended() (headers.rs:41-49) adds Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Resource-Policy: same-origin and a deny-list Permissions-Policy (camera, microphone, geolocation, payment, usb), each overridable.", + "kind": "manual" + }, + { + "id": "dw-b95d7e", + "text": "Regression tests `m4_csrf_form_read_respects_limit`, `m4_csrf_form_read_times_out`, `l1_csrf_token_masked_differently_per_response`, `l1_masked_token_validates`, `l5_rotate_issues_new_token_and_invalidates_old`, `l2_recommended_headers_include_coop_corp_permissions_policy`.", + "kind": "manual" + }, + { + "id": "dw-43edbc", + "text": "Gate: `cargo nextest run -p id_effect_axum --all-features`", + "kind": "manual" + }, + { + "id": "dw-e79746", + "text": "Owns: `crates/id_effect_axum/src/server/security/**`, `crates/id_effect_axum/tests/security_defaults.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sec-csrf-headers", + "lockedBy": "r11-sec-csrf-headers", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mveouc1i-byube1/v1.json b/.maestro/contracts/tsk-mveouc1i-byube1/v1.json new file mode 100644 index 000000000..f42a509cd --- /dev/null +++ b/.maestro/contracts/tsk-mveouc1i-byube1/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-f6083b", + "taskId": "tsk-mveouc1i-byube1", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:21:58.682Z", + "lockedAt": "2026-10-19T03:21:58.682Z", + "intent": "r11-sec-limits", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-e04aff", + "text": "M5: a LoadShed layer caps in-flight requests (default derived from available parallelism, configurable in HostConfig) and answers 503 Problem Details with Retry-After when full, without queueing; server_layer installs it by default (no such layer exists in crates/id_effect_axum/src/middleware/ today); disabling it is dangerous_no_load_shedding() (posture).", + "kind": "manual" + }, + { + "id": "dw-67d0f2", + "text": "M8: MemoryRateLimitStore eviction is O(1) amortized (LRU list or clock ring) and the store is sharded (no full scan as at middleware/rate_limit.rs:138-145; DEFAULT_MAX_KEYS at :96 kept).", + "kind": "manual" + }, + { + "id": "dw-45dda4", + "text": "Regression tests `m5_load_shed_rejects_over_capacity_with_503`, `m5_default_server_stack_has_concurrency_cap`, `m8_eviction_does_not_scan_all_keys` (visit counter under a test hook), `m8_eviction_evicts_least_recently_used`, `m8_sharded_store_under_contention`.", + "kind": "manual" + }, + { + "id": "dw-62a84e", + "text": "Gate: `cargo nextest run -p id_effect_axum --all-features`; `bash scripts/bench-run.sh --only id_effect_axum:gate_request` (no budget regression)", + "kind": "manual" + }, + { + "id": "dw-d4d664", + "text": "Owns: `crates/id_effect_axum/src/middleware/{rate_limit.rs,load_shed.rs,mod.rs}`, `crates/id_effect_axum/src/server/{bootstrap.rs,lifecycle.rs}`, `crates/id_effect_axum/tests/limits.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sec-limits", + "lockedBy": "r11-sec-limits", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mveovcwy-q3fo5g/v1.json b/.maestro/contracts/tsk-mveovcwy-q3fo5g/v1.json new file mode 100644 index 000000000..ce833087d --- /dev/null +++ b/.maestro/contracts/tsk-mveovcwy-q3fo5g/v1.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-0ace4c", + "taskId": "tsk-mveovcwy-q3fo5g", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:22:47.461Z", + "lockedAt": "2026-10-19T03:22:47.461Z", + "intent": "r11-sc-pin-permissions", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-b4b085", + "text": "S1: every `uses:` in ci.yml, audit.yml, docs.yml and dependabot-auto-merge.yml is pinned to a full 40-character commit SHA with a `# vX.Y.Z` comment (today tags, e.g. actions/checkout@v7 at ci.yml:25); dtolnay/rust-toolchain@stable / @master (ci.yml:195, :134) replaced by a pinned SHA with an explicit toolchain input; rust-toolchain.toml pins the default toolchain; the moon installer `curl … | bash` (ci.yml:75, docs.yml:26) replaced by a pinned release download checked against its sha256; .github/dependabot.yml keeps the SHA pins updated.", + "kind": "manual" + }, + { + "id": "dw-86c6ca", + "text": "S2: every workflow it owns has a top-level `permissions: contents: read` (or `{}`) with job-level grants only where needed; dependabot-auto-merge.yml no longer approves its own PRs (`gh pr review --approve` at :27 removed) and only enables auto-merge for patch/minor updates after required checks and a CODEOWNERS review.", + "kind": "manual" + }, + { + "id": "dw-7c44b3", + "text": "S5: Cargo.lock is committed (.gitignore:71 entry removed, 0.11.0.md D12) and CI builds use `--locked`; the minimal-versions job stays.", + "kind": "manual" + }, + { + "id": "dw-919a3d", + "text": "pg-integration job gains `-p id_effect_workflow` (for E7.5).", + "kind": "manual" + }, + { + "id": "dw-cc828d", + "text": "scripts/check-workflows.sh fails on an unpinned `uses:`, a workflow without top-level permissions, or `curl … | sh/bash`, has --self-test cases `s1_every_action_pinned_by_sha`, `s2_every_workflow_least_privilege`, `s2_dependabot_does_not_self_approve`, and runs as a step in ci.yml over all of .github/workflows (other leaves' new workflow files must pass it).", + "kind": "manual" + }, + { + "id": "dw-a6ab21", + "text": "Gate: `bash scripts/check-workflows.sh --self-test && bash scripts/check-workflows.sh`; `cargo build --workspace --locked`; `actionlint` on the owned workflows", + "kind": "manual" + }, + { + "id": "dw-765750", + "text": "Owns: `.github/workflows/{ci.yml,audit.yml,docs.yml,dependabot-auto-merge.yml}`, `.github/dependabot.yml`, `rust-toolchain.toml`, `.gitignore`, `Cargo.lock`, `scripts/check-workflows.sh`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sc-pin-permissions", + "lockedBy": "r11-sc-pin-permissions", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mveovhgv-tk9wbj/v1.json b/.maestro/contracts/tsk-mveovhgv-tk9wbj/v1.json new file mode 100644 index 000000000..c351e3fb4 --- /dev/null +++ b/.maestro/contracts/tsk-mveovhgv-tk9wbj/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-f7c029", + "taskId": "tsk-mveovhgv-tk9wbj", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:22:52.616Z", + "lockedAt": "2026-10-19T03:22:52.616Z", + "intent": "r11-e8-1-timers-hibernate", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-ce04a0", + "text": "fault.actors#6: ProcessCtx::send_after(msg, d) -> TimerRef, Addr::send_after for callers outside the process, cancel_timer(TimerRef) -> bool (true means the message will never be delivered); timers run on the executor clock (TestExecutor can advance them) and die with the process; ProcessCtx::set_idle_timeout(d) delivers Info::Timeout once per idle period.", + "kind": "manual" + }, + { + "id": "dw-e8b138", + "text": "fault.actors#8: Next::Hibernate(state) releases the mailbox buffers and calls an optional Process::hibernate hook, and the process resumes on the next message. Next (process.rs:41) and Info (msg.rs:40) become #[non_exhaustive] (MIGRATION-0.11 section).", + "kind": "manual" + }, + { + "id": "dw-3df4bf", + "text": "Tests `send_after_delivers_under_test_clock`, `cancelled_timer_never_delivers`, `timers_die_with_process`, `idle_timeout_fires_once_per_idle_period`, `hibernate_releases_buffer_and_resumes`; runnable examples for send_after and hibernate; book part7/ch37-01-timers-selective-receive.md covers #6, #7 and #8 (row level documented).", + "kind": "manual" + }, + { + "id": "dw-9b1358", + "text": "Gate: `cargo nextest run -p id_effect_process --all-features`; `cargo test -p id_effect_process --examples`", + "kind": "manual" + }, + { + "id": "dw-418380", + "text": "Owns: `crates/id_effect_process/src/{process.rs,cell.rs,timer.rs,dynamic.rs,supervisor.rs,msg.rs,lib.rs}`, `crates/id_effect_process/tests/timers.rs`, `crates/id_effect_process/examples/` (new send_after and hibernate examples), `crates/id_effect/book/src/part7/ch37-01-timers-selective-receive.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e8-1-timers-hibernate", + "lockedBy": "r11-e8-1-timers-hibernate", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mveovo0t-2qcudv/v1.json b/.maestro/contracts/tsk-mveovo0t-2qcudv/v1.json new file mode 100644 index 000000000..aea4098d0 --- /dev/null +++ b/.maestro/contracts/tsk-mveovo0t-2qcudv/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-56fc6b", + "taskId": "tsk-mveovo0t-2qcudv", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:23:00.050Z", + "lockedAt": "2026-10-19T03:23:00.050Z", + "intent": "r11-sec-secrets", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-8fe2cf", + "text": "M13: Secret zeroizes its value on drop (crates/id_effect_config/src/secret.rs:16-48 has no Drop today); SecretString and SecretBytes aliases; id_effect_auth Password (crates/id_effect_auth/src/password.rs:110-119) zeroizes on drop. MIGRATION-0.11 section (the new bound).", + "kind": "manual" + }, + { + "id": "dw-14f298", + "text": "L3: equality on Secret and Secret> uses subtle::ConstantTimeEq; the generic PartialEq impl (secret.rs:55-58) is removed (MIGRATION-0.11).", + "kind": "manual" + }, + { + "id": "dw-0f4c47", + "text": "M14: Session (crates/id_effect_auth/src/session/mod.rs:105, derived Debug) gets a manual Debug that prints the id prefix, user id and timestamps and only the attribute keys, never values.", + "kind": "manual" + }, + { + "id": "dw-0e9dc1", + "text": "Regression tests `m13_secret_is_zeroized_on_drop` (a recording Zeroize type), `m13_password_is_zeroized_on_drop`, `m14_session_debug_hides_attr_values_and_tokens`, `l3_secret_eq_uses_constant_time_compare`.", + "kind": "manual" + }, + { + "id": "dw-f45b36", + "text": "Gate: `cargo nextest run -p id_effect_config -p id_effect_auth --all-features`; `cargo deny check` (zeroize, subtle)", + "kind": "manual" + }, + { + "id": "dw-3d18a3", + "text": "Owns: `crates/id_effect_config/{src/secret.rs,Cargo.toml,tests/secret.rs}`, `crates/id_effect_auth/{src/password.rs,src/session/mod.rs,Cargo.toml}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sec-secrets", + "lockedBy": "r11-sec-secrets", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mveox57m-tvw0xp/v1.json b/.maestro/contracts/tsk-mveox57m-tvw0xp/v1.json new file mode 100644 index 000000000..c541593b4 --- /dev/null +++ b/.maestro/contracts/tsk-mveox57m-tvw0xp/v1.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 2, + "id": "c-b3ea78", + "taskId": "tsk-mveox57m-tvw0xp", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:24:11.834Z", + "lockedAt": "2026-10-19T03:24:11.834Z", + "intent": "r11-e2-5-stm-collections", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f53fd4", + "text": "conc.stm#6: TSet, TArray, TPriorityQueue (take retries when empty), TPromise (await_ parks until completed; completes once), TReentrantLock (read and write locks, reentrant per fiber) in id_effect::stm, each composing with Stm::or_else and Stm::retry.", + "kind": "manual" + }, + { + "id": "dw-7478d2", + "text": "Tests `tset_add_remove_atomic_with_tref`, `tarray_update_is_transactional`, `tpriority_queue_orders_and_retries_when_empty`, `tpromise_await_parks_until_completed`, `treentrant_lock_writer_excludes_readers`, `stm_collections_contention`; examples 087-091; book part4/ch12-04-collections.md covers all nine structures (evidence for #6 at level documented, cap 89).", + "kind": "manual" + }, + { + "id": "dw-f668f0", + "text": "Gate: `cargo nextest run -p id_effect --test stm_collections`; `cargo test -p id_effect --doc stm`; `cargo test -p id_effect --examples`", + "kind": "manual" + }, + { + "id": "dw-d14b06", + "text": "Owns: `crates/id_effect/src/stm/{tset,tarray,tpriority_queue,tpromise,treentrant_lock}.rs`, `crates/id_effect/src/stm/mod.rs`, `crates/id_effect/examples/087-091_stm_*.rs`, `crates/id_effect/tests/stm_collections.rs`, `crates/id_effect/book/src/part4/ch12-04-collections.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e2-5-stm-collections", + "lockedBy": "r11-e2-5-stm-collections", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/contracts/tsk-mveox92p-bcbotq/v1.json b/.maestro/contracts/tsk-mveox92p-bcbotq/v1.json new file mode 100644 index 000000000..6ad0a20df --- /dev/null +++ b/.maestro/contracts/tsk-mveox92p-bcbotq/v1.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-efb330", + "taskId": "tsk-mveox92p-bcbotq", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:24:14.707Z", + "lockedAt": "2026-10-19T03:24:14.707Z", + "intent": "r11-sec-h8-routing", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-e9e996", + "text": "H8 (0.11.0.md D8): typed routes carry an access marker; Route::public() or Route::guard(g: impl RouteGuard) is required before Router::route (crates/id_effect_axum/src/route/mod.rs:580) accepts the Endpoint; an undecided endpoint is a compile error. RouteGuard is an id_effect_axum trait implemented by id_effect_authz Guard (crates/id_effect_authz/src/guard.rs), so axum does not depend on authz.", + "kind": "manual" + }, + { + "id": "dw-3ad723", + "text": "RouteInfo (route/registry.rs:96, #[non_exhaustive]) gains access: Public | Guarded(policies); Router::route_unchecked is the #[deprecated] one-minor shim (L9) and records a posture opt-out; Router::into_axum() installs a deny-by-default layer answering 404 Problem Details for any request whose matched path is not a registered public or guarded route (covers plain axum routes merged later) unless allow_unregistered_routes_dangerous(); Router::access_report() lists public routes and is logged at startup; OpenAPI marks security per route.", + "kind": "manual" + }, + { + "id": "dw-b4ab79", + "text": "Anonymous principals (crates/id_effect_auth/src/middleware.rs:10 continues as Anonymous; guard.rs:13-16) reach only public routes; the auth middleware docs say so.", + "kind": "manual" + }, + { + "id": "dw-703a6c", + "text": "Every typed-route call site in the workspace is migrated in this leaf so `cargo build --workspace --all-targets` stays green: id_effect_axum examples and tests, id_effect_auth account and oidc routes, id_effect_console, examples/**, crates/id_effect_tool/templates/**, and book route snippets (K5).", + "kind": "manual" + }, + { + "id": "dw-48a2dd", + "text": "Regression tests `h8_route_without_access_decision_does_not_compile` (trybuild), `h8_route_unchecked_is_deprecated_and_recorded`, `h8_unregistered_axum_route_is_denied`, `h8_anonymous_reaches_only_public_routes`, `h8_access_report_lists_public_routes`. MIGRATION-0.11 section \"H8 routing\".", + "kind": "manual" + }, + { + "id": "dw-ceda7d", + "text": "Gate: `cargo nextest run -p id_effect_axum -p id_effect_authz -p id_effect_auth -p id_effect_console --all-features`; `cargo nextest run -p id_effect_axum --test ui`; `cargo build --workspace --all-targets`; `cargo test -p book-snippets` (K5 snippets compile)", + "kind": "manual" + }, + { + "id": "dw-a71842", + "text": "Owns: `crates/id_effect_axum/src/{route/mod.rs,route/registry.rs,route/access.rs,lib.rs}`, `crates/id_effect_axum/Cargo.toml`, `crates/id_effect_axum/tests/{route_access,ui,route_typed,route_registry,route_example,openapi}.rs`, `crates/id_effect_axum/tests/ui/**`, `crates/id_effect_axum/examples/**`, `crates/id_effect_authz/src/guard.rs`, `crates/id_effect_auth/src/{middleware.rs,account/**,oidc/**}` (route call sites), `crates/id_effect_console/src/**` (route call sites), route call sites only in `examples/**`, `crates/id_effect_tool/templates/**` and `crates/id_effect/book/src/**`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sec-h8-routing", + "lockedBy": "r11-sec-h8-routing", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mveoxlor-3b9mxh/v1.json b/.maestro/contracts/tsk-mveoxlor-3b9mxh/v1.json new file mode 100644 index 000000000..820782ea7 --- /dev/null +++ b/.maestro/contracts/tsk-mveoxlor-3b9mxh/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-18d82e", + "taskId": "tsk-mveoxlor-3b9mxh", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:24:33.553Z", + "lockedAt": "2026-10-19T03:24:33.553Z", + "intent": "r11-e2-6-bench-ci", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-dd6fb6", + "text": "test.qa#8: .github/workflows/bench.yml runs scripts/bench-run.sh for base and head on the same runner (dedicated `bench` runner label if available, otherwise ubuntu-latest) on pushes to main and on pull requests labelled perf, then scripts/bench-compare.sh against docs/benchmarks/gates.toml; a failed budget gate fails the job; SHA-pinned, permissions read.", + "kind": "manual" + }, + { + "id": "dw-f8ca30", + "text": "crates/id_effect_axum/benches/gate_request.rs gains a secure_stack case (SecurityLayer::recommended, rate limit, load shedding, authentication + guard, default log redaction) next to the bare request; gates.toml gains a `kind = \"pair\"` gate secure_stack / bare ≤ 1.15 (an intra-run ratio, which bench-compare.sh learns, with --self-test cases).", + "kind": "manual" + }, + { + "id": "dw-1a91f3", + "text": "docs/benchmarks/README.md documents the job and the pair gate. The secure-stack figure is re-measured with the final stack by r11-w3-kpis.", + "kind": "manual" + }, + { + "id": "dw-c1b9bd", + "text": "Gate: `bash scripts/bench-compare.sh --self-test`; `bash scripts/bench-run.sh --only id_effect_axum:gate_request`; `bash scripts/check-workflows.sh`", + "kind": "manual" + }, + { + "id": "dw-8908fd", + "text": "Owns: `.github/workflows/bench.yml`, `crates/id_effect_axum/benches/gate_request.rs`, `docs/benchmarks/{gates.toml,README.md}`, `scripts/bench-compare.sh`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e2-6-bench-ci", + "lockedBy": "r11-e2-6-bench-ci", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mveoxp2g-hrduy2/v1.json b/.maestro/contracts/tsk-mveoxp2g-hrduy2/v1.json new file mode 100644 index 000000000..46f8c43d2 --- /dev/null +++ b/.maestro/contracts/tsk-mveoxp2g-hrduy2/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-e8bbec", + "taskId": "tsk-mveoxp2g-hrduy2", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:24:37.752Z", + "lockedAt": "2026-10-19T03:24:37.752Z", + "intent": "r11-sc-dco-docs", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-15c9c6", + "text": "S6: .github/workflows/dco.yml fails a pull request when any commit lacks a Signed-off-by line (`s6_dco_check_rejects_unsigned_off_commit` as a fixture run); CONTRIBUTING.md (DCO section at :12-20) documents signing commits (SSH or GPG, or gitsign) and the signed-tag rule; enabling \"require signed commits\" on protected branches is an owner action (0.11.0.md D15) recorded with its date.", + "kind": "manual" + }, + { + "id": "dw-b08580", + "text": "S7 and sec.supply#7: SECURITY.md supported versions list 0.11.x and 0.10.x (90-day window), replacing 0.9.x at :35, and a \"Security releases\" section lists the 0.10.1 GHSA advisories (ids and links) as public security-release history.", + "kind": "manual" + }, + { + "id": "dw-3f1362", + "text": "THREAT-MODEL.md: STRIDE per trust boundary (HTTP edge, authentication, authorization, mesh, console/operator surfaces, data/SQL/Redis, outbound HTTP, supply chain); each threat names its mitigation, the ADR 0013 rule (R1–R10) and the test or gate; L6 (rsa 0.9 RUSTSEC-2023-0071 via sqlx-mysql and openidconnect, ignored with a reachability reason at deny.toml:18) is an accepted risk with the reachability argument and a re-check each release.", + "kind": "manual" + }, + { + "id": "dw-4de78d", + "text": "Gate: `bash scripts/check-workflows.sh`; `actionlint .github/workflows/dco.yml`; for each of R1..R10, `grep -c \"R\" THREAT-MODEL.md` ≥ 1", + "kind": "manual" + }, + { + "id": "dw-c26d27", + "text": "Owns: `.github/workflows/dco.yml`, `CONTRIBUTING.md`, `SECURITY.md`, `THREAT-MODEL.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sc-dco-docs", + "lockedBy": "r11-sc-dco-docs", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/contracts/tsk-mveoxua6-li0n6j/v1.json b/.maestro/contracts/tsk-mveoxua6-li0n6j/v1.json new file mode 100644 index 000000000..57aa0c634 --- /dev/null +++ b/.maestro/contracts/tsk-mveoxua6-li0n6j/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-f1bc8a", + "taskId": "tsk-mveoxua6-li0n6j", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:24:41.372Z", + "lockedAt": "2026-10-19T03:24:41.372Z", + "intent": "r11-e8-3-quic-mtls", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-c3ed94", + "text": "dist.tls#4: QUIC requires and verifies client certificates by default (replacing with_no_client_auth at crates/id_effect_node/src/transport/quic.rs:250, :254, :265); a peer without a client certificate, or with one from another CA, is refused.", + "kind": "manual" + }, + { + "id": "dw-68da5e", + "text": "dist.tls#5: NodeCertificate::from_pem_files(chain, key) with PKCS#8, PKCS#1 and SEC1 keys and a CA bundle loader (today the only constructor is self_signed, quic.rs:50); dist.tls#7: a CertReloader polls the files (no notify crate, as 0.10 D4) and new connections use the new certificate without a restart; dist.tls#2: MeshConfig defaults to an encrypted transport (0.10.1 already refuses QUIC without roots or a pinned key).", + "kind": "manual" + }, + { + "id": "dw-6fe6cb", + "text": "Every dangerous_* opt-out (accept any server cert, no client auth) calls id_effect::observability::posture::record_dangerous. id_effect_node/Cargo.toml declares, once, the features this release needs: quic, tls-tcp, insecure-plaintext-tcp, kubernetes and their deps (r11-e8-3-tcp-tls and r11-e8-3-k8s-discovery use them in wave 2 without editing Cargo.toml).", + "kind": "manual" + }, + { + "id": "dw-67a4f8", + "text": "Level tested → documented: book part7/ch38-01-transport-security.md, example 134_mtls_cluster.rs, integration test tests/transport_tls.rs. Tests `mtls_rejects_peer_without_client_cert`, `mtls_rejects_cert_from_other_ca`, `pem_chain_and_key_load`, `rotated_cert_used_for_new_connections`, `dangerous_accept_any_server_cert_is_recorded_in_posture`.", + "kind": "manual" + }, + { + "id": "dw-e15a6f", + "text": "Gate: `cargo nextest run -p id_effect_node --all-features`; `cargo run -p id_effect_node --features quic --example 134_mtls_cluster`; `cargo deny check`", + "kind": "manual" + }, + { + "id": "dw-9f7ed2", + "text": "Owns: `crates/id_effect_node/src/transport/{quic.rs,certs.rs,mod.rs}`, `crates/id_effect_node/Cargo.toml`, `crates/id_effect_node/tests/transport_tls.rs`, `crates/id_effect_node/examples/134_mtls_cluster.rs`, `crates/id_effect/book/src/part7/ch38-01-transport-security.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e8-3-quic-mtls", + "lockedBy": "r11-e8-3-quic-mtls", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mveoxvjj-lwme2p/v1.json b/.maestro/contracts/tsk-mveoxvjj-lwme2p/v1.json new file mode 100644 index 000000000..d13bf775f --- /dev/null +++ b/.maestro/contracts/tsk-mveoxvjj-lwme2p/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-3565d2", + "taskId": "tsk-mveoxvjj-lwme2p", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:24:42.967Z", + "lockedAt": "2026-10-19T03:24:42.967Z", + "intent": "r11-sc-publish", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-366d55", + "text": "S3: crates.io trusted publishing over GitHub OIDC (rust-lang/crates-io-auth-action, SHA-pinned); `CARGO_REGISTRY_TOKEN` (publish.yml:113) removed from every workflow. Configuring the trusted publisher and deleting the secret are owner actions (0.11.0.md D15) recorded with dates in the leaf evidence.", + "kind": "manual" + }, + { + "id": "dw-2f0be0", + "text": "S5 (0.11.0.md D13): the build job runs `cargo package --locked` for every crate from `scripts/validate-crate-packaging.sh --print-order`; SLSA Build L3 provenance is produced by the slsa-framework/slsa-github-generator generic reusable workflow over those .crate files and the SBOMs (replacing the re-download at publish.yml:160-166 and the attestation at :169); `cargo publish` publishes the same tree.", + "kind": "manual" + }, + { + "id": "dw-79f44a", + "text": "Verify job after publish: for every crate, download it from static.crates.io, check its sha256 equals the attested subject digest and run slsa-verifier verify-artifact; the release fails on any mismatch (exit KPI \"a verified attestation per crate\", test `s5_attested_digest_matches_published_crate`).", + "kind": "manual" + }, + { + "id": "dw-393e5d", + "text": "S6: publish refuses an unsigned tag (`s6_release_requires_signed_tag`); top-level permissions read, job-level id-token/attestations only where needed; publishes lints/id_effect_lints (r11-e1-9-dylint) with its pinned nightly after the workspace crates; docs/RELEASING.md documents the flow and the owner steps.", + "kind": "manual" + }, + { + "id": "dw-cd402f", + "text": "Gate: `bash scripts/check-workflows.sh`; `! grep -rn CARGO_REGISTRY_TOKEN .github`; `actionlint .github/workflows/publish.yml`; a workflow_dispatch run with dry_run=true recorded (run URL in the evidence)", + "kind": "manual" + }, + { + "id": "dw-1589fc", + "text": "Owns: `.github/workflows/publish.yml`, `.github/scripts/publish-crate.sh`, `docs/RELEASING.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sc-publish", + "lockedBy": "r11-sc-publish", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvep8nuq-do4lci/v1.json b/.maestro/contracts/tsk-mvep8nuq-do4lci/v1.json new file mode 100644 index 000000000..0028c9291 --- /dev/null +++ b/.maestro/contracts/tsk-mvep8nuq-do4lci/v1.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-ef88f6", + "taskId": "tsk-mvep8nuq-do4lci", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:33:08.518Z", + "lockedAt": "2026-10-19T03:33:08.518Z", + "intent": "r11-e8-3-reliable-delivery", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-1c598f", + "text": "At-least-once mode (0.11.0.md D19), opt-in per send (Addr::send_reliable) and per mesh (DeliveryMode::AtLeastOnce); at-most-once stays the default. Per-peer sequence numbers, ack frames, a bounded retransmit buffer (count and bytes, configurable) with backoff, and a receiver dedup window keyed by (incarnation, seq), so reconnects do not duplicate; messages pending when the peer restarts with a new incarnation fail with DeliveryFailed to the sender's monitor (documented).", + "kind": "manual" + }, + { + "id": "dw-e3f4bd", + "text": "PROTOCOL_VERSION bumps (crates/id_effect_node/src/wire.rs:16); older nodes are refused with VersionMismatch; MIGRATION-0.11 and CHANGELOG say so.", + "kind": "manual" + }, + { + "id": "dw-3d78bc", + "text": "dist.remote#8: the sending fiber's trace context is carried on remote cast, call and send (today an empty TraceContext::new() at mesh.rs:369, :384, :424) and becomes the parent span on the receiver.", + "kind": "manual" + }, + { + "id": "dw-88d997", + "text": "Fuzz targets mesh_frame and mesh_envelope added to fuzz/ (counted in the E11.4 KPI).", + "kind": "manual" + }, + { + "id": "dw-c2ba0a", + "text": "Tests `reliable_send_survives_connection_drop`, `duplicates_suppressed_after_reconnect`, `retransmit_buffer_bound_faults_oldest`, `at_most_once_remains_default`, `remote_call_carries_trace_context`, `version_mismatch_refused`.", + "kind": "manual" + }, + { + "id": "dw-a02314", + "text": "Gate: `cargo nextest run -p id_effect_node --all-features`; in fuzz/: `cargo +nightly- fuzz build mesh_frame mesh_envelope` and each run `-max_total_time=60` with no crash", + "kind": "manual" + }, + { + "id": "dw-3172eb", + "text": "Owns: `crates/id_effect_node/src/{envelope.rs,wire.rs,mesh.rs,session.rs,delivery.rs,lib.rs}`, `crates/id_effect_node/tests/reliable_delivery.rs`, `fuzz/fuzz_targets/{mesh_frame.rs,mesh_envelope.rs}` and their `fuzz/Cargo.toml` entries", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e8-3-reliable-delivery", + "lockedBy": "r11-e8-3-reliable-delivery", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvepmv97-fe5jxj/v1.json b/.maestro/contracts/tsk-mvepmv97-fe5jxj/v1.json new file mode 100644 index 000000000..7fdee2909 --- /dev/null +++ b/.maestro/contracts/tsk-mvepmv97-fe5jxj/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-f7e98d", + "taskId": "tsk-mvepmv97-fe5jxj", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:44:09.843Z", + "lockedAt": "2026-10-19T03:44:09.843Z", + "intent": "r11-e8-2-statem", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-9f2dba", + "text": "Additive Statem behaviour in id_effect_fsm (the Copy-bound Fsm trait at crates/id_effect_fsm/src/definition.rs:44 is unchanged): fault.fsm#3 states may carry data (no Copy bound) and a separate Data value travels with the machine; handle_event(event, state, data, ctx) returns Transition::{Next(state, data, actions), Keep(data, actions), Stop(reason)}.", + "kind": "manual" + }, + { + "id": "dw-c02d22", + "text": "fault.fsm#4 actions StateTimeout(d, msg) (cancelled on any state change), EventTimeout(d, msg) (cancelled by any event), GenericTimeout(name, d, msg), built on r11-e8-1-timers-hibernate; fault.fsm#5 Postpone: a postponed event is re-delivered after the next state change, in arrival order; also Reply and NextEvent actions; an optional state-enter callback runs on every state change (strengthens #6); runs as a supervised process (StatemProcess::start / start_linked).", + "kind": "manual" + }, + { + "id": "dw-ab2dd3", + "text": "Tests `statem_state_carries_data`, `postponed_event_redelivered_after_state_change`, `state_timeout_cancelled_on_transition`, `event_timeout_cancelled_by_any_event`, `enter_action_runs_on_every_state_change`, `statem_under_supervisor_restarts`; example statem_door_lock.rs; book part5/ch19-05-statem.md (row level documented, 8/8 at cap 89).", + "kind": "manual" + }, + { + "id": "dw-3122ae", + "text": "Gate: `cargo nextest run -p id_effect_fsm --all-features`; `cargo test -p id_effect_fsm --examples`", + "kind": "manual" + }, + { + "id": "dw-4b0895", + "text": "Owns: `crates/id_effect_fsm/src/{statem.rs,lib.rs,error.rs}`, `crates/id_effect_fsm/tests/statem.rs`, `crates/id_effect_fsm/examples/statem_door_lock.rs`, `crates/id_effect/book/src/part5/ch19-05-statem.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e8-2-statem", + "lockedBy": "r11-e8-2-statem", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvepyvw7-ohtsdc/v1.json b/.maestro/contracts/tsk-mvepyvw7-ohtsdc/v1.json new file mode 100644 index 000000000..7a759bb62 --- /dev/null +++ b/.maestro/contracts/tsk-mvepyvw7-ohtsdc/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-98539c", + "taskId": "tsk-mvepyvw7-ohtsdc", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:53:30.643Z", + "lockedAt": "2026-10-19T03:53:30.643Z", + "intent": "r11-sec-auth-state", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-8cc86f", + "text": "M18: PgTotpStepStore (features pg + totp) implements TotpStepStore (crates/id_effect_auth/src/totp.rs:236-240; MemoryTotpStepStore at :251 is the only implementation today) with one atomic compare-and-set statement; migration 0003 creates its table; using MemoryTotpStepStore under the prod profile is reported by the posture check as memory-only security state.", + "kind": "manual" + }, + { + "id": "dw-0a4d2a", + "text": "A Postgres store for the 0.10.1 LoginThrottle trait (H5), so throttling is shared across instances.", + "kind": "manual" + }, + { + "id": "dw-2ecca3", + "text": "L5: the session login path (session/manager.rs) rotates the session id and calls CsrfToken::rotate (from r11-sec-csrf-headers) on successful login.", + "kind": "manual" + }, + { + "id": "dw-fdd049", + "text": "Regression tests `m18_pg_totp_replay_rejected_across_instances`, `m18_pg_totp_concurrent_consume_one_wins`, `pg_login_throttle_shared_across_instances`, `l5_csrf_token_rotates_at_login`, `l5_session_id_rotates_at_login`.", + "kind": "manual" + }, + { + "id": "dw-2365bc", + "text": "Gate: `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p id_effect_auth --all-features` (Postgres required)", + "kind": "manual" + }, + { + "id": "dw-6ee569", + "text": "Owns: `crates/id_effect_auth/{src/totp.rs,src/session/manager.rs,migrations/0003_*,Cargo.toml,tests/auth_state.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sec-auth-state", + "lockedBy": "r11-sec-auth-state", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvepzuqe-owgjul/v1.json b/.maestro/contracts/tsk-mvepzuqe-owgjul/v1.json new file mode 100644 index 000000000..27e06f324 --- /dev/null +++ b/.maestro/contracts/tsk-mvepzuqe-owgjul/v1.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 2, + "id": "c-9135db", + "taskId": "tsk-mvepzuqe-owgjul", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:54:14.774Z", + "lockedAt": "2026-10-19T03:54:14.774Z", + "intent": "r11-e8-3-k8s-discovery", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f29018", + "text": "dist.membership#4 (0.11.0.md D18): KubernetesDiscovery under feature kubernetes lists the ready addresses of a headless service through the in-cluster EndpointSlice API over rustls, with the service-account token and CA from /var/run/secrets/kubernetes.io/serviceaccount and the namespace from its file; no kube-rs; implements the Discovery trait (crates/id_effect_node/src/discovery.rs:14) beside StaticDiscovery (:21) and DnsDiscovery (:43) and composes with ChainedDiscovery (:69).", + "kind": "manual" + }, + { + "id": "dw-258507", + "text": "Tests against an in-process fake API server: `kubernetes_discovery_lists_ready_endpoints`, `kubernetes_discovery_skips_not_ready`, `kubernetes_discovery_uses_service_account_token`, `kubernetes_discovery_rejects_untrusted_api_cert`; book section in part7/ch38-00-distribution.md.", + "kind": "manual" + }, + { + "id": "dw-9d13e0", + "text": "Gate: `cargo nextest run -p id_effect_node --features kubernetes --test discovery_kubernetes`; `cargo nextest run -p id_effect_node --all-features`", + "kind": "manual" + }, + { + "id": "dw-2b7303", + "text": "Owns: `crates/id_effect_node/src/discovery.rs`, `crates/id_effect_node/tests/discovery_kubernetes.rs`, `crates/id_effect/book/src/part7/ch38-00-distribution.md` (section)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e8-3-k8s-discovery", + "lockedBy": "r11-e8-3-k8s-discovery", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mveq0q6b-7wurki/v1.json b/.maestro/contracts/tsk-mveq0q6b-7wurki/v1.json new file mode 100644 index 000000000..268fac0c9 --- /dev/null +++ b/.maestro/contracts/tsk-mveq0q6b-7wurki/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-c32380", + "taskId": "tsk-mveq0q6b-7wurki", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:54:58.206Z", + "lockedAt": "2026-10-19T03:54:58.206Z", + "intent": "r11-e8-3-tcp-tls", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-5eb352", + "text": "The TCP transport (plaintext today, crates/id_effect_node/src/transport/tcp.rs:1-5) carries TLS 1.3 with mutual authentication under feature tls-tcp, reusing transport/certs.rs (PEM loading and reload).", + "kind": "manual" + }, + { + "id": "dw-d7dfea", + "text": "Plaintext TCP exists only with feature insecure-plaintext-tcp and an explicit TcpTransport::dangerous_plaintext(), which records a posture opt-out (ADR 0013 R8); without the feature the plaintext constructor does not exist (r11-e11-4-compile-fail adds the case). MIGRATION-0.11 section \"plaintext TCP feature\".", + "kind": "manual" + }, + { + "id": "dw-10ef3d", + "text": "Tests `tcp_tls_mutual_auth_round_trip`, `tcp_tls_rejects_untrusted_peer`, `plaintext_tcp_opt_out_is_recorded`.", + "kind": "manual" + }, + { + "id": "dw-d43758", + "text": "Gate: `cargo nextest run -p id_effect_node --features tls-tcp`; `cargo nextest run -p id_effect_node --all-features`; `cargo build -p id_effect_node --no-default-features`", + "kind": "manual" + }, + { + "id": "dw-81b468", + "text": "Owns: `crates/id_effect_node/src/transport/{tcp.rs,tls_tcp.rs,mod.rs}`, `crates/id_effect_node/tests/transport_tcp_tls.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e8-3-tcp-tls", + "lockedBy": "r11-e8-3-tcp-tls", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mveqc5k9-6weba4/v1.json b/.maestro/contracts/tsk-mveqc5k9-6weba4/v1.json new file mode 100644 index 000000000..1f989785d --- /dev/null +++ b/.maestro/contracts/tsk-mveqc5k9-6weba4/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-cfe3a5", + "taskId": "tsk-mveqc5k9-6weba4", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T04:03:51.102Z", + "lockedAt": "2026-10-19T04:03:51.102Z", + "intent": "r11-e7-5-sagas-promote", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-314941", + "text": "fault.workflows#3 on the published crate: ctx.saga() registers compensations as journaled activities; on failure they run in reverse order, at least once, and survive a restart (the id_effect_fsm saga at crates/id_effect_fsm/src/saga.rs:66 stays the in-memory variant).", + "kind": "manual" + }, + { + "id": "dw-9135b0", + "text": "Level labs → documented: book part6/ch32-00-workflow-cluster.md rewritten for id_effect_workflow (labs banner removed), part5/ch19-04-workflow-bridge.md updated; runnable examples 010_order_saga, 020_durable_timer, 030_signal_approval (DATABASE_URL); README with a rust fence; integration test `saga_compensates_in_reverse_after_restart`.", + "kind": "manual" + }, + { + "id": "dw-17e39f", + "text": "labs/id_effect_workflow deleted with its duroxide, duroxide-pg and rusqlite dependencies and the NetworkJournalStub (labs/id_effect_workflow/src/journal.rs:131, QUALITY_BAR check S); labs/Cargo.toml member removed.", + "kind": "manual" + }, + { + "id": "dw-c6f387", + "text": "The leaf evidence maps fault.workflows #1 #2 #3 #4 #5 #7 to paths and tests at HEAD (6/8 = 75 at documented).", + "kind": "manual" + }, + { + "id": "dw-ccb036", + "text": "Gate: `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p id_effect_workflow --all-features` (Postgres required); `cargo test --manifest-path labs/Cargo.toml --workspace`; `bash scripts/tier-conformance.sh id_effect_workflow`", + "kind": "manual" + }, + { + "id": "dw-140d67", + "text": "Owns: `crates/id_effect_workflow/{src/saga.rs,src/lib.rs,README.md,examples/**,tests/saga_pg.rs}`, `crates/id_effect/book/src/part6/ch32-00-workflow-cluster.md`, `crates/id_effect/book/src/part5/ch19-04-workflow-bridge.md`, `labs/id_effect_workflow/**` (deleted), `labs/Cargo.toml`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e7-5-sagas-promote", + "lockedBy": "r11-e7-5-sagas-promote", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mverrnjm-v3bnoo/v1.json b/.maestro/contracts/tsk-mverrnjm-v3bnoo/v1.json new file mode 100644 index 000000000..3e82d3c1e --- /dev/null +++ b/.maestro/contracts/tsk-mverrnjm-v3bnoo/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-59c955", + "taskId": "tsk-mverrnjm-v3bnoo", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T04:43:53.023Z", + "lockedAt": "2026-10-19T04:43:53.023Z", + "intent": "r11-sec-ops-surfaces", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-dd06cb", + "text": "M7: DEFAULT_SCALAR_URL (crates/id_effect_axum/src/openapi/ui.rs:69, @1) pinned to an exact version with its SRI sha384 integrity set by default (:103 is None today); ApiDocs is not mounted under the prod profile unless guarded or explicitly public_dangerous() (posture).", + "kind": "manual" + }, + { + "id": "dw-66dc74", + "text": "M6: the service template's local_operator shortcut (templates/service/src/console.rs.template:71-79) exists only in the dev profile; the console checks Host and Origin against console.allowed_hosts (default: the bind address) and answers 403 otherwise; outside dev the console mounts only behind a non-loopback guard; the Ledger enables the console in dev.toml only (base.toml:72-73 sets enabled = true for every profile today).", + "kind": "manual" + }, + { + "id": "dw-64b769", + "text": "M4 (template): BodyLimitLayer and TimeoutLayer wrap SecurityLayer (templates/service/src/lib.rs.template:134-137). L2 (metrics): the template serves /metrics on the console listener or behind a guard, not on the public router (:131); id_effect_axum metrics gains a guarded router helper.", + "kind": "manual" + }, + { + "id": "dw-7aa2bf", + "text": "Regression tests `m7_scalar_bundle_pinned_with_integrity`, `m7_docs_disabled_in_prod_unless_guarded`, `m6_console_rejects_foreign_host_and_origin`, `m6_loopback_operator_only_in_dev`, `m4_template_limit_layers_wrap_security_layer`, `l2_template_metrics_not_public` (`m6_ledger_console_disabled_in_prod_profile` lives in r11-w3-ledger).", + "kind": "manual" + }, + { + "id": "dw-c3603d", + "text": "Gate: `cargo nextest run -p id_effect_axum -p id_effect_console -p id_effect_tool --all-features`; `bash scripts/template-smoke.sh`", + "kind": "manual" + }, + { + "id": "dw-a16acf", + "text": "Owns: `crates/id_effect_axum/src/{openapi/ui.rs,openapi/mod.rs,metrics.rs}`, `crates/id_effect_axum/tests/openapi_ui.rs`, `crates/id_effect_console/src/{api.rs,lib.rs}`, `crates/id_effect_console/tests/host_origin.rs`, `crates/id_effect_tool/templates/service/{src/console.rs.template,src/lib.rs.template,config/**}`, `crates/id_effect_tool/tests/template_security.rs`, `examples/reference/ledger/config/{base.toml,dev.toml,prod.toml}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sec-ops-surfaces", + "lockedBy": "r11-sec-ops-surfaces", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvervb9q-tizqjp/v1.json b/.maestro/contracts/tsk-mvervb9q-tizqjp/v1.json new file mode 100644 index 000000000..fdcd31563 --- /dev/null +++ b/.maestro/contracts/tsk-mvervb9q-tizqjp/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-16e5d7", + "taskId": "tsk-mvervb9q-tizqjp", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T04:46:45.087Z", + "lockedAt": "2026-10-19T04:46:45.087Z", + "intent": "r11-sec-client-ssrf", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-fedb8d", + "text": "M3 (0.11.0.md D10): HttpClientConfig::default() turns on an SsrfPolicy enforced by a custom reqwest DNS resolver that checks every resolved address (DNS-rebinding safe) and by a check on literal-IP URLs: loopback, RFC 1918, CGNAT 100.64/10, link-local 169.254/16 and fe80::/10 (incl. 169.254.169.254 and fd00:ec2::254), ULA fc00::/7, multicast, unspecified and IPv4-mapped forms are refused; schemes http and https only.", + "kind": "manual" + }, + { + "id": "dw-b6a9d2", + "text": "Redirects off by default (crates/id_effect_platform/src/http/mod.rs:299-302 builds the client without a redirect policy today, so reqwest follows up to 10); follow_redirects(max) is opt-in and re-checks the policy on every hop; allow_hosts / allow_cidrs allowlist, settable from config; dangerous_allow_private_networks() records a posture opt-out. MIGRATION-0.11 section \"SSRF default\".", + "kind": "manual" + }, + { + "id": "dw-9b96d5", + "text": "Existing tests that call wiremock on 127.0.0.1 through HttpClient (id_effect_platform, id_effect_mail, id_effect_auth oidc, Ledger) use the allowlist; the Ledger test profile allowlists its fakes in config/test.toml.", + "kind": "manual" + }, + { + "id": "dw-66d0ca", + "text": "Regression tests `m3_ssrf_blocks_loopback_private_and_metadata`, `m3_ssrf_checks_every_resolved_address`, `m3_ipv4_mapped_ipv6_is_blocked`, `m3_redirects_not_followed_by_default`, `m3_followed_redirect_to_private_ip_is_blocked`, `m3_allowlist_permits_named_host`.", + "kind": "manual" + }, + { + "id": "dw-e38aa4", + "text": "Gate: `cargo nextest run -p id_effect_platform -p id_effect_mail -p id_effect_auth --all-features`; `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p ledger` (Docker required)", + "kind": "manual" + }, + { + "id": "dw-5b959d", + "text": "Owns: `crates/id_effect_platform/{src/http/**,Cargo.toml,tests/ssrf.rs}`, fixture lines in existing files under `crates/id_effect_mail/tests/**`, `crates/id_effect_auth/tests/**` and `examples/reference/ledger/tests/**`, `examples/reference/ledger/config/test.toml`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sec-client-ssrf", + "lockedBy": "r11-sec-client-ssrf", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvet31ys-gowf3l/v1.json b/.maestro/contracts/tsk-mvet31ys-gowf3l/v1.json new file mode 100644 index 000000000..557a32b67 --- /dev/null +++ b/.maestro/contracts/tsk-mvet31ys-gowf3l/v1.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-a07b64", + "taskId": "tsk-mvet31ys-gowf3l", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T05:20:45.689Z", + "lockedAt": "2026-10-19T05:20:45.689Z", + "intent": "r11-w3-kit-template", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-7b6474", + "text": "id_effect_kit features workflow (id_effect_workflow), mesh-mtls and kubernetes; full updated; the prelude exposes id_effect::stm.", + "kind": "manual" + }, + { + "id": "dw-64ec09", + "text": "ADR 0013 R10: id_effect_kit::posture::check(profile) returns a PostureReport with named probes — console guarded, docs closed, mesh transport encrypted, redaction policy non-empty, SSRF guard on, CSRF on, load shedding on, no memory-only security state, and no recorded dangerous opt-out that is not listed in config `security.accepted_risks` with a reason; under the prod profile startup fails on any failed probe; dev and test profiles report only.", + "kind": "manual" + }, + { + "id": "dw-7cd9bb", + "text": "The service, worker and cluster templates call the posture check at startup; the cluster template configures mTLS with PEM paths (dev certificates for local runs); the service template passes the prod posture check as generated.", + "kind": "manual" + }, + { + "id": "dw-a5db2d", + "text": "scripts/check-dangerous.sh proves every `pub fn dangerous_*` / `insecure_*` under crates/ calls id_effect::observability::posture::record_dangerous, with --self-test; added as one step to .github/workflows/lint.yml.", + "kind": "manual" + }, + { + "id": "dw-5a98c1", + "text": "Tests `posture_prod_fails_on_unaccepted_opt_out`, `posture_accepted_risk_needs_reason`, `posture_report_lists_probes`, `template_service_passes_prod_posture`.", + "kind": "manual" + }, + { + "id": "dw-3507b7", + "text": "Gate: `cargo nextest run -p id_effect_kit --all-features`; `bash scripts/template-smoke.sh`; `bash scripts/check-dangerous.sh --self-test && bash scripts/check-dangerous.sh`; `DX_KPIS_RELEASE=0.11 bash scripts/dx-kpis.sh` K2 ≤ 8 s", + "kind": "manual" + }, + { + "id": "dw-6f0771", + "text": "Owns: `crates/id_effect_kit/**` (incl. new `src/posture.rs`, `tests/posture.rs`), `crates/id_effect_tool/templates/**`, `scripts/check-dangerous.sh`, one step in `.github/workflows/lint.yml`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-w3-kit-template", + "lockedBy": "r11-w3-kit-template", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mveutmw9-l3uhuv/v1.json b/.maestro/contracts/tsk-mveutmw9-l3uhuv/v1.json new file mode 100644 index 000000000..ceb7554e7 --- /dev/null +++ b/.maestro/contracts/tsk-mveutmw9-l3uhuv/v1.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 2, + "id": "c-3e4a1c", + "taskId": "tsk-mveutmw9-l3uhuv", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T06:09:27.526Z", + "lockedAt": "2026-10-19T06:09:27.526Z", + "intent": "r11-e11-4-compile-fail", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-eab9ea", + "text": "tests/security_compile_fail (unpublished, created by r11-t0-skeletons) runs trybuild over at least 15 cases, each file named after its rule or finding and listed in docs/security/compile-fail.md with its ADR 0013 rule: h8_route_without_access_decision, h8_undecided_endpoint_into_router, h8_guard_needs_route_guard_type, h8_public_and_guard_together, m16_query_with_string, m16_execute_with_string, m16_query_raw_without_capability, m16_raw_sql_capability_has_no_public_constructor, m13_secret_of_non_zeroize_type, l3_generic_secret_equality_removed, secret_is_not_serialize, r8_plaintext_tcp_without_feature, r10_accepted_risk_without_reason, m19_cross_tenant_without_opt_out, deny_deprecated_redaction_policy_new, deny_deprecated_route_unchecked (spares beyond 15 allowed).", + "kind": "manual" + }, + { + "id": "dw-6d3029", + "text": "Every `.stderr` snapshot is checked in against the toolchain pinned in rust-toolchain.toml; cases that the API answers with a `help:` note keep it in the snapshot.", + "kind": "manual" + }, + { + "id": "dw-bdb28f", + "text": "Gate: `cargo nextest run -p security_compile_fail`; `ls tests/security_compile_fail/ui/*.rs | wc -l` ≥ 15", + "kind": "manual" + }, + { + "id": "dw-acae7b", + "text": "Owns: `tests/security_compile_fail/**`, `docs/security/compile-fail.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e11-4-compile-fail", + "lockedBy": "r11-e11-4-compile-fail", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mveuyqcl-h6cafd/v1.json b/.maestro/contracts/tsk-mveuyqcl-h6cafd/v1.json new file mode 100644 index 000000000..4dcf52060 --- /dev/null +++ b/.maestro/contracts/tsk-mveuyqcl-h6cafd/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-960273", + "taskId": "tsk-mveuyqcl-h6cafd", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T06:13:22.252Z", + "lockedAt": "2026-10-19T06:13:22.252Z", + "intent": "r11-w3-ledger", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-051666", + "text": "The Ledger (built only through id_effect_kit) passes the prod posture check with zero accepted risks; every route is public or guarded and an access-report snapshot test lists the public routes; the console is off in prod and on in dev behind its guard; the SendGrid host is allowlisted in the SSRF policy; no RawSqlCapability is provided; `cargo dylint` with raw_sql_non_literal is clean on the Ledger.", + "kind": "manual" + }, + { + "id": "dw-68cde7", + "text": "Account closure runs as a durable workflow: an approval signal, a 30-day grace timer (under the test clock), a statement-export activity on the job queue and saga compensation; this makes the Ledger the reference-app consumer of id_effect_workflow, whose tier moves to platform (check P).", + "kind": "manual" + }, + { + "id": "dw-7c2875", + "text": "Tests `ledger_prod_posture_is_clean`, `ledger_access_report_snapshot`, `h8_ledger_unregistered_route_denied`, `m6_ledger_console_disabled_in_prod_profile`, `account_closure_workflow_survives_restart`; the reference-ledger CI job runs them with Postgres, Redis, Mailpit and the S3 container.", + "kind": "manual" + }, + { + "id": "dw-b2a21d", + "text": "Gate: `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p ledger` (Docker and Postgres required); `bash scripts/tier-conformance.sh` (all PASS)", + "kind": "manual" + }, + { + "id": "dw-e4d06b", + "text": "Owns: `examples/reference/ledger/**`, `.github/workflows/ci.yml` (reference-ledger job), the tier line in `crates/id_effect_workflow/Cargo.toml`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-w3-ledger", + "lockedBy": "r11-w3-ledger", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvey8ngt-tghgvt/v1.json b/.maestro/contracts/tsk-mvey8ngt-tghgvt/v1.json new file mode 100644 index 000000000..a4917fa94 --- /dev/null +++ b/.maestro/contracts/tsk-mvey8ngt-tghgvt/v1.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 2, + "id": "c-44b421", + "taskId": "tsk-mvey8ngt-tghgvt", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T07:45:03.560Z", + "lockedAt": "2026-10-19T07:45:03.560Z", + "intent": "r11-t0-migration-guide", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-ded175", + "text": "docs/MIGRATION-0.11.md has one section per break reported by `cargo semver-checks` against 0.10.1, each with its shim or replacement (L9): typed routes need .public()/.guard() (shim Router::route_unchecked); query/execute take Sql (escape hatch query_raw + RawSqlCapability); id_effect_process Next/Info/SendError become #[non_exhaustive] with new variants; RedactionPolicy::new → RedactionPolicy::empty; Secret requires Zeroize and compares in constant time; id_effect_fp::stm → id_effect::stm; HttpClient SSRF guard on and redirects off by default; plaintext TCP behind feature insecure-plaintext-tcp; masked CSRF token format; mesh protocol version bump (older nodes refused).", + "kind": "manual" + }, + { + "id": "dw-112876", + "text": "CHANGELOG 0.11.0 has a Scorecard section (index deltas from REPORT-0.11.0.md) and a Security section (findings closed with their regression tests, and every new dangerous_*/insecure_* opt-out).", + "kind": "manual" + }, + { + "id": "dw-ac1d7a", + "text": "Gate: `cargo semver-checks` output is a subset of the MIGRATION-0.11 list (the comparison is attached to the leaf evidence)", + "kind": "manual" + }, + { + "id": "dw-c7dd7c", + "text": "Owns: `docs/MIGRATION-0.11.md`, `CHANGELOG.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-t0-migration-guide", + "lockedBy": "r11-t0-migration-guide", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/contracts/tsk-mvey915n-8cs2b5/v1.json b/.maestro/contracts/tsk-mvey915n-8cs2b5/v1.json new file mode 100644 index 000000000..70b920bcd --- /dev/null +++ b/.maestro/contracts/tsk-mvey915n-8cs2b5/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-2123cb", + "taskId": "tsk-mvey915n-8cs2b5", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T07:45:20.957Z", + "lockedAt": "2026-10-19T07:45:20.957Z", + "intent": "r11-w3-kpis", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-e06723", + "text": "docs/security/findings.json registers all 41 findings of the 2026-10-18 review (H1–H8, M1–M20, L1–L6, S1–S7) with severity, status (closed / accepted), the release and leaf that closed it, and the regression test or gate.", + "kind": "manual" + }, + { + "id": "dw-b55f79", + "text": "scripts/security-kpis.sh (with --self-test) writes docs/security/kpis-0.11.json: open highs (0), OpenSSF Scorecard score (from the scorecard workflow result, ≥ 7.5), Best Practices status (Passing), SLSA level and the count of published crates with a verified attestation (all of them), fuzz targets in CI (≥ 8) and open crashes (0), compile-fail security cases (≥ 15), secure-stack / bare bench ratio (≤ 1.15, final stack).", + "kind": "manual" + }, + { + "id": "dw-876d74", + "text": "DX KPIs: `DX_KPIS_RELEASE=0.11 bash scripts/dx-kpis.sh` writes docs/benchmarks/dx-kpis-0.11.json with K1 ≤ 180 s, K2 ≤ 8 s, K4 ≥ 17/20, K5 ≥ 80 %, K3 and K6 recorded.", + "kind": "manual" + }, + { + "id": "dw-b0ed32", + "text": "Gate: `bash scripts/security-kpis.sh --self-test && bash scripts/security-kpis.sh 0.11.0`; `DX_KPIS_RELEASE=0.11 bash scripts/dx-kpis.sh`", + "kind": "manual" + }, + { + "id": "dw-9e6fec", + "text": "Owns: `docs/security/{findings.json,kpis-0.11.json}`, `scripts/security-kpis.sh`, `docs/benchmarks/dx-kpis-0.11.json`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-w3-kpis", + "lockedBy": "r11-w3-kpis", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/contracts/tsk-mvf4c3oa-dc40fo/v1.json b/.maestro/contracts/tsk-mvf4c3oa-dc40fo/v1.json new file mode 100644 index 000000000..caef0b7c7 --- /dev/null +++ b/.maestro/contracts/tsk-mvf4c3oa-dc40fo/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-2e2040", + "taskId": "tsk-mvf4c3oa-dc40fo", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T10:35:43.284Z", + "lockedAt": "2026-10-19T10:35:43.284Z", + "intent": "r12-t0-adr0014", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-0845c6", + "text": "docs/adrs/0014-one-call-surface.md (status Proposed) records: RPC is the first-party call surface and REST + OpenAPI stays the public API style; GraphQL is a non-goal (L15); HTTP/3 is a standard transport with TCP fallback always on and one config flag (server.http3.enabled) to turn it off (D4); crate placement (D3); the shared QUIC endpoint in id_effect_platform (D6) and its client-authentication rule (D7); TLS + HTTP/2 on TCP (D10); the contract artifact (D11); encodings (D13); and every row of the release spec Security table as a rule with its enforcement point.", + "kind": "manual" + }, + { + "id": "dw-497fd4", + "text": "D5 recorded in the ADR and in PLAN-OF-PLANS §5 T12 and §6 row 0.12.0: the unary gate is an absolute delta (rpc/unary_json − rpc/axum_json_handler ≤ 3 µs, same run and runner; the ratio is informational) with the reason (0.11 inline request fibers, bf7be935: bare request 0.90 µs, so 1.10× leaves about 90 ns).", + "kind": "manual" + }, + { + "id": "dw-f02991", + "text": "docs/scorecard/scorecard.json: web.rpc target 0.12.0 88 → 100 (D2); data.orm, web.ui, web.admin, ai.llm, eco.i18n, eco.tenancy target key 0.12.0 → 0.13.0 (D1, values unchanged); no other row changes. PLAN-OF-PLANS §6 rows 0.12.0 and 0.13.0 restated from scorecard.sh --targets (0.12.0: E 80.6 #1, RT 84.4, red ≤ 7; 0.13.0: E 84.0, RT 86.2, red ≤ 3) and a §11 change-log row names D1, D2, D5 and this leaf.", + "kind": "manual" + }, + { + "id": "dw-09285d", + "text": "If the owner overturns D1 before this leaf merges, only the web.rpc change is made and the release spec scorecard block switches to stored_plan_if_D1_overturned.", + "kind": "manual" + }, + { + "id": "dw-d603fb", + "text": "Gate: `bash scripts/scorecard.sh --validate --self-test`; `bash scripts/scorecard.sh --targets 0.12.0` prints E 80.6 (#1), RT 84.4 (#1), <40 = 7; `bash scripts/scorecard.sh --targets 0.13.0` prints E 84.0; `bash .tmp/plan12/scope.sh` reproduces the release spec table", + "kind": "manual" + }, + { + "id": "dw-ad56e9", + "text": "Owns: `docs/adrs/0014-one-call-surface.md`, `docs/scorecard/scorecard.json` (targets only), `.maestro/specs/roadmap/PLAN-OF-PLANS.md` (§5 T12, §6, §11), the `scorecard:` block of `.maestro/specs/releases/0.12.0.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "a0-roadmap", + "lockedBy": "a0-roadmap", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvf4c3s4-xn4847/v1.json b/.maestro/contracts/tsk-mvf4c3s4-xn4847/v1.json new file mode 100644 index 000000000..b0325e864 --- /dev/null +++ b/.maestro/contracts/tsk-mvf4c3s4-xn4847/v1.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-69fb70", + "taskId": "tsk-mvf4c3s4-xn4847", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T10:35:43.406Z", + "lockedAt": "2026-10-19T10:35:43.406Z", + "intent": "r12-t0-skeletons", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-55b886", + "text": "ADR 0010 Amendment 8 (after Amendment 7, docs/adrs/0010-crate-tiers.md:220) names: id_effect_rpc (published, enters tier integration, moves to platform when the Relay depends on it; leaves the labs row); id_effect_rpc_macros (published proc-macro crate, tier integration); the id_effect_platform features tls, quic, http3, acme and the move of crates/id_effect_node/src/transport/certs.rs into id_effect_platform::tls with a one-minor deprecated re-export in node (rule 3, L9); examples/reference/relay (tier example, publish = false).", + "kind": "manual" + }, + { + "id": "dw-b68169", + "text": "`git mv labs/id_effect_rpc crates/id_effect_rpc` (history kept); labs/Cargo.toml members lose id_effect_rpc; path dependencies rewritten (../../crates/x → ../x); the crate builds and its existing tests (tests/{codegen_stub,openapi_emit,wire_round_trip}.rs) pass unchanged; publish = false and tier labs stay until r12-e12-1-promote.", + "kind": "manual" + }, + { + "id": "dw-0750c5", + "text": "crates/id_effect_rpc_macros skeleton: proc-macro = true, workspace version, README with a rust fence, #![deny(missing_docs)], one test; examples/reference/relay skeleton: Cargo.toml (package relay, publish = false, depends on id_effect_kit only), README, src/main.rs, one test.", + "kind": "manual" + }, + { + "id": "dw-2d4cf9", + "text": "Root Cargo.toml members gain crates/id_effect_rpc, crates/id_effect_rpc_macros, examples/reference/relay; scripts/validate-crate-packaging.sh LEVEL/ORDER: --print-order lists id_effect_rpc_macros, then id_effect_rpc after id_effect_axum, id_effect_platform, id_effect_node and id_effect_events, and both before id_effect_kit and id_effect_tool; crates.io availability of both names recorded in the leaf evidence (generic User-Agent, no personal data); .github/workflows/ci.yml -p lines include both crates and the labs job no longer lists id_effect_rpc.", + "kind": "manual" + }, + { + "id": "dw-6a2b16", + "text": "docs/MIGRATION-0.12.md created with one heading per planned break (node transport::certs move, axum TLS/HTTP/3 config fields, HttpClientConfig::http3, QUIC mesh ALPN idm/1, labs RpcGroup::register → #[rpc::service], OpenAPI 3.0 → 3.1 output, template server.http3).", + "kind": "manual" + }, + { + "id": "dw-dfe0c3", + "text": "Gate: `bash scripts/validate-crate-packaging.sh`; `bash scripts/validate-crate-packaging.sh --print-order`; `cargo nextest run -p id_effect_rpc -p id_effect_rpc_macros -p relay`; `cargo nextest run --manifest-path labs/Cargo.toml --workspace`", + "kind": "manual" + }, + { + "id": "dw-fa8192", + "text": "Owns: `docs/adrs/0010-crate-tiers.md`, root `Cargo.toml` (members), `labs/Cargo.toml`, `crates/id_effect_rpc/**` (move only; r12-e12-1-promote owns it next), `crates/id_effect_rpc_macros/**` (skeleton; r12-e12-1-macro next), `examples/reference/relay/**` (skeleton; r12-w7-relay next), `scripts/validate-crate-packaging.sh`, `.github/workflows/ci.yml` (-p lines, labs job), `docs/MIGRATION-0.12.md` (headings)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "a1-governance", + "lockedBy": "a1-governance", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvf4xez4-00j1za/v1.json b/.maestro/contracts/tsk-mvf4xez4-00j1za/v1.json new file mode 100644 index 000000000..825717b53 --- /dev/null +++ b/.maestro/contracts/tsk-mvf4xez4-00j1za/v1.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 2, + "id": "c-4d02a6", + "taskId": "tsk-mvf4xez4-00j1za", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T10:52:16.238Z", + "lockedAt": "2026-10-19T10:52:16.238Z", + "intent": "r12-e12-1-promote", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-767d3c", + "text": "crates/id_effect_rpc passes tier integration: publish = false removed, [package.metadata.id_effect] tier = \"integration\", #![deny(missing_docs)], README with a runnable rust fence, coverage at the QUALITY_BAR floor, test density ≥ 1 test per 80 lines; labs wording removed from book part2/ch07-12-rpc-boundaries.md and part6/ch29-00-api-boundaries.md; new chapter part6/ch33-00-one-call-surface.md (overview, links to ch33-01..12).", + "kind": "manual" + }, + { + "id": "dw-2459ee", + "text": "Cargo.toml declares, once, every 0.12 feature and optional dependency later leaves need (client, postcard, ws, sse, events, mesh, channels, http3, webtransport, test-util; id_effect_rpc_macros, postcard, id_effect_events, id_effect_node, id_effect_axum features ws/http3/webtransport, the tungstenite major that axum ws uses), so no later leaf edits Cargo.toml.", + "kind": "manual" + }, + { + "id": "dw-3e64f5", + "text": "Module layout: contract (serde model of a service contract: services, versions, procedures, kinds, input/output/error JSON Schemas, access decision; D11), codec/{mod,json}.rs (Codec trait + JSON), server/{mod,dispatch,http}.rs, client/{mod,http}.rs, wire (envelope types). src/codegen.rs (trait stub strings) is removed. lib.rs becomes an append-only shared file for later leaves (one pub mod / pub use line each).", + "kind": "manual" + }, + { + "id": "dw-5fe507", + "text": "H8: the /rpc mount is a declared typed route; the low-level RpcGroup requires .public() or .guard(g) per procedure (typestate as id_effect_axum routes) and an unknown tag answers 404 Problem.", + "kind": "manual" + }, + { + "id": "dw-7ac803", + "text": "Design note .maestro/specs/releases/0.12/r12-rpc-design.md: contract model, the six kinds and their HTTP mapping, the WebSocket/WebTransport frame format (call id, kind, flags, cursor), the cursor format, the batch body, the error union encoding, codec negotiation names. It is reviewed (owner or coordinator) before r12-e12-1-kinds starts; later leaves follow it.", + "kind": "manual" + }, + { + "id": "dw-75f963", + "text": "Tests `rpc_unregistered_tag_is_404_problem`, `rpc_group_requires_access_decision`, `contract_json_round_trips`, plus the moved tests still green.", + "kind": "manual" + }, + { + "id": "dw-4bb9b2", + "text": "Gate: `bash scripts/tier-conformance.sh id_effect_rpc`; `cargo nextest run -p id_effect_rpc --all-features`; `cargo test -p id_effect_rpc --doc`; `cargo clippy -p id_effect_rpc --all-targets --all-features -- -D warnings`", + "kind": "manual" + }, + { + "id": "dw-c57486", + "text": "Owns: `crates/id_effect_rpc/**` (all files in wave 1), `.maestro/specs/releases/0.12/r12-rpc-design.md`, `crates/id_effect/book/src/part2/ch07-12-rpc-boundaries.md`, `crates/id_effect/book/src/part6/ch29-00-api-boundaries.md`, `crates/id_effect/book/src/part6/ch33-00-one-call-surface.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "b1-rpc", + "lockedBy": "b1-rpc", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvf4xf42-qjodki/v1.json b/.maestro/contracts/tsk-mvf4xf42-qjodki/v1.json new file mode 100644 index 000000000..fcbfc6922 --- /dev/null +++ b/.maestro/contracts/tsk-mvf4xf42-qjodki/v1.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 2, + "id": "c-8595ef", + "taskId": "tsk-mvf4xf42-qjodki", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T10:52:16.361Z", + "lockedAt": "2026-10-19T10:52:16.361Z", + "intent": "r12-e12-2-axum-ws-sse", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-09bca3", + "text": "web.realtime#1: effect-native WebSocket endpoints (axum ws feature) on typed routes that declare public or guarded access (H8); the socket is a pull-based Stream plus a Sink inside a supervised fiber, so a dropped client interrupts its handler and runs finalizers.", + "kind": "manual" + }, + { + "id": "dw-090809", + "text": "web.realtime#2: an SSE helper with event ids and Last-Event-ID resume; web.realtime#8: heartbeats (ping every 20 s, two missed pongs close the socket) and an idle timeout, on the executor clock (TestClock in tests).", + "kind": "manual" + }, + { + "id": "dw-173bac", + "text": "web.realtime#6 (connect half) and ADR 0013 R9: authentication on connect through the existing Authenticate layer; the Origin header is checked on upgrade against a configured allowlist, an empty list refuses every browser origin, and dangerous_any_origin() records a posture opt-out; the dev profile allows http://localhost:* by configuration.", + "kind": "manual" + }, + { + "id": "dw-655cc3", + "text": "ADR 0013 R4: frame size 64 KiB (close 1009 above it), message rate per socket and sockets per client IP bounded by default (config).", + "kind": "manual" + }, + { + "id": "dw-4ae3a9", + "text": "id_effect_axum/Cargo.toml declares, once, the features ws, sse, tls, http3, webtransport and their optional dependencies (tokio-rustls, hyper-util http2, h3 =0.0.8, h3-quinn =0.0.10, h3-webtransport =0.1.2, quinn, id_effect_platform/tls and /quic) for r12-e12-3-tls-h2, r12-e12-3-h3-server and r12-e12-3-webtransport.", + "kind": "manual" + }, + { + "id": "dw-145a8f", + "text": "Tests `ws_round_trip_under_test_clock`, `sse_resumes_from_last_event_id`, `heartbeat_closes_dead_connection`, `dropped_socket_interrupts_handler`, `r9_ws_upgrade_from_foreign_origin_refused`, `r4_ws_frame_over_max_closes_1009`, `r4_ws_sockets_per_ip_capped`, `dangerous_any_origin_is_recorded`; examples 090_ws_echo.rs, 091_sse_ticker.rs; book part6/ch33-05-websocket-sse.md.", + "kind": "manual" + }, + { + "id": "dw-019f2b", + "text": "Gate: `cargo nextest run -p id_effect_axum --all-features`; `cargo run -p id_effect_axum --features ws --example 090_ws_echo -- --self-test`; `bash scripts/check-dangerous.sh`", + "kind": "manual" + }, + { + "id": "dw-4cf521", + "text": "Owns: `crates/id_effect_axum/Cargo.toml`, `crates/id_effect_axum/src/realtime/{mod.rs,ws.rs,sse.rs,origin.rs}`, `crates/id_effect_axum/tests/{ws.rs,sse.rs}`, `crates/id_effect_axum/examples/{090_ws_echo.rs,091_sse_ticker.rs}`, `crates/id_effect/book/src/part6/ch33-05-websocket-sse.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "b2-axum", + "lockedBy": "b2-axum", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvf4xf8t-hyoe72/v1.json b/.maestro/contracts/tsk-mvf4xf8t-hyoe72/v1.json new file mode 100644 index 000000000..b095da8f9 --- /dev/null +++ b/.maestro/contracts/tsk-mvf4xf8t-hyoe72/v1.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 2, + "id": "c-9f4505", + "taskId": "tsk-mvf4xf8t-hyoe72", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T10:52:16.481Z", + "lockedAt": "2026-10-19T10:52:16.481Z", + "intent": "r12-e12-3-quic-endpoint", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-050448", + "text": "D6: id_effect_platform features tls (certificates, Identity, rustls client/server configs, moved from crates/id_effect_node/src/transport/certs.rs:24-597 with history) and quic (QuicEndpoint with an AlpnRouter for h3 and idm/1); id_effect_node::transport::certs stays as a #[deprecated(note = \"use id_effect_platform::tls\")] re-export for one minor (L9); node features quic and tls-tcp depend on id_effect_platform with default-features = false; no dependency cycle (`cargo tree -i id_effect_node -p id_effect_platform` is empty).", + "kind": "manual" + }, + { + "id": "dw-86f865", + "text": "Endpoint defaults (ADR 0013 R4, R8): address validation (Retry) on, 0-RTT off, finite max concurrent bidi/uni streams, idle timeout, max UDP payload and receive window; each loosening is a dangerous_* opt-out recorded in the posture registry.", + "kind": "manual" + }, + { + "id": "dw-6b9e0f", + "text": "D7: when idm/1 and h3 share one endpoint, client certificates are requested as optional with no CA hints, a presented certificate is still verified against the cluster CA, and the idm/1 handler refuses a connection without a verified client chain before reading any frame; quic.mesh_port (config) gives the mesh its own endpoint with handshake-level mutual TLS as in 0.11.", + "kind": "manual" + }, + { + "id": "dw-ae5fb0", + "text": "The mesh QuicTransport runs on a QuicEndpoint (QuicTransport::on_endpoint) and requires ALPN idm/1; the 0.11 tests `mtls_rejects_peer_without_client_cert`, `mtls_rejects_cert_from_other_ca`, `pem_chain_and_key_load`, `rotated_cert_used_for_new_connections` pass on both shapes (dist.tls #2 #3 #4 #5 #7 kept).", + "kind": "manual" + }, + { + "id": "dw-bb8f3c", + "text": "id_effect_platform/Cargo.toml declares, once, features tls, quic, http3, acme and their deps (rustls 0.23.46+, quinn 0.11.12+ on aws-lc-rs, rcgen, h3 =0.0.8, h3-quinn =0.0.10, instant-acme 0.8) for r12-e12-3-h3-client and r12-e12-3-certs-acme; one quinn and one rustls in `cargo tree -d`.", + "kind": "manual" + }, + { + "id": "dw-2e6201", + "text": "Tests `alpn_routes_h3_and_idm`, `idm_without_client_cert_refused_on_shared_endpoint`, `idm_cert_from_other_ca_refused_on_shared_endpoint`, `unknown_alpn_refused`, `r4_quic_retry_on_by_default`, `r4_quic_limits_finite_by_default`, `mesh_port_splits_endpoint`, `deprecated_node_certs_path_still_compiles`; book part7/ch38-01-transport-security.md gains a \"Shared endpoint\" section.", + "kind": "manual" + }, + { + "id": "dw-a4b3e4", + "text": "Gate: `cargo nextest run -p id_effect_platform -p id_effect_node --all-features`; `cargo run -p id_effect_node --features quic --example 134_mtls_cluster`; `cargo deny check`; `cargo tree -d -p id_effect_node --all-features` lists no second quinn or rustls", + "kind": "manual" + }, + { + "id": "dw-c3bb40", + "text": "Owns: `crates/id_effect_platform/Cargo.toml`, `crates/id_effect_platform/src/tls/{mod.rs,certs.rs}`, `crates/id_effect_platform/src/quic/**`, `crates/id_effect_platform/tests/quic_endpoint.rs`, `crates/id_effect_node/Cargo.toml`, `crates/id_effect_node/src/transport/{certs.rs,quic.rs,mod.rs}`, `crates/id_effect_node/tests/{transport_tls.rs,shared_endpoint.rs}`, `crates/id_effect/book/src/part7/ch38-01-transport-security.md` (section)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "b3-quic", + "lockedBy": "b3-quic", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvf70f4h-isuu0c/v1.json b/.maestro/contracts/tsk-mvf70f4h-isuu0c/v1.json new file mode 100644 index 000000000..0a19ef662 --- /dev/null +++ b/.maestro/contracts/tsk-mvf70f4h-isuu0c/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-e0acee", + "taskId": "tsk-mvf70f4h-isuu0c", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T11:50:37.106Z", + "lockedAt": "2026-10-19T11:50:37.106Z", + "intent": "r12-e12-3-tls-h2", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-fea978", + "text": "D10: axum feature tls serves TLS 1.3 (1.2 allowed) on TCP with ALPN h2 and http/1.1 from the id_effect_platform::tls Identity; new connections use a reloaded certificate without a restart; hyper-util auto builder gains http2.", + "kind": "manual" + }, + { + "id": "dw-84a46b", + "text": "The 0.11 ConnectionLimits (crates/id_effect_axum/src/server/connection.rs:65) apply to TLS connections and HTTP/2: header read timeout, connection cap, requests per connection, plus a finite HTTP/2 max concurrent streams and a TLS handshake timeout; each loosening is a recorded dangerous_* opt-out.", + "kind": "manual" + }, + { + "id": "dw-31b042", + "text": "HostConfig gains a tls section (cert/key paths or an Identity provider); plain HTTP stays the default without it; prod posture warns when HTTP/3 is enabled without TLS (no Alt-Svc is sent over plain HTTP).", + "kind": "manual" + }, + { + "id": "dw-162475", + "text": "Tests `tls_h2_round_trip`, `tls_http1_round_trip`, `tls_reloaded_cert_used_for_new_connections`, `h2_concurrent_streams_capped`, `tls_handshake_timeout_closes_slow_client`; book part6/ch29-04-tls-http2.md.", + "kind": "manual" + }, + { + "id": "dw-664e08", + "text": "Gate: `cargo nextest run -p id_effect_axum --features tls`; `cargo nextest run -p id_effect_axum --all-features`; `bash scripts/check-dangerous.sh`", + "kind": "manual" + }, + { + "id": "dw-ef5554", + "text": "Owns: `crates/id_effect_axum/src/server/{connection.rs,tls.rs,bootstrap.rs,mod.rs}`, `crates/id_effect_axum/tests/tls_h2.rs`, `crates/id_effect/book/src/part6/ch29-04-tls-http2.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "c1-tls-h2", + "lockedBy": "c1-tls-h2", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvf70fjh-fmn5my/v1.json b/.maestro/contracts/tsk-mvf70fjh-fmn5my/v1.json new file mode 100644 index 000000000..75af8779f --- /dev/null +++ b/.maestro/contracts/tsk-mvf70fjh-fmn5my/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-0fb2aa", + "taskId": "tsk-mvf70fjh-fmn5my", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T11:50:37.239Z", + "lockedAt": "2026-10-19T11:50:37.239Z", + "intent": "r12-e12-3-h3-client", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3bc483", + "text": "HttpClientConfig::http3(bool) (opt-in for the client): requests go over h3 + h3-quinn when an Alt-Svc entry for the origin is known; the client never uses reqwest HTTP/3 and the workspace never sets --cfg reqwest_unstable.", + "kind": "manual" + }, + { + "id": "dw-ccda32", + "text": "Alt-Svc cache: max-age honoured, bounded entries (LRU), cleared on a network-change error; fallback to HTTP/2 when the QUIC handshake does not complete within 300 ms, with a 5-minute negative cache per origin; the HTTP/2 request is never delayed by more than the fallback timer.", + "kind": "manual" + }, + { + "id": "dw-f22c76", + "text": "The 0.11 SSRF guard (SsrfPolicy, crates/id_effect_platform/src/http/ssrf.rs) checks every resolved address on the h3 path too; timeouts, retries and the body cap (DEFAULT_MAX_BODY_BYTES, http/mod.rs:68) apply unchanged; TLS uses the platform webpki roots and verifies the server name.", + "kind": "manual" + }, + { + "id": "dw-0522df", + "text": "Tests `alt_svc_upgrades_next_request_to_h3`, `udp_blackhole_falls_back_to_h2_within_budget`, `negative_cache_skips_h3_after_failure`, `h3_path_enforces_ssrf_policy`, `h3_body_cap_enforced`, `h3_rejects_wrong_server_name`; book part6/ch29-08-http3-client.md.", + "kind": "manual" + }, + { + "id": "dw-1a7464", + "text": "Gate: `cargo nextest run -p id_effect_platform --features http3`; `cargo nextest run -p id_effect_platform --all-features`; `! git grep -n reqwest_unstable -- '*.toml' '*.rs' '*.yml' '*.nix'`", + "kind": "manual" + }, + { + "id": "dw-e82485", + "text": "Owns: `crates/id_effect_platform/src/http/{h3.rs,alt_svc.rs,mod.rs}`, `crates/id_effect_platform/tests/http3_client.rs`, `crates/id_effect/book/src/part6/ch29-08-http3-client.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "c2-h3-client", + "lockedBy": "c2-h3-client", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvf70fq3-r9ruuc/v1.json b/.maestro/contracts/tsk-mvf70fq3-r9ruuc/v1.json new file mode 100644 index 000000000..5dd5a8a85 --- /dev/null +++ b/.maestro/contracts/tsk-mvf70fq3-r9ruuc/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-c44ef8", + "taskId": "tsk-mvf70fq3-r9ruuc", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T11:50:37.491Z", + "lockedAt": "2026-10-19T11:50:37.491Z", + "intent": "r12-e12-3-certs-acme", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-1ada82", + "text": "Reload: the CertReloader (moved from node in r12-e12-3-quic-endpoint) drives the QUIC endpoint and the TCP TLS listener from one Identity; new connections use the new certificate, existing ones are not cut; dist.tls#7 stays met for the mesh.", + "kind": "manual" + }, + { + "id": "dw-b842b7", + "text": "D8: platform feature acme issues and renews certificates with instant-acme over TLS-ALPN-01 (the acme-tls/1 ALPN answered by the rustls certificate resolver), renewal at two thirds of the lifetime with jitter, the account key stored with mode 0600, the directory URL from config (Lets Encrypt staging by default in non-prod profiles); a failed order keeps the current certificate and is logged and counted; DNS-01 is a non-goal.", + "kind": "manual" + }, + { + "id": "dw-cf4789", + "text": "Tests `reload_swaps_identity_on_endpoint_and_tcp`, `existing_connections_survive_reload`, `acme_issues_and_renews_against_pebble` (pinned Pebble image by digest, ID_EFFECT_REQUIRE_DOCKER=1), `acme_failure_keeps_current_cert`, `acme_account_key_mode_0600`; book part6/ch29-09-certificates-acme.md.", + "kind": "manual" + }, + { + "id": "dw-d089e9", + "text": "Gate: `cargo nextest run -p id_effect_platform --features tls,acme`; `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p id_effect_platform --features acme --test acme_pebble`", + "kind": "manual" + }, + { + "id": "dw-7d84a1", + "text": "Owns: `crates/id_effect_platform/src/tls/{reload.rs,acme.rs}`, `crates/id_effect_platform/tests/{tls_reload.rs,acme_pebble.rs}`, `crates/id_effect/book/src/part6/ch29-09-certificates-acme.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "c3-acme", + "lockedBy": "c3-acme", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvf7jpll-z8910m/v1.json b/.maestro/contracts/tsk-mvf7jpll-z8910m/v1.json new file mode 100644 index 000000000..bd3ec5a09 --- /dev/null +++ b/.maestro/contracts/tsk-mvf7jpll-z8910m/v1.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-1fb1ff", + "taskId": "tsk-mvf7jpll-z8910m", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T12:05:49.757Z", + "lockedAt": "2026-10-19T12:05:49.757Z", + "intent": "r12-e12-1-kinds", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-9fe2c9", + "text": "Six kinds per the design note: query (GET /rpc/?input= with ETag and If-None-Match → 304, or POST; retry-safe), mutation (POST; optional Idempotency-Key replayed from an IdempotencyStore trait with a bounded in-memory store, TTL 24 h, 100,000 keys, D14), server stream (NDJSON response), client stream (chunked NDJSON request with item and byte caps), and the runtime types Duplex and Subscription with an in-process transport (their wire mappings come in waves 3–4).", + "kind": "manual" + }, + { + "id": "dw-6b0ea2", + "text": "Typed errors: the handler E becomes a tagged union {\"_tag\": ...} in the contract and on the wire; defects (panics, Die) become InternalError { id } carrying only the correlation id; no message or backtrace reaches the client (ADR 0013 R3).", + "kind": "manual" + }, + { + "id": "dw-6733e0", + "text": "Cancellation: a client disconnect or cancel interrupts the handler fiber and its scope finalizers run (the 0.11 inline executor DropPolicy::Interrupt path); a per-call deadline header is capped by the server maximum.", + "kind": "manual" + }, + { + "id": "dw-0c0fac", + "text": "Browser CSRF (ADR 0013 R9): mutations require Content-Type application/json (or the postcard type) and the x-rpc header; the 0.11 CSRF layer applies to cookie sessions.", + "kind": "manual" + }, + { + "id": "dw-da9598", + "text": "Tests `query_get_returns_etag_and_304`, `mutation_idempotency_key_replays_result`, `client_stream_over_cap_rejected`, `typed_error_reaches_client_as_tagged_union`, `defect_becomes_internal_error_without_message`, `client_disconnect_interrupts_handler_and_runs_finalizers`, `deadline_capped_by_server_max`, `r9_rpc_mutation_without_custom_header_refused`, `r9_rpc_form_content_type_refused`; book part6/ch33-01-procedures.md.", + "kind": "manual" + }, + { + "id": "dw-65cc93", + "text": "Gate: `cargo nextest run -p id_effect_rpc --all-features`; `cargo clippy -p id_effect_rpc --all-targets --all-features -- -D warnings`", + "kind": "manual" + }, + { + "id": "dw-8fb969", + "text": "Owns: `crates/id_effect_rpc/src/{kinds.rs,handler.rs,cancel.rs,error_union.rs,idempotency.rs}`, `crates/id_effect_rpc/src/server/{mod.rs,dispatch.rs,http.rs}`, `crates/id_effect_rpc/tests/{kinds_http.rs,cancellation.rs,errors.rs}`, `crates/id_effect/book/src/part6/ch33-01-procedures.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "c4-kinds", + "lockedBy": "c4-kinds", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvf9rld7-x1htmf/v1.json b/.maestro/contracts/tsk-mvf9rld7-x1htmf/v1.json new file mode 100644 index 000000000..eceb06c5a --- /dev/null +++ b/.maestro/contracts/tsk-mvf9rld7-x1htmf/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-c1a71d", + "taskId": "tsk-mvf9rld7-x1htmf", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T13:07:50.304Z", + "lockedAt": "2026-10-19T13:07:50.304Z", + "intent": "r12-e12-2-rpc-ws-sse", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-b27ede", + "text": "web.rpc#5: every procedure kind over WebSocket frames multiplexed by call id (frame format from r12-rpc-design.md), subprotocols idrpc.json.v1 and idrpc.postcard.v1; subscriptions and server streams over SSE with mutations over POST next to it (the browser fallback).", + "kind": "manual" + }, + { + "id": "dw-3ef7e3", + "text": "Backpressure: streams are pull-based end to end, so a slow client slows its own stream and the server buffers at most the configured window per stream; per-connection stream cap and frame cap (ADR 0013 R4).", + "kind": "manual" + }, + { + "id": "dw-57a22b", + "text": "The Rust client chooses a transport (HTTP, WebSocket, SSE) from config; client/mod.rs holds the choice.", + "kind": "manual" + }, + { + "id": "dw-5c4c22", + "text": "Tests `every_kind_over_websocket`, `subscription_over_sse`, `mutation_over_post_next_to_sse`, `slow_ws_client_applies_backpressure`, `r4_ws_streams_per_connection_capped`, `ws_frame_decode_rejects_unknown_kind`; `transport::frame::decode` is public for the fuzz target; book part6/ch33-06-rpc-transports.md.", + "kind": "manual" + }, + { + "id": "dw-b2710d", + "text": "Gate: `cargo nextest run -p id_effect_rpc --features ws,sse`; `cargo nextest run -p id_effect_rpc --all-features`", + "kind": "manual" + }, + { + "id": "dw-77170d", + "text": "Owns: `crates/id_effect_rpc/src/transport/{mod.rs,ws.rs,sse.rs,frame.rs}`, `crates/id_effect_rpc/src/client/{mod.rs,ws.rs,sse.rs}`, `crates/id_effect_rpc/src/server/mod.rs` (transport mounts, after r12-e12-1-kinds), `crates/id_effect_rpc/tests/{transport_ws.rs,transport_sse.rs}`, `crates/id_effect/book/src/part6/ch33-06-rpc-transports.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "d1-rpc-ws-sse", + "lockedBy": "d1-rpc-ws-sse", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvf9rlgq-6v0ddu/v1.json b/.maestro/contracts/tsk-mvf9rlgq-6v0ddu/v1.json new file mode 100644 index 000000000..abcb1e2b3 --- /dev/null +++ b/.maestro/contracts/tsk-mvf9rlgq-6v0ddu/v1.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-65c93f", + "taskId": "tsk-mvf9rlgq-6v0ddu", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T13:07:50.430Z", + "lockedAt": "2026-10-19T13:07:50.430Z", + "intent": "r12-e12-1-batching", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-fbdc50", + "text": "POST /rpc with a JSON array body runs a batch: ids echoed, results in request order, each call authorized, rate-limited, traced and failing on its own; bounded parallelism through the compute admission (crates/id_effect/src/compute/admission.rs), an ordered flag runs calls in sequence.", + "kind": "manual" + }, + { + "id": "dw-e251c6", + "text": "One id_effect::request::RequestCache (crates/id_effect/src/request.rs:309) is scoped per batch, so duplicate loads through a RequestResolver are merged (the DataLoader equivalent).", + "kind": "manual" + }, + { + "id": "dw-53d1e3", + "text": "Finite defaults (ADR 0013 R4): at most 50 calls and 1 MiB per batch (config), 413 Problem otherwise; the rate limiter is charged N tokens for a batch of N (id_effect_axum middleware/rate_limit.rs gains a per-request charge).", + "kind": "manual" + }, + { + "id": "dw-61a99b", + "text": "The Rust client batches calls made in the same tick into one request (bounded window, opt-out per call).", + "kind": "manual" + }, + { + "id": "dw-ee5a2e", + "text": "Tests `batch_results_in_request_order_each_fails_alone`, `batch_dedups_duplicate_loads_through_request_cache`, `ordered_batch_runs_sequentially`, `r4_batch_over_limit_rejected_413`, `r4_batch_charges_n_rate_limit_tokens`, `client_batches_same_tick_calls`; `wire::decode_batch` is public for the fuzz target; book part6/ch33-02-batching.md.", + "kind": "manual" + }, + { + "id": "dw-c68e20", + "text": "Gate: `cargo nextest run -p id_effect_rpc -p id_effect_axum --all-features`", + "kind": "manual" + }, + { + "id": "dw-48c791", + "text": "Owns: `crates/id_effect_rpc/src/{batch.rs,client/http.rs}`, `crates/id_effect_rpc/src/server/http.rs` (batch branch, after r12-e12-1-kinds), `crates/id_effect_axum/src/middleware/rate_limit.rs`, `crates/id_effect_rpc/tests/batching.rs`, `crates/id_effect/book/src/part6/ch33-02-batching.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "d2-batching", + "lockedBy": "d2-batching", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvf9rlkh-oyyusj/v1.json b/.maestro/contracts/tsk-mvf9rlkh-oyyusj/v1.json new file mode 100644 index 000000000..dfc30d3af --- /dev/null +++ b/.maestro/contracts/tsk-mvf9rlkh-oyyusj/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-6c5676", + "taskId": "tsk-mvf9rlkh-oyyusj", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T13:07:50.557Z", + "lockedAt": "2026-10-19T13:07:50.557Z", + "intent": "r12-e12-1-macro", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f19d48", + "text": "#[rpc::service(version = N)] on a trait whose methods carry one of #[query] #[mutation] #[server_stream] #[client_stream] #[bidi] #[subscription] and one of #[public] / #[guard(G)] generates: the handler trait (each method an Effect, so DI, typed errors and TestExecutor apply), the Rust client generic over the transport (web.rpc#3), Service::contract() and the router mount; input, output and error types must be HasSchema (an on_unimplemented message names the derive).", + "kind": "manual" + }, + { + "id": "dw-179a70", + "text": "D11: id_effect_rpc::contract::assert_snapshot keeps rpc.contract.json current (UPDATE_GOLDEN=1 rewrites, as id_effect::testing::golden); two service versions can be served side by side (web.rpc#6).", + "kind": "manual" + }, + { + "id": "dw-5f8cfe", + "text": "trybuild compile-fail cases in crates/id_effect_rpc/tests/ui, each first error with a help: line (K4 style): procedure without access decision (ADR 0013 R1), two kinds on one method, non-HasSchema type, #[bidi] without a Duplex return, duplicate procedure names, missing version.", + "kind": "manual" + }, + { + "id": "dw-000f32", + "text": "Tests `generated_client_round_trip_every_kind`, `contract_snapshot_is_current`, `service_versions_served_side_by_side`, `guarded_procedure_runs_guard_before_handler`; book part6/ch33-04-service-macro.md.", + "kind": "manual" + }, + { + "id": "dw-32a289", + "text": "Gate: `cargo nextest run -p id_effect_rpc -p id_effect_rpc_macros --all-features`; `cargo test -p id_effect_rpc --test ui`; `bash scripts/tier-conformance.sh id_effect_rpc_macros`", + "kind": "manual" + }, + { + "id": "dw-26266b", + "text": "Owns: `crates/id_effect_rpc_macros/**`, `crates/id_effect_rpc/src/service.rs`, `crates/id_effect_rpc/tests/{ui/**,ui.rs,service_macro.rs}`, `crates/id_effect_rpc/rpc.contract.json`, `crates/id_effect/book/src/part6/ch33-04-service-macro.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "d3-macro", + "lockedBy": "d3-macro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvf9rlnr-ts175i/v1.json b/.maestro/contracts/tsk-mvf9rlnr-ts175i/v1.json new file mode 100644 index 000000000..6ffc21c2e --- /dev/null +++ b/.maestro/contracts/tsk-mvf9rlnr-ts175i/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-cf44a1", + "taskId": "tsk-mvf9rlnr-ts175i", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T13:07:50.681Z", + "lockedAt": "2026-10-19T13:07:50.681Z", + "intent": "r12-e12-1-postcard", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-344160", + "text": "D13: Codec negotiation per request (Content-Type / Accept: application/json default, application/vnd.id-effect.rpc+postcard) and per connection (WebSocket subprotocol names idrpc.json.v1 / idrpc.postcard.v1 reserved for r12-e12-2-rpc-ws-sse); an unknown codec answers 415 Problem.", + "kind": "manual" + }, + { + "id": "dw-dad56e", + "text": "The postcard path decodes with serde and then runs the Schema validators of the input type, so refinements and brands still apply; encoded size is capped before decode.", + "kind": "manual" + }, + { + "id": "dw-8e4410", + "text": "Tests `postcard_round_trip_every_kind`, `postcard_payload_still_runs_schema_refinements`, `unknown_codec_is_415`, `json_is_default`, `postcard_oversized_frame_rejected_before_decode`; `codec::postcard::decode_frame` is public for the fuzz target; book part6/ch33-03-encodings.md.", + "kind": "manual" + }, + { + "id": "dw-2e8c51", + "text": "Gate: `cargo nextest run -p id_effect_rpc --features postcard`; `cargo nextest run -p id_effect_rpc --all-features`", + "kind": "manual" + }, + { + "id": "dw-2bca06", + "text": "Owns: `crates/id_effect_rpc/src/codec/{mod.rs,postcard.rs}`, `crates/id_effect_rpc/tests/codec.rs`, `crates/id_effect/book/src/part6/ch33-03-encodings.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "d4-postcard", + "lockedBy": "d4-postcard", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvf9rlqz-0bc6j8/v1.json b/.maestro/contracts/tsk-mvf9rlqz-0bc6j8/v1.json new file mode 100644 index 000000000..26476f57c --- /dev/null +++ b/.maestro/contracts/tsk-mvf9rlqz-0bc6j8/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-3d5ca3", + "taskId": "tsk-mvf9rlqz-0bc6j8", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T13:07:50.801Z", + "lockedAt": "2026-10-19T13:07:50.801Z", + "intent": "r12-e12-2-subscriptions", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-d834ef", + "text": "A Subscription procedure can be backed by an id_effect_events EventLog (crates/id_effect_events/src/subscription.rs:39 read_all(after, limit)): the cursor is the global position, the stream replays from the cursor and then tails live events without a gap at the hand-over; an optional projection filter narrows the stream.", + "kind": "manual" + }, + { + "id": "dw-398adb", + "text": "Interrupting the subscriber releases the reader (no leaked polling fiber); the read batch size and the poll interval are bounded config.", + "kind": "manual" + }, + { + "id": "dw-1a90dd", + "text": "Tests `subscription_replays_from_cursor_then_tails`, `subscription_resume_has_no_gap_or_duplicate`, `subscription_interrupt_releases_reader` (memory EventLog), and the same three against Postgres under ID_EFFECT_REQUIRE_PG=1 (own container, never the ambient DATABASE_URL); book part6/ch33-08-subscriptions.md.", + "kind": "manual" + }, + { + "id": "dw-da26cc", + "text": "Gate: `cargo nextest run -p id_effect_rpc --features events`; `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p id_effect_rpc --features events --test subscription_events`", + "kind": "manual" + }, + { + "id": "dw-34d66a", + "text": "Owns: `crates/id_effect_rpc/src/subscription/{mod.rs,events.rs}`, `crates/id_effect_rpc/tests/subscription_events.rs`, `crates/id_effect/book/src/part6/ch33-08-subscriptions.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "d5-subscriptions", + "lockedBy": "d5-subscriptions", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvf9twme-9avzxk/v1.json b/.maestro/contracts/tsk-mvf9twme-9avzxk/v1.json new file mode 100644 index 000000000..8b247bf22 --- /dev/null +++ b/.maestro/contracts/tsk-mvf9twme-9avzxk/v1.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 2, + "id": "c-670353", + "taskId": "tsk-mvf9twme-9avzxk", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T13:09:30.130Z", + "lockedAt": "2026-10-19T13:09:30.130Z", + "intent": "r12-e12-3-h3-server", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-ffa2ad", + "text": "axum feature http3: an adapter feeds h3 (=0.0.8) + h3-quinn (=0.0.10) requests from the platform QuicEndpoint (ALPN h3) into the same axum Router as a tower Service, so every typed route, middleware, guard and RPC endpoint works over HTTP/3 unchanged; the adapter is one module (server/h3.rs) so the backend can be swapped once hyper ships HTTP/3.", + "kind": "manual" + }, + { + "id": "dw-41fe67", + "text": "Alt-Svc: h3=\":\"; ma=86400 is added to TLS TCP responses when HTTP/3 is enabled; TCP and UDP listen on the same port number; graceful shutdown drains both (GOAWAY on h3, the 0.11 drain on TCP); server.http3.enabled = false turns HTTP/3 and Alt-Svc off (one flag, D4).", + "kind": "manual" + }, + { + "id": "dw-99ad5e", + "text": "Limits mirror ConnectionLimits for HTTP/3 (connections, request header timeout, requests per connection, QUIC stream credits); the client IP comes from the QUIC peer address through the 0.10.1 trusted-proxy rules; 0-RTT off, dangerous_allow_zero_rtt_for_queries() allows it for safe methods only and records a posture opt-out.", + "kind": "manual" + }, + { + "id": "dw-79eb76", + "text": "Spike first (D9): one HTTP/2 vs HTTP/3 p99 measurement under netem loss 1% delay 5ms with 64 multiplexed streams, recorded in the leaf evidence before the adapter is finished.", + "kind": "manual" + }, + { + "id": "dw-30753a", + "text": "Conformance: a CI job http3-conformance runs h3spec (release binary pinned by sha256) and curl --http3 (container image pinned by digest) against an example server; actions pinned by SHA (0.11 rule).", + "kind": "manual" + }, + { + "id": "dw-321378", + "text": "Tests `h3_serves_typed_routes_unchanged`, `h3_middleware_and_guards_apply`, `alt_svc_header_on_tcp_responses`, `http3_disabled_by_one_flag`, `shutdown_drains_tcp_and_udp`, `zero_rtt_off_by_default`, `h3_client_ip_from_quic_peer`; book part6/ch29-05-http3.md.", + "kind": "manual" + }, + { + "id": "dw-172733", + "text": "Gate: `cargo nextest run -p id_effect_axum --features http3`; `cargo nextest run -p id_effect_axum --all-features`; CI job `http3-conformance` green", + "kind": "manual" + }, + { + "id": "dw-28da5d", + "text": "Owns: `crates/id_effect_axum/src/server/{h3.rs,alt_svc.rs,lifecycle.rs}`, `crates/id_effect_axum/tests/http3.rs`, `crates/id_effect_axum/examples/092_http3.rs`, `.github/workflows/ci.yml` (the `http3-conformance` job only), `crates/id_effect/book/src/part6/ch29-05-http3.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "d6-h3-server", + "lockedBy": "d6-h3-server", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvfamuiv-lyz0jg/v1.json b/.maestro/contracts/tsk-mvfamuiv-lyz0jg/v1.json new file mode 100644 index 000000000..18be3f115 --- /dev/null +++ b/.maestro/contracts/tsk-mvfamuiv-lyz0jg/v1.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-4537bf", + "taskId": "tsk-mvfamuiv-lyz0jg", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T13:32:02.114Z", + "lockedAt": "2026-10-19T13:32:02.114Z", + "intent": "r12-e12-2-channels", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-eaef62", + "text": "web.realtime#3: id_effect_rpc::channels topics with join and leave, mapped to mesh process groups (ProcessGroups / OrSwot, crates/id_effect_node/src/groups.rs:30,126) by one hub process per node; subscribers are RPC subscriptions (WebSocket, SSE, later WebTransport).", + "kind": "manual" + }, + { + "id": "dw-54cf86", + "text": "web.realtime#5: a broadcast on any node reaches the subscribers on every node; delivery order per publisher is preserved; a node leaving removes its members.", + "kind": "manual" + }, + { + "id": "dw-a3bdd0", + "text": "web.realtime#4: presence with per-connection metadata, join/leave diffs pushed to subscribers, convergence after a node goes down (monitor-driven cleanup).", + "kind": "manual" + }, + { + "id": "dw-a51e2d", + "text": "web.realtime#6 (join half) and ADR 0013 R1: joining a topic goes through a JoinPolicy; the default policy denies; an adapter runs an id_effect_authz policy without making id_effect_rpc depend on axum-only authz items.", + "kind": "manual" + }, + { + "id": "dw-4db6e3", + "text": "Tests `three_node_broadcast_under_test_clock` (in-memory mesh transport), `presence_converges_after_node_down`, `channel_join_denied_by_default_policy`, `leave_stops_delivery`, `publisher_order_preserved_per_topic`; example channels_chat.rs; book part6/ch33-09-channels-presence.md. No id_effect_channels crate (L15).", + "kind": "manual" + }, + { + "id": "dw-590e14", + "text": "Gate: `cargo nextest run -p id_effect_rpc --features channels`; `cargo run -p id_effect_rpc --features channels --example channels_chat -- --self-test`", + "kind": "manual" + }, + { + "id": "dw-cf91bb", + "text": "Owns: `crates/id_effect_rpc/src/channels/**`, `crates/id_effect_rpc/tests/{channels_three_nodes.rs,presence.rs}`, `crates/id_effect_rpc/examples/channels_chat.rs`, `crates/id_effect/book/src/part6/ch33-09-channels-presence.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "e1-channels", + "lockedBy": "e1-channels", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvfc5h86-z8ef14/v1.json b/.maestro/contracts/tsk-mvfc5h86-z8ef14/v1.json new file mode 100644 index 000000000..2c3084112 --- /dev/null +++ b/.maestro/contracts/tsk-mvfc5h86-z8ef14/v1.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-a3cc1c", + "taskId": "tsk-mvfc5h86-z8ef14", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T14:14:31.141Z", + "lockedAt": "2026-10-19T14:14:31.141Z", + "intent": "r12-e12-2-mesh-rpc", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-7f5516", + "text": "The same #[rpc::service] contract is callable node to node: an RPC endpoint process per service runs under a cluster-wide name (0.11 global registry), the client resolves it and calls over the mesh with postcard encoding; unary calls, server streams and two-way streams (credit-based) work.", + "kind": "manual" + }, + { + "id": "dw-62e8a8", + "text": "Spans: the caller fiber trace context becomes the parent of the handler span on the remote node (the 0.11 dist.remote#8 mechanism), and the rpc.request span carries the service, procedure and node.", + "kind": "manual" + }, + { + "id": "dw-5c8b24", + "text": "An endpoint process restart under its supervisor is transparent to callers (re-resolve on noproc, bounded retry for queries only).", + "kind": "manual" + }, + { + "id": "dw-4c9252", + "text": "No edits to crates/id_effect_node (the endpoint is a Process behaviour registered through public node APIs).", + "kind": "manual" + }, + { + "id": "dw-93f172", + "text": "Tests `mesh_unary_and_stream_round_trip`, `mesh_call_carries_trace_context`, `mesh_endpoint_restart_is_transparent_to_callers`, `mesh_mutation_not_retried_on_noproc`; book part7/ch38-02-rpc-over-the-mesh.md.", + "kind": "manual" + }, + { + "id": "dw-208197", + "text": "Gate: `cargo nextest run -p id_effect_rpc --features mesh`", + "kind": "manual" + }, + { + "id": "dw-2bf6e9", + "text": "Owns: `crates/id_effect_rpc/src/{transport/mesh.rs,client/mesh.rs}`, `crates/id_effect_rpc/tests/mesh_rpc.rs`, `crates/id_effect/book/src/part7/ch38-02-rpc-over-the-mesh.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "e2-mesh-rpc", + "lockedBy": "e2-mesh-rpc", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvfdi1tt-t8vr7z/v1.json b/.maestro/contracts/tsk-mvfdi1tt-t8vr7z/v1.json new file mode 100644 index 000000000..37d782b50 --- /dev/null +++ b/.maestro/contracts/tsk-mvfdi1tt-t8vr7z/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-d76698", + "taskId": "tsk-mvfdi1tt-t8vr7z", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T14:52:16.227Z", + "lockedAt": "2026-10-19T14:52:16.227Z", + "intent": "r12-e12-4-openapi-asyncapi", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-aaca2b", + "text": "web.rpc#7: id_effect_rpc::openapi emits OpenAPI 3.1 from Service::contract() for queries and mutations (GET and POST forms, the x-rpc header, idempotency key, error unions as oneOf with a _tag discriminator, the batch endpoint); it replaces the labs 3.0 emitter (crates/id_effect_rpc/src/openapi.rs).", + "kind": "manual" + }, + { + "id": "dw-e09cb0", + "text": "id_effect_rpc::asyncapi emits AsyncAPI 3.0 for server streams, client streams, two-way streams, subscriptions and channels, with WebSocket and SSE bindings and the cursor field documented.", + "kind": "manual" + }, + { + "id": "dw-533276", + "text": "Both documents validate against their meta-schemas (jsonschema, no remote resolution); /rpc/openapi.json and /rpc/asyncapi.json are closed in the prod profile unless guarded (ADR 0013 R7).", + "kind": "manual" + }, + { + "id": "dw-e7b4cd", + "text": "Tests `openapi_31_validates_against_meta_schema`, `openapi_error_union_is_one_of_with_discriminator`, `asyncapi_30_validates`, `asyncapi_lists_every_stream_and_channel`, `r7_rpc_docs_closed_in_prod`; book part6/ch33-10-contracts.md.", + "kind": "manual" + }, + { + "id": "dw-ff8f48", + "text": "Gate: `cargo nextest run -p id_effect_rpc --all-features --test openapi_emit --test asyncapi_emit`", + "kind": "manual" + }, + { + "id": "dw-347ba0", + "text": "Owns: `crates/id_effect_rpc/src/{openapi.rs,asyncapi.rs}`, `crates/id_effect_rpc/tests/{openapi_emit.rs,asyncapi_emit.rs}`, `crates/id_effect_rpc/tests/fixtures/asyncapi-3.0.schema.json`, `crates/id_effect/book/src/part6/ch33-10-contracts.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "e3-openapi", + "lockedBy": "e3-openapi", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvffsow8-ogb7fz/v1.json b/.maestro/contracts/tsk-mvffsow8-ogb7fz/v1.json new file mode 100644 index 000000000..509a5c25c --- /dev/null +++ b/.maestro/contracts/tsk-mvffsow8-ogb7fz/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-d2023b", + "taskId": "tsk-mvffsow8-ogb7fz", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T15:56:36.455Z", + "lockedAt": "2026-10-19T15:56:36.455Z", + "intent": "r12-f-h3-platform", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-59108f", + "text": "F22 (D9 tuning task): `QuicLimits` (or a sibling transport setting) gains a congestion-control choice (`Cubic`, `Bbr`); the `h3` route defaults to BBR, the mesh `idm/1` route keeps Cubic; the choice is documented as quinn-experimental for BBR and is configurable per route. Test `h3_route_uses_bbr_mesh_uses_cubic`.", + "kind": "manual" + }, + { + "id": "dw-cea452", + "text": "F23: the QUIC endpoint sets UDP send/receive socket buffers (default 4 MiB, best effort, logs the granted size at debug); configurable; test asserts the requested size is applied or the shortfall logged.", + "kind": "manual" + }, + { + "id": "dw-bea5a1", + "text": "F10: the HTTP/3 client trusts the platform verifier (`rustls-platform-verifier =0.7.1`, already in Cargo.lock) when no explicit roots are configured, keeping `HttpClientConfig::root_certificates` as the override; SSRF and body-cap tests stay green.", + "kind": "manual" + }, + { + "id": "dw-267903", + "text": "F24: `HostTls::Provided` can carry an `Identity` (or exposes one from its resolver) so a host with provided TLS serves HTTP/3 too; when it cannot, the existing log line stays. Test `provided_tls_serves_h3`.", + "kind": "manual" + }, + { + "id": "dw-6e10aa", + "text": "Gate: `cargo nextest run -p id_effect_platform --all-features`, `cargo nextest run -p id_effect_axum --features http3,tls`, clippy -D warnings, coverage of new code >= 96% regions, `cargo tree -d` one quinn / rustls.", + "kind": "manual" + }, + { + "id": "dw-4de393", + "text": "Owns: `crates/id_effect_platform/{Cargo.toml,src/quic/**,src/http/h3.rs,tests/http3_client.rs}`, `crates/id_effect_axum/src/server/{bootstrap.rs,tls.rs,lifecycle.rs}` (Provided identity only), CHANGELOG/MIGRATION-0.12 own hunks.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "f1-h3-platform", + "lockedBy": "f1-h3-platform", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvfiv513-vvk5an/v1.json b/.maestro/contracts/tsk-mvfiv513-vvk5an/v1.json new file mode 100644 index 000000000..72a974d86 --- /dev/null +++ b/.maestro/contracts/tsk-mvfiv513-vvk5an/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-c62743", + "taskId": "tsk-mvfiv513-vvk5an", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T17:22:24.635Z", + "lockedAt": "2026-10-19T17:22:24.635Z", + "intent": "r12-e12-3-deploy-guide", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-1c73b2", + "text": "Book part6/ch29-07-http3-deployment.md documents both topologies: (a) a load balancer terminating HTTP/3 at the edge (needs nothing from the app; keep or disable server.http3.enabled for internal hops) and (b) direct-to-app with TCP and UDP 443 open, TLS-ALPN-01 ACME, the Alt-Svc behaviour, firewall rules, health checks over TCP, and what happens when UDP is blocked (clients stay on HTTP/2).", + "kind": "manual" + }, + { + "id": "dw-ee00f6", + "text": "Examples docs/deploy/http3/kamal.yml (UDP port publish) and docs/deploy/http3/helm-values.yaml (a UDP service port next to TCP, a NetworkPolicy for UDP 443) with a README; every Rust snippet in the chapter compiles through examples/book-snippets (K5).", + "kind": "manual" + }, + { + "id": "dw-be8ff6", + "text": "The mesh section links the shared-endpoint rule (D7) and quic.mesh_port, and states that the service template never shares a port with a mesh.", + "kind": "manual" + }, + { + "id": "dw-394e47", + "text": "Gate: `cargo test -p book-snippets`; `bash scripts/check-workflows.sh`; `yq e . docs/deploy/http3/kamal.yml docs/deploy/http3/helm-values.yaml > /dev/null`", + "kind": "manual" + }, + { + "id": "dw-f6d80d", + "text": "Owns: `crates/id_effect/book/src/part6/ch29-07-http3-deployment.md`, `docs/deploy/http3/{kamal.yml,helm-values.yaml,README.md}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "e4-deploy-guide", + "lockedBy": "e4-deploy-guide", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/contracts/tsk-mvfixl0v-di486t/v1.json b/.maestro/contracts/tsk-mvfixl0v-di486t/v1.json new file mode 100644 index 000000000..2f20586c8 --- /dev/null +++ b/.maestro/contracts/tsk-mvfixl0v-di486t/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-127d7d", + "taskId": "tsk-mvfixl0v-di486t", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T17:24:21.500Z", + "lockedAt": "2026-10-19T17:24:21.500Z", + "intent": "r12-e12-2-bidi-resume", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-e142e3", + "text": "web.rpc#4 (bidirectional): Duplex over WebSocket with credit-based backpressure in both directions; memory per stream bounded by the credit window.", + "kind": "manual" + }, + { + "id": "dw-e42978", + "text": "Resume: every server-to-client message carries a cursor; after a reconnect the client sends session id + last cursor and the server replays from a bounded per-session buffer (default 1,024 messages / 1 MiB / 30 s, config); a cursor older than the buffer yields the typed ResumeError::Expired so the client restarts the stream; subscriptions resume from the event store instead (r12-e12-2-subscriptions).", + "kind": "manual" + }, + { + "id": "dw-757e83", + "text": "Security (ADR 0013 R1): session ids are 128-bit random and bound to the authenticated principal; resuming under another principal is refused and counted.", + "kind": "manual" + }, + { + "id": "dw-2cf9b5", + "text": "Tests `bidi_backpressure_bounds_memory`, `resume_after_disconnect_no_gaps_no_duplicates` (proptest over random disconnect points, 1,000 cases, TestClock), `resume_after_buffer_overrun_is_expired`, `resume_under_another_principal_refused`, `session_buffer_released_after_retention`; `resume::decode_cursor` is public for the fuzz target; book part6/ch33-07-two-way-streams.md.", + "kind": "manual" + }, + { + "id": "dw-75c141", + "text": "Gate: `PROPTEST_CASES=1000 cargo nextest run -p id_effect_rpc --all-features --test resume_property`; `cargo nextest run -p id_effect_rpc --all-features`", + "kind": "manual" + }, + { + "id": "dw-101b13", + "text": "Owns: `crates/id_effect_rpc/src/{duplex.rs,resume.rs}`, `crates/id_effect_rpc/src/transport/ws.rs` (resume handshake, after r12-e12-2-rpc-ws-sse), `crates/id_effect_rpc/tests/{bidi.rs,resume_property.rs}`, `crates/id_effect/book/src/part6/ch33-07-two-way-streams.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "f2-bidi-resume", + "lockedBy": "f2-bidi-resume", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvfixl4d-wu25gw/v1.json b/.maestro/contracts/tsk-mvfixl4d-wu25gw/v1.json new file mode 100644 index 000000000..00f1baa6c --- /dev/null +++ b/.maestro/contracts/tsk-mvfixl4d-wu25gw/v1.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-05c7ce", + "taskId": "tsk-mvfixl4d-wu25gw", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T17:24:21.620Z", + "lockedAt": "2026-10-19T17:24:21.620Z", + "intent": "r12-e12-5-benches", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-1f17a7", + "text": "Criterion benches in crates/id_effect_rpc/benches/gate_rpc.rs: rpc/unary_json (a #[rpc::service] query, JSON, small struct, through the in-process tower path as the 0.11 gate_request bench), rpc/axum_json_handler (a raw axum handler doing the same serde JSON decode and encode of the same struct; the baseline), rpc/unary_postcard, rpc/batch_50 and rpc/sequential_50 (50 calls over loopback TCP with keep-alive, sequential awaits).", + "kind": "manual" + }, + { + "id": "dw-1c3498", + "text": "D5: a delta gate rpc/unary_json − rpc/axum_json_handler ≤ 3,000 ns (same run and runner; ratio printed for information) is written into docs/benchmarks/gates.toml only after the first measurement is recorded in the leaf evidence; if the measurement exceeds 3 µs the leaf stops and asks the owner (release spec Open question 2) instead of loosening it.", + "kind": "manual" + }, + { + "id": "dw-1b39ee", + "text": "Pair gate rpc/batch_50 / rpc/sequential_50 ≤ 0.20 (≥ 5× faster); the 0.11 secure-stack delta gate (≤ 5 µs) is unchanged.", + "kind": "manual" + }, + { + "id": "dw-0e8ba6", + "text": "Soak: examples/streams_soak.rs opens 10,000 concurrent WebSocket server streams on one node with heartbeats and samples RSS; pass when RSS at minute 10 is ≤ 1.05 × RSS at minute 1 and every stream received every heartbeat; a 60 s variant runs in PR CI, the 10-minute run in bench.yml.", + "kind": "manual" + }, + { + "id": "dw-31646e", + "text": "D9: benches/h3_loss.rs measures p99 of 64 multiplexed 16 KiB responses over one connection, HTTP/2 over TLS vs HTTP/3, with tc netem loss 1% delay 5ms on loopback (bench.yml job with sudo); pair gate h3_p99 / h2_p99 ≤ 1.00 (noise 0.05); results published in docs/benchmarks/v0.12.json and README.", + "kind": "manual" + }, + { + "id": "dw-18e866", + "text": "Gate: `bash scripts/bench-run.sh` then `bash scripts/bench-compare.sh docs/benchmarks/v0.11.json ` (no budget/delta/pair FAIL); `cargo run --release -p id_effect_rpc --example streams_soak -- --streams 10000 --minutes 10`; `bash scripts/check-workflows.sh`", + "kind": "manual" + }, + { + "id": "dw-113b66", + "text": "Owns: `crates/id_effect_rpc/benches/{gate_rpc.rs,h3_loss.rs}`, `crates/id_effect_rpc/examples/streams_soak.rs`, `docs/benchmarks/{gates.toml,README.md,v0.12.json}`, `scripts/bench-run.sh`, `.github/workflows/bench.yml`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "f3-benches", + "lockedBy": "f3-benches", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvfl8a7k-641iow/v1.json b/.maestro/contracts/tsk-mvfl8a7k-641iow/v1.json new file mode 100644 index 000000000..50a297a34 --- /dev/null +++ b/.maestro/contracts/tsk-mvfl8a7k-641iow/v1.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-b48bf0", + "taskId": "tsk-mvfl8a7k-641iow", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T18:28:59.564Z", + "lockedAt": "2026-10-19T18:28:59.564Z", + "intent": "r12-e12-4-gen-ts", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-8cad68", + "text": "id-effect rpc gen ts --contract --out writes plain TypeScript with no runtime dependency: types from the contract (structs, tagged unions, Option as optional or null, Vec, maps, brands as branded types, dates as ISO strings), error unions as discriminated unions on _tag, one client class per service and version (web.rpc#3).", + "kind": "manual" + }, + { + "id": "dw-06277d", + "text": "Runtime behaviour of the generated client: calls made in one microtask go out as one batch; transport choice WebTransport, then WebSocket, then SSE + POST; reconnect with exponential backoff and jitter and resume from the last cursor (web.realtime#7); AbortSignal cancels a call; the x-rpc header is always sent.", + "kind": "manual" + }, + { + "id": "dw-ede7dd", + "text": "cli.rs gains the rpc subcommand with gen ts only (Command is #[non_exhaustive], crates/id_effect_tool/src/cli.rs:29, so a new Rpc variant is not a break); r12-e12-4-rpc-check adds the check arm, so no stub ships (QUALITY_BAR check S).", + "kind": "manual" + }, + { + "id": "dw-447ac5", + "text": "D12: tests type-check with deno check and run deno test (pinned Deno >= 2.4 via the CI setup step pinned by SHA); no package.json, node_modules or npm lockfile is added.", + "kind": "manual" + }, + { + "id": "dw-c1a2d0", + "text": "Tests `gen_ts_golden_output` (crates/id_effect_tool/tests/golden/rpc/**), `generated_client_type_checks`, `generated_client_round_trip_against_test_server`, `generated_client_batches_same_microtask_calls`, `generated_client_resumes_after_reconnect`; book part6/ch33-11-typescript-client.md.", + "kind": "manual" + }, + { + "id": "dw-c9a845", + "text": "Gate: `cargo nextest run -p id_effect_tool --test rpc_gen_ts`; `deno check crates/id_effect_tool/tests/ts/*.ts`; `deno test crates/id_effect_tool/tests/ts/`", + "kind": "manual" + }, + { + "id": "dw-5741a4", + "text": "Owns: `crates/id_effect_tool/src/{rpc/mod.rs,rpc/gen_ts.rs,rpc/ts/**,cli.rs}`, `crates/id_effect_tool/Cargo.toml` (id_effect_rpc dependency for the contract model), `crates/id_effect_tool/tests/{rpc_gen_ts.rs,golden/rpc/**,ts/**}`, `crates/id_effect/book/src/part6/ch33-11-typescript-client.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "g1-gen-ts", + "lockedBy": "g1-gen-ts", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvfl8acb-0806yq/v1.json b/.maestro/contracts/tsk-mvfl8acb-0806yq/v1.json new file mode 100644 index 000000000..13ac3233c --- /dev/null +++ b/.maestro/contracts/tsk-mvfl8acb-0806yq/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-e1164a", + "taskId": "tsk-mvfl8acb-0806yq", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T18:28:59.683Z", + "lockedAt": "2026-10-19T18:28:59.683Z", + "intent": "r12-e12-3-webtransport", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-9040d3", + "text": "axum feature webtransport: WebTransport sessions (h3-webtransport =0.1.2) on the HTTP/3 adapter; the extended CONNECT is a declared typed route (public or guarded) and its Origin header is checked against the same allowlist as WebSocket (ADR 0013 R9).", + "kind": "manual" + }, + { + "id": "dw-6ffbfa", + "text": "id_effect_rpc feature webtransport: two-way streams and subscriptions over WebTransport bidirectional streams (one QUIC stream per call), resume after session loss with the r12-e12-2-bidi-resume cursor protocol; stream credits and session limits finite.", + "kind": "manual" + }, + { + "id": "dw-3db9a6", + "text": "D12 browser tests: Chromium driven from Rust over CDP (chromiumoxide or headless_chrome, choice recorded), using serverCertificateHashes with the 13-day ECDSA dev certificate; the client prefers WebTransport, then WebSocket. The browser test runs only with ID_EFFECT_REQUIRE_BROWSER=1 (fails, never skips, when set) and in the Relay CI job.", + "kind": "manual" + }, + { + "id": "dw-4070ab", + "text": "Tests `webtransport_bidi_round_trip`, `webtransport_subscription_round_trip`, `webtransport_resume_after_session_loss`, `r9_webtransport_connect_from_foreign_origin_refused`, `browser_prefers_webtransport_then_websocket`; book part6/ch29-06-webtransport.md.", + "kind": "manual" + }, + { + "id": "dw-b8bb8f", + "text": "Gate: `cargo nextest run -p id_effect_rpc -p id_effect_axum --features webtransport`; `ID_EFFECT_REQUIRE_BROWSER=1 cargo nextest run -p id_effect_rpc --features webtransport --test browser`", + "kind": "manual" + }, + { + "id": "dw-8444e5", + "text": "Owns: `crates/id_effect_axum/src/server/webtransport.rs`, `crates/id_effect_rpc/src/transport/webtransport.rs`, `crates/id_effect_rpc/tests/{transport_webtransport.rs,browser.rs}`, `crates/id_effect/book/src/part6/ch29-06-webtransport.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "g2-webtransport", + "lockedBy": "g2-webtransport", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvfl8agk-vm3y4j/v1.json b/.maestro/contracts/tsk-mvfl8agk-vm3y4j/v1.json new file mode 100644 index 000000000..4593ae8cd --- /dev/null +++ b/.maestro/contracts/tsk-mvfl8agk-vm3y4j/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-3f05f7", + "taskId": "tsk-mvfl8agk-vm3y4j", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T18:28:59.801Z", + "lockedAt": "2026-10-19T18:28:59.801Z", + "intent": "r12-e12-5-fuzz-security", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-de9f26", + "text": "At least 5 new cargo-fuzz targets (planned 6) with seed corpora: rpc_wire_request (JSON envelope), rpc_batch (wire::decode_batch), rpc_postcard_frame (codec::postcard::decode_frame), rpc_duplex_frame (transport::frame::decode, shared by WebSocket and WebTransport), rpc_resume_cursor (resume::decode_cursor), sse_last_event_id; each builds on the pinned nightly and runs 5 minutes in ClusterFuzzLite PR mode with no crash; the nightly batch includes them.", + "kind": "manual" + }, + { + "id": "dw-acb556", + "text": "Compile-fail security cases in tests/security_compile_fail: r1_rpc_procedure_without_access_decision (a #[rpc::service] method without #[public] or #[guard]) and r10_zero_rtt_on_mutation (enabling 0-RTT on a mutation does not compile).", + "kind": "manual" + }, + { + "id": "dw-b085d5", + "text": "crates/id_effect_rpc/tests/security_surface.rs asserts that every test id in the release spec Security table exists in the workspace (fails on a missing id) and runs the cross-crate cases (Origin, CSRF header, batch limit, stream cap) against one server.", + "kind": "manual" + }, + { + "id": "dw-39d224", + "text": "THREAT-MODEL.md gains a call-surface section (assets, entry points: /rpc, WebSocket, SSE, HTTP/3, WebTransport, mesh RPC; threats and the test that covers each); docs/security/fuzzing.md lists the new targets.", + "kind": "manual" + }, + { + "id": "dw-d61977", + "text": "Gate: `cd fuzz && cargo +nightly- fuzz build`; `cargo nextest run -p security_compile_fail`; `cargo nextest run -p id_effect_rpc --all-features --test security_surface`", + "kind": "manual" + }, + { + "id": "dw-5bdd09", + "text": "Owns: `fuzz/**`, `tests/security_compile_fail/**`, `crates/id_effect_rpc/tests/security_surface.rs`, `THREAT-MODEL.md`, `docs/security/fuzzing.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "g3-fuzz-security", + "lockedBy": "g3-fuzz-security", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvfns901-uz5ej3/v1.json b/.maestro/contracts/tsk-mvfns901-uz5ej3/v1.json new file mode 100644 index 000000000..954c4576a --- /dev/null +++ b/.maestro/contracts/tsk-mvfns901-uz5ej3/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-6882ee", + "taskId": "tsk-mvfns901-uz5ej3", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T19:40:12.952Z", + "lockedAt": "2026-10-19T19:40:12.952Z", + "intent": "r12-e12-4-rpc-check", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-752542", + "text": "id-effect rpc check --contract --against compares two contracts and classifies every change: breaking (procedure removed, kind changed, required input field added, input type narrowed, output field removed or narrowed, error variant added, access decision tightened) or compatible (procedure added, optional input field added, output field added); prints the diff with a reason per item.", + "kind": "manual" + }, + { + "id": "dw-8ede7a", + "text": "web.rpc#6: a breaking change passes only when the service version is bumped and the previous version is still served; exit 1 otherwise, exit 2 on unreadable input.", + "kind": "manual" + }, + { + "id": "dw-5dde31", + "text": "Tests `removed_procedure_is_breaking`, `optional_field_added_is_compatible`, `required_field_added_is_breaking`, `error_variant_added_is_breaking_for_clients`, `breaking_change_with_version_bump_passes`, `against_git_ref_reads_old_contract`; book part6/ch33-12-contract-check.md.", + "kind": "manual" + }, + { + "id": "dw-fe7949", + "text": "Gate: `cargo nextest run -p id_effect_tool --test rpc_check`; `cargo nextest run -p id_effect_tool`", + "kind": "manual" + }, + { + "id": "dw-569648", + "text": "Owns: `crates/id_effect_tool/src/rpc/check.rs`, `crates/id_effect_tool/src/cli.rs` (the check arm only, after r12-e12-4-gen-ts), `crates/id_effect_tool/tests/rpc_check.rs`, `crates/id_effect/book/src/part6/ch33-12-contract-check.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "g4-rpc-check", + "lockedBy": "g4-rpc-check", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvfo16u4-pdz4fg/v1.json b/.maestro/contracts/tsk-mvfo16u4-pdz4fg/v1.json new file mode 100644 index 000000000..f726d3ab9 --- /dev/null +++ b/.maestro/contracts/tsk-mvfo16u4-pdz4fg/v1.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 2, + "id": "c-d0ac4c", + "taskId": "tsk-mvfo16u4-pdz4fg", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T19:47:05.419Z", + "lockedAt": "2026-10-19T19:47:05.419Z", + "intent": "r12-w6-kit-template", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-90fe21", + "text": "id_effect_kit features: rpc (id_effect_rpc + macros, HTTP transport, implies web), rpc-ws (WebSocket + SSE transports, subscriptions; with events also the events-backed subscriptions), http3 (axum http3 + tls; with http-client also the platform HTTP/3 client), webtransport (http3 + rpc-ws + WebTransport); cluster additionally turns on id_effect_rpc?/channels and ?/mesh; all four in full; prelude exposes rpc::service and the procedure attributes.", + "kind": "manual" + }, + { + "id": "dw-b01d7d", + "text": "Posture probes (kit posture::check): every procedure declares public or guarded, WebSocket/WebTransport origins configured, 0-RTT off, QUIC Retry on, HTTP/3 enabled only with TLS, no id-effect dev certificate in prod; tests listed below.", + "kind": "manual" + }, + { + "id": "dw-74d3a4", + "text": "service template: a sample #[rpc::service] next to the REST routes, HTTP/3 on by default (server.http3.enabled = true, D4) on the same port as TLS TCP, TCP always on; prod config requires certificate paths or ACME (or the flag off behind a load balancer); README and the zero-to-production tutorial explain the flag.", + "kind": "manual" + }, + { + "id": "dw-c92238", + "text": "id-effect dev creates a localhost dev CA and a 13-day ECDSA P-256 certificate (WebTransport serverCertificateHashes compatible), rotates it before expiry, prints trust instructions, and never runs in the prod profile.", + "kind": "manual" + }, + { + "id": "dw-940030", + "text": "scripts/template-smoke.sh generates a service and its TypeScript client (id-effect rpc gen ts), runs the service and a Deno round trip over HTTP/3 and over TCP; scripts/check-dangerous.sh lists every new dangerous_* API.", + "kind": "manual" + }, + { + "id": "dw-6f49c7", + "text": "Tests `template_service_serves_rpc_over_h3_and_tcp`, `template_http3_disabled_by_one_flag`, `posture_prod_refuses_dev_certificate`, `posture_reports_rpc_probes`, `posture_fails_on_undeclared_procedure`, `dev_cert_valid_for_webtransport_hashes`.", + "kind": "manual" + }, + { + "id": "dw-fb3f2b", + "text": "Gate: `cargo nextest run -p id_effect_kit -p id_effect_tool --all-features`; `bash scripts/template-smoke.sh`; `bash scripts/check-dangerous.sh`; `cargo test -p book-snippets` (tutorial)", + "kind": "manual" + }, + { + "id": "dw-2cd70b", + "text": "Owns: `crates/id_effect_kit/**`, `crates/id_effect_tool/templates/**`, `crates/id_effect_tool/src/dev.rs`, `crates/id_effect_tool/Cargo.toml` (rcgen for the dev certificate, after r12-e12-4-gen-ts), `crates/id_effect_tool/tests/dev_cert.rs`, `scripts/template-smoke.sh`, `scripts/check-dangerous.sh`, `crates/id_effect/book/src/tutorial-zero-to-production.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "h1-kit-template", + "lockedBy": "h1-kit-template", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvfohzqk-xg47t7/v1.json b/.maestro/contracts/tsk-mvfohzqk-xg47t7/v1.json new file mode 100644 index 000000000..424607b68 --- /dev/null +++ b/.maestro/contracts/tsk-mvfohzqk-xg47t7/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-c44cae", + "taskId": "tsk-mvfohzqk-xg47t7", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T20:00:18.766Z", + "lockedAt": "2026-10-19T20:00:18.766Z", + "intent": "r12-f-client-batching", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-2c8781", + "text": "F27: calls a `#[rpc::service]` generated client makes over `HttpTransport` in the same tick go out as one batch (≤ 50 calls / 1 MiB, same rules as `RpcClient::call`), each batched call carrying its service version `v`; a call alone in its tick is still a plain envelope.", + "kind": "manual" + }, + { + "id": "dw-a91122", + "text": "`RpcTransport` gains an effect-returning unary hook with a default (existing transports unchanged); `HttpTransport` overrides it so `call_unary` issues the request from the caller fiber and joins the batch window.", + "kind": "manual" + }, + { + "id": "dw-1447a4", + "text": "`client/http.rs` gains a JSON-value batched call carrying `v`; the batch wire is unchanged (WireCall already has `v`).", + "kind": "manual" + }, + { + "id": "dw-d8ee90", + "text": "Tests: `generated_client_batches_same_tick_calls` (two calls in one tick → one request, both answered, versions sent), `generated_client_lone_call_is_plain_envelope`; existing batching and service_macro tests stay green.", + "kind": "manual" + }, + { + "id": "dw-8499f8", + "text": "Gate: `cargo nextest run -p id_effect_rpc --all-features` and default; clippy -D warnings (all features, default, `--no-default-features --features client`); coverage of new code >= 96% regions.", + "kind": "manual" + }, + { + "id": "dw-46021d", + "text": "Owns: `crates/id_effect_rpc/src/{client/http.rs,service.rs}`, `crates/id_effect_rpc/tests/service_macro.rs` (new tests only), CHANGELOG own hunk.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "h2-client-batching", + "lockedBy": "h2-client-batching", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvfoqs1j-gik5ls/v1.json b/.maestro/contracts/tsk-mvfoqs1j-gik5ls/v1.json new file mode 100644 index 000000000..e426e257f --- /dev/null +++ b/.maestro/contracts/tsk-mvfoqs1j-gik5ls/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-59e3f5", + "taskId": "tsk-mvfoqs1j-gik5ls", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T20:06:59.040Z", + "lockedAt": "2026-10-19T20:06:59.040Z", + "intent": "r12-f-unary-perf", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-0c4d86", + "text": "D5: `rpc/unary_json` − `rpc/axum_json_handler` ≤ 3,000 ns on the r12-e12-5-benches harness (measured +3,399 ns at 9f422370, Maestro evidence evd-1792438306570-c6acf9), median of 5 runs at load < 4, recorded in docs/benchmarks/v0.12.json; then the D5 `[[gate]]` table in docs/benchmarks/gates.toml (as docs/benchmarks/README.md \"Unary budget (D5)\" shows) is enabled. The budget itself is not raised.", + "kind": "manual" + }, + { + "id": "dw-d5570e", + "text": "Method: profile the unary path (perf / cargo-flamegraph or criterion + counting allocations) and remove per-call work that is not security-relevant — double JSON (de)serialisation, per-call allocations, repeated header parsing, boxed futures, guard-chain clones. No security check is removed or weakened (x-rpc, Content-Type, guards, deadline clamp, InternalError redaction, idempotency); secure-stack gate stays green (≤ +5 µs).", + "kind": "manual" + }, + { + "id": "dw-0c8128", + "text": "F48: the axum host listener and `serve_router*` set `TCP_NODELAY` on accepted TCP connections (WebSocket p99 41-42 ms → ~1 ms measured by benches); test asserts nodelay on an accepted socket.", + "kind": "manual" + }, + { + "id": "dw-fac02a", + "text": "Gate: `cargo nextest run -p id_effect_rpc -p id_effect_axum --all-features`; clippy -D warnings; bench-run + bench-compare for rpc and axum gates; coverage of changed code ≥ 96% regions.", + "kind": "manual" + }, + { + "id": "dw-f25b49", + "text": "Owns: `crates/id_effect_rpc/src/{server/**,handler.rs,registry.rs,codec/json.rs,wire.rs}`, `crates/id_effect_axum/src/server/{connection.rs,lifecycle.rs,bootstrap.rs}` (nodelay only), `docs/benchmarks/{gates.toml,v0.12.json,README.md}`, CHANGELOG own hunk.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "h3-unary-perf", + "lockedBy": "h3-unary-perf", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvfr897j-gvxts8/v1.json b/.maestro/contracts/tsk-mvfr897j-gvxts8/v1.json new file mode 100644 index 000000000..60a7f35d7 --- /dev/null +++ b/.maestro/contracts/tsk-mvfr897j-gvxts8/v1.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-147f9f", + "taskId": "tsk-mvfr897j-gvxts8", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T21:16:33.554Z", + "lockedAt": "2026-10-19T21:16:33.554Z", + "intent": "r12-w7-relay", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-832962", + "text": "examples/reference/relay is built only through id_effect_kit (features rpc, rpc-ws, http3, webtransport, cluster, mesh-mtls, events, sql-pg, auth, authz): chat rooms are channels topics, membership and presence per room, messages sent over a two-way RPC stream, room history as an events-backed subscription on Postgres, node-to-node calls over mesh RPC, three nodes on real QUIC (loopback ports, the shared endpoint of D7 with mutual TLS for idm/1).", + "kind": "manual" + }, + { + "id": "dw-ba831c", + "text": "Clients in CI: the Rust client, the generated TypeScript client under Deno, and Chromium over CDP (WebTransport and WebSocket) with ID_EFFECT_REQUIRE_BROWSER=1.", + "kind": "manual" + }, + { + "id": "dw-a378c1", + "text": "Prod posture clean with zero accepted risks; every procedure declared public or guarded; join denied without room membership; id-effect rpc check runs against rpc.contract.json at the last tag; the CI job reference-relay runs with Postgres and runs once more with UDP dropped by iptables to prove TCP fallback.", + "kind": "manual" + }, + { + "id": "dw-e741b0", + "text": "Tier: the Relay depends on id_effect_rpc, so its tier line moves to platform (ADR 0012 rule 4, check P of scripts/tier-conformance.sh); web.rpc and web.realtime reach level production.", + "kind": "manual" + }, + { + "id": "dw-983317", + "text": "Tests `relay_three_nodes_chat_over_http3`, `relay_three_nodes_chat_over_websocket`, `relay_presence_converges_after_node_loss`, `relay_resume_after_client_reconnect_no_gaps`, `relay_udp_blocked_falls_back_to_tcp`, `relay_join_denied_without_membership`, `relay_browser_chat_round_trip`, `relay_prod_posture_is_clean`, `relay_contract_check_passes`.", + "kind": "manual" + }, + { + "id": "dw-61ac6d", + "text": "Gate: `ID_EFFECT_REQUIRE_DOCKER=1 ID_EFFECT_REQUIRE_BROWSER=1 cargo nextest run -p relay`; `bash scripts/tier-conformance.sh` (all PASS); CI job `reference-relay` green", + "kind": "manual" + }, + { + "id": "dw-a1d695", + "text": "Owns: `examples/reference/relay/**`, `.github/workflows/ci.yml` (the `reference-relay` job and its `ci-pass` needs entry), the tier line in `crates/id_effect_rpc/Cargo.toml`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "i1-relay", + "lockedBy": "i1-relay", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/contracts/tsk-mvfr89bs-sr3cs4/v1.json b/.maestro/contracts/tsk-mvfr89bs-sr3cs4/v1.json new file mode 100644 index 000000000..490c333cb --- /dev/null +++ b/.maestro/contracts/tsk-mvfr89bs-sr3cs4/v1.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-e5a1cd", + "taskId": "tsk-mvfr89bs-sr3cs4", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T21:16:33.675Z", + "lockedAt": "2026-10-19T21:16:33.675Z", + "intent": "r12-w7-ledger", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f8d4e8", + "text": "The Ledger exposes its accounts and entries API as a #[rpc::service] (queries and mutations with idempotency keys, one server stream for statements) that calls the same handlers as its REST routes; REST and OpenAPI stay (ADR 0014: REST + OpenAPI remains the public API style).", + "kind": "manual" + }, + { + "id": "dw-7b5446", + "text": "Its tests use the generated TypeScript client under Deno for a round trip, and the Rust client for the rest; rpc.contract.json is checked in; id-effect rpc check runs in the reference-ledger CI job against the last tag.", + "kind": "manual" + }, + { + "id": "dw-064f63", + "text": "Prod posture still clean with zero accepted risks; every procedure declared public or guarded (access-report snapshot updated).", + "kind": "manual" + }, + { + "id": "dw-f21f6e", + "text": "Tests `ledger_rpc_and_rest_agree`, `ledger_ts_client_round_trip`, `ledger_rpc_procedures_all_declared`, `ledger_rpc_mutation_idempotent`, `ledger_prod_posture_is_clean`.", + "kind": "manual" + }, + { + "id": "dw-7a597e", + "text": "Gate: `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p ledger` (Docker and Postgres; never the ambient DATABASE_URL); CI job `reference-ledger` green", + "kind": "manual" + }, + { + "id": "dw-b5af15", + "text": "Owns: `examples/reference/ledger/**`, `.github/workflows/ci.yml` (steps of the `reference-ledger` job only)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "i2-ledger", + "lockedBy": "i2-ledger", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvfusz4r-iiqduh/v1.json b/.maestro/contracts/tsk-mvfusz4r-iiqduh/v1.json new file mode 100644 index 000000000..0aaebc6d9 --- /dev/null +++ b/.maestro/contracts/tsk-mvfusz4r-iiqduh/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-46323a", + "taskId": "tsk-mvfusz4r-iiqduh", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T22:56:46.468Z", + "lockedAt": "2026-10-19T22:56:46.468Z", + "intent": "r12-w8-level-audit", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-da0f37", + "text": "For web.rpc (#1–#8), web.realtime (#1–#8) and dist.tls (#1–#8): every planned check id maps to an existing path at HEAD, a named test that passes, a book chapter and a runnable example (METHOD §7.2); level production is evidenced by the reference-relay and reference-ledger CI jobs; the evidence map is attached to the leaf evidence.", + "kind": "manual" + }, + { + "id": "dw-a04f10", + "text": "dist.tls is re-checked after the mesh moved onto the shared endpoint (r12-e12-3-quic-endpoint): #2 #3 #4 #5 #7 still met on both endpoint shapes.", + "kind": "manual" + }, + { + "id": "dw-c15637", + "text": "Every gap becomes a Maestro task before exit; no file is edited by this leaf.", + "kind": "manual" + }, + { + "id": "dw-e78c0a", + "text": "Gate: `jq -r '.rows[] | select(.id==\"web.rpc\" or .id==\"web.realtime\" or .id==\"dist.tls\") | .checks[].id' docs/scorecard/scorecard.json` → each id present in the evidence map with a path that exists (`test -e`) and a test that passes (`cargo nextest run -E 'test()'`)", + "kind": "manual" + }, + { + "id": "dw-9e2eba", + "text": "Owns: none (read-only)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "j1-level-audit", + "lockedBy": "j1-level-audit", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/contracts/tsk-mvfusz8h-6pkxkz/v1.json b/.maestro/contracts/tsk-mvfusz8h-6pkxkz/v1.json new file mode 100644 index 000000000..3fa2ed297 --- /dev/null +++ b/.maestro/contracts/tsk-mvfusz8h-6pkxkz/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-c296e8", + "taskId": "tsk-mvfusz8h-6pkxkz", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T22:56:46.587Z", + "lockedAt": "2026-10-19T22:56:46.587Z", + "intent": "r12-w8-kpis", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-91e6ad", + "text": "docs/security/kpis-0.12.json: open highs (0), fuzz targets (≥ 13 total, ≥ 5 new, open crashes 0), compile-fail security cases (0.11 count + 2), secure-stack delta (≤ 5 µs), the Security-table tests green; scripts/security-kpis.sh learns the 0.12 fields and keeps its self-test.", + "kind": "manual" + }, + { + "id": "dw-f1b652", + "text": "T12 bench results copied from docs/benchmarks/v0.12.json: unary delta, batch pair ratio, soak RSS ratio and heartbeat completeness, h3/h2 p99 ratio.", + "kind": "manual" + }, + { + "id": "dw-aeecf1", + "text": "docs/benchmarks/dx-kpis-0.12.json: K1–K6 with HTTP/3 on in the service template (K1 ≤ 180 s, K2 ≤ 8 s, K4 ≥ 17/20, K5 ≥ 80 %, K6 within budget) plus the cold-build delta of the http3 kit feature and of node quic pulling id_effect_platform (release spec Risk 9); measured on an idle machine.", + "kind": "manual" + }, + { + "id": "dw-c4b7f8", + "text": "Gate: `bash scripts/security-kpis.sh --self-test && bash scripts/security-kpis.sh 0.12.0`; `DX_KPIS_RELEASE=0.12 bash scripts/dx-kpis.sh`", + "kind": "manual" + }, + { + "id": "dw-20af69", + "text": "Owns: `docs/security/kpis-0.12.json`, `scripts/security-kpis.sh`, `docs/benchmarks/dx-kpis-0.12.json`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "j2-kpis", + "lockedBy": "j2-kpis", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/contracts/tsk-mvfuszbz-xpikgu/v1.json b/.maestro/contracts/tsk-mvfuszbz-xpikgu/v1.json new file mode 100644 index 000000000..6ef1e12d0 --- /dev/null +++ b/.maestro/contracts/tsk-mvfuszbz-xpikgu/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-c9f395", + "taskId": "tsk-mvfuszbz-xpikgu", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T22:56:46.708Z", + "lockedAt": "2026-10-19T22:56:46.708Z", + "intent": "r12-f-ts-cursor", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-d3b43c", + "text": "F58: `RpcStream.cursor` (and every resume the runtime sends: WS/WT `resume`, SSE `Last-Event-ID`, subscription re-`call`) is the cursor of the last item the caller has CONSUMED from the iterator, not the last item received; buffered-but-unread items are re-delivered after a resume, never skipped.", + "kind": "manual" + }, + { + "id": "dw-f6f8c6", + "text": "Credits stay consistent with the design note: an item is credited when consumed; after a resume the server counts items up to the sent cursor as credited (handoff-resume-012.md).", + "kind": "manual" + }, + { + "id": "dw-37f591", + "text": "Deno test `resume_does_not_skip_buffered_unread_items` (server sends N items, client reads k < N, connection drops, client resumes → receives items k+1..N exactly once, in order) for a server stream and a subscription; goldens regenerated (`UPDATE_GOLDEN=1`).", + "kind": "manual" + }, + { + "id": "dw-9907b8", + "text": "Gate: `ID_EFFECT_REQUIRE_DENO=1 cargo nextest run -p id_effect_tool --test rpc_gen_ts`; `deno test` / `deno check` under crates/id_effect_tool/tests/ts; `id-effect rpc check --generated` for Ledger and Relay web clients regenerated if their checked-in output changes.", + "kind": "manual" + }, + { + "id": "dw-029880", + "text": "Owns: `crates/id_effect_tool/src/rpc/ts/**`, `crates/id_effect_tool/tests/{rpc_gen_ts.rs,golden/rpc/**,ts/**}`, regenerated client output under `examples/reference/{ledger,relay}/**` only where generated, CHANGELOG own hunk.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "j3-ts-cursor", + "lockedBy": "j3-ts-cursor", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvfypvdw-vae18k/v1.json b/.maestro/contracts/tsk-mvfypvdw-vae18k/v1.json new file mode 100644 index 000000000..45a34ff1e --- /dev/null +++ b/.maestro/contracts/tsk-mvfypvdw-vae18k/v1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-f4b245", + "taskId": "tsk-mvfypvdw-vae18k", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-20T00:46:18.366Z", + "lockedAt": "2026-10-20T00:46:18.366Z", + "intent": "r12-f-rpc-spans", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-08f2be", + "text": "web.rpc#8: every call over WebSocket, WebTransport and SSE opens one `rpc.request` span (same fields as HTTP: rpc.system, rpc.service, rpc.method, correlation id, transport) parented on the connection span; the HTTP path gets a test. Tests `http_call_emits_rpc_request_span`, `ws_call_emits_rpc_request_span`, `sse_call_emits_rpc_request_span`, `webtransport_call_emits_rpc_request_span` (capturing subscriber, asserts one span per call with the tag).", + "kind": "manual" + }, + { + "id": "dw-9f8181", + "text": "Relay CSP: determine why Chromium refused WebTransport with \"violates the document's Content Security Policy\" in one loaded run (level audit, F59). If the Relay page CSP lacks the WebTransport origin in `connect-src` (https:// to the same host:port, which CSP treats as a different scheme from the page or a different port), fix the Relay page CSP (or SecurityLayer defaults if the default is wrong for WebTransport); add a test that the served CSP admits the WebTransport URL; rerun `ID_EFFECT_REQUIRE_BROWSER=1 cargo nextest run -p relay` 5 times. If it is a load artefact only, document the evidence.", + "kind": "manual" + }, + { + "id": "dw-d9b27e", + "text": "No change to security defaults beyond a correct CSP entry for the app's own WebTransport endpoint.", + "kind": "manual" + }, + { + "id": "dw-ddd90a", + "text": "Gate: `cargo nextest run -p id_effect_rpc --all-features`, `-p relay` with ID_EFFECT_REQUIRE_DOCKER/DENO/BROWSER=1; clippy -D warnings; unary bench delta stays ≤ 3,000 ns (spans must not regress D5: rerun `rpc/unary_json` vs `rpc/axum_json_handler`).", + "kind": "manual" + }, + { + "id": "dw-bdd37c", + "text": "Owns: `crates/id_effect_rpc/src/transport/{ws,webtransport,sse}.rs` (span hunks), `crates/id_effect_rpc/src/span.rs`, `crates/id_effect_rpc/tests/spans.rs`, `examples/reference/relay/**` (CSP and the stale cursor comment at web/tests/relay.test.ts:92), CHANGELOG own hunk.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "k1-rpc-spans", + "lockedBy": "k1-rpc-spans", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/contracts/tsk-mvg7dsl7-vj0q93/v1.json b/.maestro/contracts/tsk-mvg7dsl7-vj0q93/v1.json new file mode 100644 index 000000000..ccb2193f1 --- /dev/null +++ b/.maestro/contracts/tsk-mvg7dsl7-vj0q93/v1.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 2, + "id": "c-b4e562", + "taskId": "tsk-mvg7dsl7-vj0q93", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-20T04:48:47.847Z", + "lockedAt": "2026-10-20T04:48:47.847Z", + "intent": "r12-t0-migration-guide", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-7bc956", + "text": "docs/MIGRATION-0.12.md has one section per cargo semver-checks break with its shim and a before/after snippet: id_effect_node::transport::certs → id_effect_platform::tls (deprecated re-export for one minor); axum HostConfig / ConnectionLimits TLS and HTTP/3 fields; id_effect_platform HttpClientConfig::http3; QUIC mesh ALPN idm/1 (0.11 nodes are refused); labs RpcGroup::register → #[rpc::service] (and the low-level RpcGroup with .public()/.guard()); OpenAPI 3.0 → 3.1 output of id_effect_rpc; the service template config key server.http3.enabled.", + "kind": "manual" + }, + { + "id": "dw-1e503b", + "text": "CHANGELOG.md 0.12.0 section with Added / Changed / Security / Scorecard subsections; the Scorecard section quotes REPORT-0.12.0 (E, RT, red rows, web.rpc, web.realtime, dist.tls); the Security section lists every call-surface default from the release spec Security table.", + "kind": "manual" + }, + { + "id": "dw-0194d9", + "text": "Gate: `cargo semver-checks` output ⊆ the MIGRATION-0.12 list (diff recorded in the leaf evidence)", + "kind": "manual" + }, + { + "id": "dw-b7ca40", + "text": "Owns: `docs/MIGRATION-0.12.md`, `CHANGELOG.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "l1-exit", + "lockedBy": "l1-exit", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/handoffs/hnd-mva0298c-me1y21.json b/.maestro/handoffs/hnd-mva0298c-me1y21.json new file mode 100644 index 000000000..95e5a2bda --- /dev/null +++ b/.maestro/handoffs/hnd-mva0298c-me1y21.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mva0298c-me1y21", + "task_id": "tsk-mva026ub-1u86pn", + "trigger_verb": "task:claim", + "created_at": "2026-10-15T20:37:09.564Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/http-showcase-ddd.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mva252at-pr2b3c.json b/.maestro/handoffs/hnd-mva252at-pr2b3c.json index 4ea04d00b..b98aeb3ce 100644 --- a/.maestro/handoffs/hnd-mva252at-pr2b3c.json +++ b/.maestro/handoffs/hnd-mva252at-pr2b3c.json @@ -1,9 +1,9 @@ { - "id": "hnd-mva252at-pr2b3c", - "task_id": "tsk-mv9xeqip-axc0oj", - "trigger_verb": "task:claim", - "created_at": "2026-10-15T21:35:19.781Z", - "agent_id": "claude-opus", - "spec_path": "/data/Code/rust/id_effect/.maestro/specs/roadmap-bootstrap.md", - "to_agent": "claude-code" + "id": "hnd-mva252at-pr2b3c", + "task_id": "tsk-mv9xeqip-axc0oj", + "trigger_verb": "task:claim", + "created_at": "2026-10-15T21:35:19.781Z", + "agent_id": "claude-opus", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/roadmap-bootstrap.md", + "to_agent": "claude-code" } diff --git a/.maestro/handoffs/hnd-mva252f7-xnovdq.json b/.maestro/handoffs/hnd-mva252f7-xnovdq.json index 0702bae14..64d516dd0 100644 --- a/.maestro/handoffs/hnd-mva252f7-xnovdq.json +++ b/.maestro/handoffs/hnd-mva252f7-xnovdq.json @@ -1,9 +1,9 @@ { - "id": "hnd-mva252f7-xnovdq", - "task_id": "tsk-mv9xeqip-pixe4f", - "trigger_verb": "task:claim", - "created_at": "2026-10-15T21:35:19.939Z", - "agent_id": "claude-opus", - "spec_path": "/data/Code/rust/id_effect/.maestro/specs/roadmap-bootstrap.md", - "to_agent": "claude-code" + "id": "hnd-mva252f7-xnovdq", + "task_id": "tsk-mv9xeqip-pixe4f", + "trigger_verb": "task:claim", + "created_at": "2026-10-15T21:35:19.939Z", + "agent_id": "claude-opus", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/roadmap-bootstrap.md", + "to_agent": "claude-code" } diff --git a/.maestro/handoffs/hnd-mvaoq242-me2o4m.json b/.maestro/handoffs/hnd-mvaoq242-me2o4m.json new file mode 100644 index 000000000..56965947b --- /dev/null +++ b/.maestro/handoffs/hnd-mvaoq242-me2o4m.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvaoq242-me2o4m", + "task_id": "tsk-mvaoq06i-s0i7vr", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T08:07:30.866Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.8.0-method-rebase.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvaw7nlf-ed7vrh.json b/.maestro/handoffs/hnd-mvaw7nlf-ed7vrh.json new file mode 100644 index 000000000..635906d12 --- /dev/null +++ b/.maestro/handoffs/hnd-mvaw7nlf-ed7vrh.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvaw7nlf-ed7vrh", + "task_id": "tsk-mvaw7lqp-9fes2a", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T11:37:09.171Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.8.0-method-2-1.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvawksua-h712jx.json b/.maestro/handoffs/hnd-mvawksua-h712jx.json new file mode 100644 index 000000000..58ed12c8b --- /dev/null +++ b/.maestro/handoffs/hnd-mvawksua-h712jx.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvawksua-h712jx", + "task_id": "tsk-mvawkapy-nam47g", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T11:47:22.498Z", + "spec_path": ".maestro/specs/releases/0.8/e0-2-security-process.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvawkszp-29udpy.json b/.maestro/handoffs/hnd-mvawkszp-29udpy.json new file mode 100644 index 000000000..6ec902d18 --- /dev/null +++ b/.maestro/handoffs/hnd-mvawkszp-29udpy.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvawkszp-29udpy", + "task_id": "tsk-mvawkapy-h9kogq", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T11:47:22.693Z", + "spec_path": ".maestro/specs/releases/0.8/e0-3-stability.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvawkt3c-5swjq7.json b/.maestro/handoffs/hnd-mvawkt3c-5swjq7.json new file mode 100644 index 000000000..82b3551e6 --- /dev/null +++ b/.maestro/handoffs/hnd-mvawkt3c-5swjq7.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvawkt3c-5swjq7", + "task_id": "tsk-mvawkapy-xdespa", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T11:47:22.824Z", + "spec_path": ".maestro/specs/releases/0.8/e0-4-governance.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvawkt70-9y0x1w.json b/.maestro/handoffs/hnd-mvawkt70-9y0x1w.json new file mode 100644 index 000000000..ec19b4d3b --- /dev/null +++ b/.maestro/handoffs/hnd-mvawkt70-9y0x1w.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvawkt70-9y0x1w", + "task_id": "tsk-mvawkapy-tx4pfl", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T11:47:22.956Z", + "spec_path": ".maestro/specs/releases/0.8/e5-1-web-hardening.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvawktb2-t3ltbl.json b/.maestro/handoffs/hnd-mvawktb2-t3ltbl.json new file mode 100644 index 000000000..8f3889685 --- /dev/null +++ b/.maestro/handoffs/hnd-mvawktb2-t3ltbl.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvawktb2-t3ltbl", + "task_id": "tsk-mvawkapy-uuzduq", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T11:47:23.102Z", + "spec_path": ".maestro/specs/releases/0.8/e6-1a-metrics.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvawktep-3xvzc4.json b/.maestro/handoffs/hnd-mvawktep-3xvzc4.json new file mode 100644 index 000000000..80b3eecff --- /dev/null +++ b/.maestro/handoffs/hnd-mvawktep-3xvzc4.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvawktep-3xvzc4", + "task_id": "tsk-mvawkapy-y6r782", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T11:47:23.233Z", + "spec_path": ".maestro/specs/releases/0.8/e6-1b-health.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvawktj5-mwz6z0.json b/.maestro/handoffs/hnd-mvawktj5-mwz6z0.json new file mode 100644 index 000000000..455c85843 --- /dev/null +++ b/.maestro/handoffs/hnd-mvawktj5-mwz6z0.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvawktj5-mwz6z0", + "task_id": "tsk-mvawkapy-uh71va", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T11:47:23.393Z", + "spec_path": ".maestro/specs/releases/0.8/e1-10-cli-kit.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvawktn5-g53b4r.json b/.maestro/handoffs/hnd-mvawktn5-g53b4r.json new file mode 100644 index 000000000..a92c1c845 --- /dev/null +++ b/.maestro/handoffs/hnd-mvawktn5-g53b4r.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvawktn5-g53b4r", + "task_id": "tsk-mvawkapy-km3wy8", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T11:47:23.537Z", + "spec_path": ".maestro/specs/releases/0.8/e2-6-compile-time.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvawktqx-vnlrf9.json b/.maestro/handoffs/hnd-mvawktqx-vnlrf9.json new file mode 100644 index 000000000..9d9751709 --- /dev/null +++ b/.maestro/handoffs/hnd-mvawktqx-vnlrf9.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvawktqx-vnlrf9", + "task_id": "tsk-mvawkapy-fwcf2f", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T11:47:23.673Z", + "spec_path": ".maestro/specs/releases/0.8/e2-1h-hygiene.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvb0jt66-2g908f.json b/.maestro/handoffs/hnd-mvb0jt66-2g908f.json new file mode 100644 index 000000000..c5212078d --- /dev/null +++ b/.maestro/handoffs/hnd-mvb0jt66-2g908f.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvb0jt66-2g908f", + "task_id": "tsk-mvb0jm49-3ytgwg", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T13:38:34.734Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.8/e2-1c-cron-resilience.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvb0jt9s-fvr2yl.json b/.maestro/handoffs/hnd-mvb0jt9s-fvr2yl.json new file mode 100644 index 000000000..15351330b --- /dev/null +++ b/.maestro/handoffs/hnd-mvb0jt9s-fvr2yl.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvb0jt9s-fvr2yl", + "task_id": "tsk-mvb0jm8t-w9qmja", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T13:38:34.864Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.8/e2-1s-stream-ops.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvb0jtdi-ursc66.json b/.maestro/handoffs/hnd-mvb0jtdi-ursc66.json new file mode 100644 index 000000000..4c78d59c6 --- /dev/null +++ b/.maestro/handoffs/hnd-mvb0jtdi-ursc66.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvb0jtdi-ursc66", + "task_id": "tsk-mvb0jmcb-ajdki6", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T13:38:34.998Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.8/e2-3-schema.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvb0jth1-tdn9by.json b/.maestro/handoffs/hnd-mvb0jth1-tdn9by.json new file mode 100644 index 000000000..a25240a2b --- /dev/null +++ b/.maestro/handoffs/hnd-mvb0jth1-tdn9by.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvb0jth1-tdn9by", + "task_id": "tsk-mvb0jmfu-jljtq9", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T13:38:35.125Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.8/e2-2-property-testing.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvb0jtkl-vt76vb.json b/.maestro/handoffs/hnd-mvb0jtkl-vt76vb.json new file mode 100644 index 000000000..6e735d9fc --- /dev/null +++ b/.maestro/handoffs/hnd-mvb0jtkl-vt76vb.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvb0jtkl-vt76vb", + "task_id": "tsk-mvb0jmj8-is6qdv", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T13:38:35.253Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.8/e2-4-determinism-idioms.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvb33b29-ii64px.json b/.maestro/handoffs/hnd-mvb33b29-ii64px.json new file mode 100644 index 000000000..a99eabb74 --- /dev/null +++ b/.maestro/handoffs/hnd-mvb33b29-ii64px.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvb33b29-ii64px", + "task_id": "tsk-mvb3399t-pysl4g", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T14:49:43.617Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.8/e2-3b-schema-followups.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvb8imbw-zpztu8.json b/.maestro/handoffs/hnd-mvb8imbw-zpztu8.json new file mode 100644 index 000000000..231c1b78f --- /dev/null +++ b/.maestro/handoffs/hnd-mvb8imbw-zpztu8.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvb8imbw-zpztu8", + "task_id": "tsk-mvb8iicl-xlm756", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T17:21:36.140Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.8/w3-a-kit.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvb8imf4-fwzmyp.json b/.maestro/handoffs/hnd-mvb8imf4-fwzmyp.json new file mode 100644 index 000000000..ada6dc871 --- /dev/null +++ b/.maestro/handoffs/hnd-mvb8imf4-fwzmyp.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvb8imf4-fwzmyp", + "task_id": "tsk-mvb8iifv-1kp3tn", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T17:21:36.256Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.8/w3-d-diagnostics-gallery.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvb8imig-3o9kbh.json b/.maestro/handoffs/hnd-mvb8imig-3o9kbh.json new file mode 100644 index 000000000..db0441614 --- /dev/null +++ b/.maestro/handoffs/hnd-mvb8imig-3o9kbh.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvb8imig-3o9kbh", + "task_id": "tsk-mvb8iij2-kzsw5d", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T17:21:36.376Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.8/w3-e-book-snippets.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvb8sz6d-olit3r.json b/.maestro/handoffs/hnd-mvb8sz6d-olit3r.json new file mode 100644 index 000000000..5c5eb49ac --- /dev/null +++ b/.maestro/handoffs/hnd-mvb8sz6d-olit3r.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvb8sz6d-olit3r", + "task_id": "tsk-mvb8sxid-wpkrtg", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T17:29:39.349Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.8/w3-b-tool-new.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvb9djg1-k0xl30.json b/.maestro/handoffs/hnd-mvb9djg1-k0xl30.json new file mode 100644 index 000000000..bd6af51bf --- /dev/null +++ b/.maestro/handoffs/hnd-mvb9djg1-k0xl30.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvb9djg1-k0xl30", + "task_id": "tsk-mvb9dhfl-6cdwnk", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T17:45:38.737Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.8/w3-e4-snippet-deps.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvb9wspe-9rw8zj.json b/.maestro/handoffs/hnd-mvb9wspe-9rw8zj.json new file mode 100644 index 000000000..3fba2a9d1 --- /dev/null +++ b/.maestro/handoffs/hnd-mvb9wspe-9rw8zj.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvb9wspe-9rw8zj", + "task_id": "tsk-mvb9wqu3-mo1a0u", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T18:00:37.202Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.8/w3-c-doctor.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbanfqt-hdmvj4.json b/.maestro/handoffs/hnd-mvbanfqt-hdmvj4.json new file mode 100644 index 000000000..550de6656 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbanfqt-hdmvj4.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbanfqt-hdmvj4", + "task_id": "tsk-mvbanfma-w9xz64", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T18:21:20.117Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.8/w3-f-tutorial-kpis.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbbmn4y-n67iy2.json b/.maestro/handoffs/hnd-mvbbmn4y-n67iy2.json new file mode 100644 index 000000000..8fd40bdf0 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbbmn4y-n67iy2.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbbmn4y-n67iy2", + "task_id": "tsk-mvbbmmz0-vebdrd", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T18:48:42.658Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.8/e0-1a-remove-obix-outbox.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbbxyhh-7fero4.json b/.maestro/handoffs/hnd-mvbbxyhh-7fero4.json new file mode 100644 index 000000000..e0f433b40 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbbxyhh-7fero4.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbbxyhh-7fero4", + "task_id": "tsk-mvawkapy-lzvg52", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T18:57:30.581Z", + "spec_path": ".maestro/specs/releases/0.8/e0-1-mit-relicense.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbdt6e0-cq4w17.json b/.maestro/handoffs/hnd-mvbdt6e0-cq4w17.json new file mode 100644 index 000000000..acb18ccb7 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbdt6e0-cq4w17.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbdt6e0-cq4w17", + "task_id": "tsk-mvbdsp4f-xmojqz", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T19:49:46.776Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbdt6hw-cwl9w5.json b/.maestro/handoffs/hnd-mvbdt6hw-cwl9w5.json new file mode 100644 index 000000000..36594c2c7 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbdt6hw-cwl9w5.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbdt6hw-cwl9w5", + "task_id": "tsk-mvbdsp4f-wd3fai", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T19:49:46.916Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbdt6ld-m5zgd1.json b/.maestro/handoffs/hnd-mvbdt6ld-m5zgd1.json new file mode 100644 index 000000000..00ef977d7 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbdt6ld-m5zgd1.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbdt6ld-m5zgd1", + "task_id": "tsk-mvbdsp4f-ex9rcm", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T19:49:47.041Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbdt6ou-pxb02n.json b/.maestro/handoffs/hnd-mvbdt6ou-pxb02n.json new file mode 100644 index 000000000..641ca44fd --- /dev/null +++ b/.maestro/handoffs/hnd-mvbdt6ou-pxb02n.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbdt6ou-pxb02n", + "task_id": "tsk-mvbdsp4f-p6l6nv", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T19:49:47.166Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbdxynm-ezlxsq.json b/.maestro/handoffs/hnd-mvbdxynm-ezlxsq.json new file mode 100644 index 000000000..868d5ff0a --- /dev/null +++ b/.maestro/handoffs/hnd-mvbdxynm-ezlxsq.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbdxynm-ezlxsq", + "task_id": "tsk-mvbdsp4f-bcinb5", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T19:53:30.034Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbdxyr4-xhp815.json b/.maestro/handoffs/hnd-mvbdxyr4-xhp815.json new file mode 100644 index 000000000..f7cf893d2 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbdxyr4-xhp815.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbdxyr4-xhp815", + "task_id": "tsk-mvbdsp4f-c6kk1f", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T19:53:30.160Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbeajau-9niym8.json b/.maestro/handoffs/hnd-mvbeajau-9niym8.json new file mode 100644 index 000000000..428f09272 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbeajau-9niym8.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbeajau-9niym8", + "task_id": "tsk-mvbdsp4f-souo6g", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T20:03:16.662Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbebkra-115n3a.json b/.maestro/handoffs/hnd-mvbebkra-115n3a.json new file mode 100644 index 000000000..f37c56b73 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbebkra-115n3a.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbebkra-115n3a", + "task_id": "tsk-mvbdsp4f-cbg990", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T20:04:05.206Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbehsqt-0lumo0.json b/.maestro/handoffs/hnd-mvbehsqt-0lumo0.json new file mode 100644 index 000000000..cb378c183 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbehsqt-0lumo0.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbehsqt-0lumo0", + "task_id": "tsk-mvbdsp4f-5hrfbn", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T20:08:55.493Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbfbg6b-osf2tg.json b/.maestro/handoffs/hnd-mvbfbg6b-osf2tg.json new file mode 100644 index 000000000..d0dec4791 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbfbg6b-osf2tg.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbfbg6b-osf2tg", + "task_id": "tsk-mvbdsp4f-i8nl5p", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T20:31:58.883Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbfbgfb-oqy0zz.json b/.maestro/handoffs/hnd-mvbfbgfb-oqy0zz.json new file mode 100644 index 000000000..d20a242b3 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbfbgfb-oqy0zz.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbfbgfb-oqy0zz", + "task_id": "tsk-mvbdsp4f-y1xx0b", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T20:31:59.207Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbfl58d-tt87q9.json b/.maestro/handoffs/hnd-mvbfl58d-tt87q9.json new file mode 100644 index 000000000..c9da996b2 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbfl58d-tt87q9.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbfl58d-tt87q9", + "task_id": "tsk-mvbdsp4f-ua6x6x", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T20:39:31.261Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbg11az-pplnlu.json b/.maestro/handoffs/hnd-mvbg11az-pplnlu.json new file mode 100644 index 000000000..190801103 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbg11az-pplnlu.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbg11az-pplnlu", + "task_id": "tsk-mvbdsp4f-e7yo8j", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T20:51:52.667Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbg11fu-zd71q6.json b/.maestro/handoffs/hnd-mvbg11fu-zd71q6.json new file mode 100644 index 000000000..8ce639886 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbg11fu-zd71q6.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbg11fu-zd71q6", + "task_id": "tsk-mvbdsp4f-spcif4", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T20:51:52.842Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbg11k5-o1xub8.json b/.maestro/handoffs/hnd-mvbg11k5-o1xub8.json new file mode 100644 index 000000000..5a3963583 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbg11k5-o1xub8.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbg11k5-o1xub8", + "task_id": "tsk-mvbdsp4f-a1hau9", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T20:51:52.997Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbg7cwu-eruy8b.json b/.maestro/handoffs/hnd-mvbg7cwu-eruy8b.json new file mode 100644 index 000000000..a82d97c9a --- /dev/null +++ b/.maestro/handoffs/hnd-mvbg7cwu-eruy8b.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbg7cwu-eruy8b", + "task_id": "tsk-mvbg796n-xpvbrc", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T20:56:47.646Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/e5-2a-pg-session-store.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbgn40k-5y8z1q.json b/.maestro/handoffs/hnd-mvbgn40k-5y8z1q.json new file mode 100644 index 000000000..830183c79 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbgn40k-5y8z1q.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbgn40k-5y8z1q", + "task_id": "tsk-mvbdsp4f-2m3dmu", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T21:09:02.612Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbh37v7-jvnioq.json b/.maestro/handoffs/hnd-mvbh37v7-jvnioq.json new file mode 100644 index 000000000..2db406971 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbh37v7-jvnioq.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbh37v7-jvnioq", + "task_id": "tsk-mvbdsp4f-0yq2ci", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T21:21:34.099Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbh47rq-2yd70k.json b/.maestro/handoffs/hnd-mvbh47rq-2yd70k.json new file mode 100644 index 000000000..07131752b --- /dev/null +++ b/.maestro/handoffs/hnd-mvbh47rq-2yd70k.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbh47rq-2yd70k", + "task_id": "tsk-mvbh3xxf-emsxrb", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T21:22:20.630Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e3-1b-sqlite.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbh47wl-18lsui.json b/.maestro/handoffs/hnd-mvbh47wl-18lsui.json new file mode 100644 index 000000000..39d7653a6 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbh47wl-18lsui.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbh47wl-18lsui", + "task_id": "tsk-mvbh3xq5-gl0mv7", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T21:22:20.805Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e3-1b-conformance-routing.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbh4801-qu8h90.json b/.maestro/handoffs/hnd-mvbh4801-qu8h90.json new file mode 100644 index 000000000..e5a73cf79 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbh4801-qu8h90.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbh4801-qu8h90", + "task_id": "tsk-mvbh3y7e-zu7pb4", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T21:22:20.929Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e3-3-sea-query.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbh483d-zraw8o.json b/.maestro/handoffs/hnd-mvbh483d-zraw8o.json new file mode 100644 index 000000000..7ef7b308e --- /dev/null +++ b/.maestro/handoffs/hnd-mvbh483d-zraw8o.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbh483d-zraw8o", + "task_id": "tsk-mvbh3yki-lxt7mh", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T21:22:21.049Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e4-2-openapi-doc.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbh486t-2jsuyj.json b/.maestro/handoffs/hnd-mvbh486t-2jsuyj.json new file mode 100644 index 000000000..3438e9839 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbh486t-2jsuyj.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbh486t-2jsuyj", + "task_id": "tsk-mvbh3ye3-0dqyir", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T21:22:21.173Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e4-1b-layers.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbh48a5-61nmc9.json b/.maestro/handoffs/hnd-mvbh48a5-61nmc9.json new file mode 100644 index 000000000..6cd99d753 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbh48a5-61nmc9.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbh48a5-61nmc9", + "task_id": "tsk-mvbh3yr2-bsovx7", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T21:22:21.293Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e5-2b-oidc.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbh48di-biv6bq.json b/.maestro/handoffs/hnd-mvbh48di-biv6bq.json new file mode 100644 index 000000000..898cd5e26 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbh48di-biv6bq.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbh48di-biv6bq", + "task_id": "tsk-mvbh3z49-hbvk3j", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T21:22:21.415Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e6-5-containers.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbhysda-q1j7ue.json b/.maestro/handoffs/hnd-mvbhysda-q1j7ue.json new file mode 100644 index 000000000..014bdb626 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbhysda-q1j7ue.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbhysda-q1j7ue", + "task_id": "tsk-mvbh3yhd-mlyvgg", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T21:46:07.006Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e4-1b-static-cache.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbhysrz-wr7b5l.json b/.maestro/handoffs/hnd-mvbhysrz-wr7b5l.json new file mode 100644 index 000000000..9f492ebf9 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbhysrz-wr7b5l.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbhysrz-wr7b5l", + "task_id": "tsk-mvbh3yue-83n9vp", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T21:46:07.535Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e5-2b-totp.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbhyt6n-uzy320.json b/.maestro/handoffs/hnd-mvbhyt6n-uzy320.json new file mode 100644 index 000000000..6c99d446c --- /dev/null +++ b/.maestro/handoffs/hnd-mvbhyt6n-uzy320.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbhyt6n-uzy320", + "task_id": "tsk-mvbh3yxp-p9pp0e", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T21:46:08.063Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e5-5-audit.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbhythp-4ewmoa.json b/.maestro/handoffs/hnd-mvbhythp-4ewmoa.json new file mode 100644 index 000000000..86ddf527d --- /dev/null +++ b/.maestro/handoffs/hnd-mvbhythp-4ewmoa.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbhythp-4ewmoa", + "task_id": "tsk-mvbh3z0z-819ef0", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T21:46:08.461Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e5-5-tenancy-doc.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbixqfc-etm0af.json b/.maestro/handoffs/hnd-mvbixqfc-etm0af.json new file mode 100644 index 000000000..013bc8105 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbixqfc-etm0af.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbixqfc-etm0af", + "task_id": "tsk-mvbh3yns-8zm2bm", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T22:13:17.448Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e4-2-ui-routes.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbixqtv-unbvn4.json b/.maestro/handoffs/hnd-mvbixqtv-unbvn4.json new file mode 100644 index 000000000..70f5c9662 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbixqtv-unbvn4.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbixqtv-unbvn4", + "task_id": "tsk-mvbh3y45-3zte0e", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T22:13:17.972Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e3-3-repository.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbixr8c-4u9dah.json b/.maestro/handoffs/hnd-mvbixr8c-4u9dah.json new file mode 100644 index 000000000..665553fa5 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbixr8c-4u9dah.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbixr8c-4u9dah", + "task_id": "tsk-mvbh3xu3-3dzjef", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T22:13:18.492Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e3-1b-mysql.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbixrim-0r572w.json b/.maestro/handoffs/hnd-mvbixrim-0r572w.json new file mode 100644 index 000000000..49b17253a --- /dev/null +++ b/.maestro/handoffs/hnd-mvbixrim-0r572w.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbixrim-0r572w", + "task_id": "tsk-mvbh3yap-l09oxk", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T22:13:18.862Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e3-4-redis.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbjroej-gj30uo.json b/.maestro/handoffs/hnd-mvbjroej-gj30uo.json new file mode 100644 index 000000000..6e8c0f33b --- /dev/null +++ b/.maestro/handoffs/hnd-mvbjroej-gj30uo.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbjroej-gj30uo", + "task_id": "tsk-mvbh3z7s-2ol5gk", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T22:36:34.507Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e6-5-isolation-fixtures.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbko9c8-l29lcm.json b/.maestro/handoffs/hnd-mvbko9c8-l29lcm.json new file mode 100644 index 000000000..fbccaab73 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbko9c8-l29lcm.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbko9c8-l29lcm", + "task_id": "tsk-mvbh3y0u-n3qtug", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T23:01:54.632Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e3-3-docs.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbl5872-wy4cqd.json b/.maestro/handoffs/hnd-mvbl5872-wy4cqd.json new file mode 100644 index 000000000..5d9a1e7cf --- /dev/null +++ b/.maestro/handoffs/hnd-mvbl5872-wy4cqd.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbl5872-wy4cqd", + "task_id": "tsk-mvbl55h5-z4i872", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T23:15:06.302Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-sql-tls.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvblcgxb-7689rf.json b/.maestro/handoffs/hnd-mvblcgxb-7689rf.json new file mode 100644 index 000000000..e46880c5c --- /dev/null +++ b/.maestro/handoffs/hnd-mvblcgxb-7689rf.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvblcgxb-7689rf", + "task_id": "tsk-mvblcbcl-ojq8t0", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T23:20:44.207Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-w3-kit-template.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbm0dm9-uaot89.json b/.maestro/handoffs/hnd-mvbm0dm9-uaot89.json new file mode 100644 index 000000000..cc9effd86 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbm0dm9-uaot89.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbm0dm9-uaot89", + "task_id": "tsk-mvbm09jg-c92j9a", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T23:39:19.665Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-w3-tutorial.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbm0dxo-wfeibq.json b/.maestro/handoffs/hnd-mvbm0dxo-wfeibq.json new file mode 100644 index 000000000..b0ba5f687 --- /dev/null +++ b/.maestro/handoffs/hnd-mvbm0dxo-wfeibq.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbm0dxo-wfeibq", + "task_id": "tsk-mvblcbgd-lktftw", + "trigger_verb": "task:claim", + "created_at": "2026-10-16T23:39:20.076Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-w3-ledger.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvboub0s-yyifmq.json b/.maestro/handoffs/hnd-mvboub0s-yyifmq.json new file mode 100644 index 000000000..705dc6461 --- /dev/null +++ b/.maestro/handoffs/hnd-mvboub0s-yyifmq.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvboub0s-yyifmq", + "task_id": "tsk-mvblcbjl-ijn35n", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T00:58:35.212Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-w3-k3.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvboub42-dptr1j.json b/.maestro/handoffs/hnd-mvboub42-dptr1j.json new file mode 100644 index 000000000..1d366dbe9 --- /dev/null +++ b/.maestro/handoffs/hnd-mvboub42-dptr1j.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvboub42-dptr1j", + "task_id": "tsk-mvblcbms-o8fsc1", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T00:58:35.330Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-w3-level-audit.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvbp9cb2-ynfrbt.json b/.maestro/handoffs/hnd-mvbp9cb2-ynfrbt.json new file mode 100644 index 000000000..34b7f6c7d --- /dev/null +++ b/.maestro/handoffs/hnd-mvbp9cb2-ynfrbt.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvbp9cb2-ynfrbt", + "task_id": "tsk-mvbp968a-2m4p9i", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T01:10:16.718Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-w3-gaps.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcjwrrq-bwj4fl.json b/.maestro/handoffs/hnd-mvcjwrrq-bwj4fl.json new file mode 100644 index 000000000..b9e7ed49c --- /dev/null +++ b/.maestro/handoffs/hnd-mvcjwrrq-bwj4fl.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcjwrrq-bwj4fl", + "task_id": "tsk-mvcjv4jp-snazrp", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T15:28:18.326Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-t0-adr-skeletons.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcjwrv7-e8wvsl.json b/.maestro/handoffs/hnd-mvcjwrv7-e8wvsl.json new file mode 100644 index 000000000..eceb9df42 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcjwrv7-e8wvsl.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcjwrv7-e8wvsl", + "task_id": "tsk-mvcjv4gc-nokr7e", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T15:28:18.451Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-sqlt-pg-wire.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcjwryk-dawvm1.json b/.maestro/handoffs/hnd-mvcjwryk-dawvm1.json new file mode 100644 index 000000000..1d9c8c6fd --- /dev/null +++ b/.maestro/handoffs/hnd-mvcjwryk-dawvm1.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcjwryk-dawvm1", + "task_id": "tsk-mvcjv2xv-k2omgn", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T15:28:18.572Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-co-schema-errors.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcjws21-86akbh.json b/.maestro/handoffs/hnd-mvcjws21-86akbh.json new file mode 100644 index 000000000..b5c38a652 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcjws21-86akbh.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcjws21-86akbh", + "task_id": "tsk-mvcjv3m4-28cr81", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T15:28:18.697Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e6-4-redaction-sampling.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvckbvk5-jaqzzo.json b/.maestro/handoffs/hnd-mvckbvk5-jaqzzo.json new file mode 100644 index 000000000..1ca560a6f --- /dev/null +++ b/.maestro/handoffs/hnd-mvckbvk5-jaqzzo.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvckbvk5-jaqzzo", + "task_id": "tsk-mvcjv35b-3d8syc", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T15:40:03.077Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e4-3-client-defaults.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvckciuq-61emr2.json b/.maestro/handoffs/hnd-mvckciuq-61emr2.json new file mode 100644 index 000000000..bd0309e53 --- /dev/null +++ b/.maestro/handoffs/hnd-mvckciuq-61emr2.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvckciuq-61emr2", + "task_id": "tsk-mvcjv31o-sevooz", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T15:40:33.266Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-co-sql-errors.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcke9ej-skzb62.json b/.maestro/handoffs/hnd-mvcke9ej-skzb62.json new file mode 100644 index 000000000..5eb1ef746 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcke9ej-skzb62.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcke9ej-skzb62", + "task_id": "tsk-mvcjv3wa-40y1j9", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T15:41:54.331Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-1-queue-core.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcke9im-f2w51o.json b/.maestro/handoffs/hnd-mvcke9im-f2w51o.json new file mode 100644 index 000000000..a858b5eea --- /dev/null +++ b/.maestro/handoffs/hnd-mvcke9im-f2w51o.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcke9im-f2w51o", + "task_id": "tsk-mvcjv3iq-y7wzar", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T15:41:54.478Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e6-2-fiber-registry.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcke9ml-380z2z.json b/.maestro/handoffs/hnd-mvcke9ml-380z2z.json new file mode 100644 index 000000000..b197c8de2 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcke9ml-380z2z.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcke9ml-380z2z", + "task_id": "tsk-mvcjv3fd-36woc8", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T15:41:54.621Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e4-4-storage.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvckg208-lfaowm.json b/.maestro/handoffs/hnd-mvckg208-lfaowm.json new file mode 100644 index 000000000..29b8c109d --- /dev/null +++ b/.maestro/handoffs/hnd-mvckg208-lfaowm.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvckg208-lfaowm", + "task_id": "tsk-mvcjv4cx-u59do2", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T15:43:18.056Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-sqlt-mysql-query-ci.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcngf2f-n63f4h.json b/.maestro/handoffs/hnd-mvcngf2f-n63f4h.json new file mode 100644 index 000000000..ba4734625 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcngf2f-n63f4h.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcngf2f-n63f4h", + "task_id": "tsk-mvcjv38n-qmey5i", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T17:07:33.831Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e4-3-tracing.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcngf76-3hfcz7.json b/.maestro/handoffs/hnd-mvcngf76-3hfcz7.json new file mode 100644 index 000000000..9d3530858 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcngf76-3hfcz7.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcngf76-3hfcz7", + "task_id": "tsk-mvcjv3pj-tef3al", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T17:07:34.002Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e6-5b-faults.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcngfau-c1xgey.json b/.maestro/handoffs/hnd-mvcngfau-c1xgey.json new file mode 100644 index 000000000..bcd413c0d --- /dev/null +++ b/.maestro/handoffs/hnd-mvcngfau-c1xgey.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcngfau-c1xgey", + "task_id": "tsk-mvcjv466-ezgto8", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T17:07:34.134Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-reload-config.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcngff3-emrowy.json b/.maestro/handoffs/hnd-mvcngff3-emrowy.json new file mode 100644 index 000000000..18efec34c --- /dev/null +++ b/.maestro/handoffs/hnd-mvcngff3-emrowy.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcngff3-emrowy", + "task_id": "tsk-mvcjv3zi-qxn0mq", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T17:07:34.287Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-4-promote.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcno4nc-tx4u4d.json b/.maestro/handoffs/hnd-mvcno4nc-tx4u4d.json new file mode 100644 index 000000000..405395e3f --- /dev/null +++ b/.maestro/handoffs/hnd-mvcno4nc-tx4u4d.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcno4nc-tx4u4d", + "task_id": "tsk-mvcjv49j-j19atg", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T17:13:33.576Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-reload-rolling.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcntjt5-gf4zzv.json b/.maestro/handoffs/hnd-mvcntjt5-gf4zzv.json new file mode 100644 index 000000000..e5b900538 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcntjt5-gf4zzv.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcntjt5-gf4zzv", + "task_id": "tsk-mvcjv3sy-7786hr", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T17:17:46.505Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e6-5b-guide.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcohj0b-sbgsmm.json b/.maestro/handoffs/hnd-mvcohj0b-sbgsmm.json new file mode 100644 index 000000000..72210b789 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcohj0b-sbgsmm.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcohj0b-sbgsmm", + "task_id": "tsk-mvcjv42t-2myvtg", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T17:36:25.211Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-4-snapshots-subscriptions.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcp8ti4-ckcuoy.json b/.maestro/handoffs/hnd-mvcp8ti4-ckcuoy.json new file mode 100644 index 000000000..b39b434f9 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcp8ti4-ckcuoy.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcp8ti4-ckcuoy", + "task_id": "tsk-mvcjv3bz-jrd70e", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T17:57:38.524Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e4-4-container-docs.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcpf9cu-7b2wdg.json b/.maestro/handoffs/hnd-mvcpf9cu-7b2wdg.json new file mode 100644 index 000000000..c333d4850 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcpf9cu-7b2wdg.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcpf9cu-7b2wdg", + "task_id": "tsk-mvcpexut-0na9la", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T18:02:39.006Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-3-transports.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcphvsc-pxv725.json b/.maestro/handoffs/hnd-mvcphvsc-pxv725.json new file mode 100644 index 000000000..24a58b27f --- /dev/null +++ b/.maestro/handoffs/hnd-mvcphvsc-pxv725.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcphvsc-pxv725", + "task_id": "tsk-mvcpexl4-f87btw", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T18:04:41.388Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-2-broker-api.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcpjskd-1nf2m1.json b/.maestro/handoffs/hnd-mvcpjskd-1nf2m1.json new file mode 100644 index 000000000..955cb3231 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcpjskd-1nf2m1.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcpjskd-1nf2m1", + "task_id": "tsk-mvcpey7z-zm5f97", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T18:06:10.525Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e6-2-console-api.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcpu9nh-vfrxqf.json b/.maestro/handoffs/hnd-mvcpu9nh-vfrxqf.json new file mode 100644 index 000000000..7b68cb1ff --- /dev/null +++ b/.maestro/handoffs/hnd-mvcpu9nh-vfrxqf.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcpu9nh-vfrxqf", + "task_id": "tsk-mvcpexet-7d5r2f", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T18:14:19.229Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-1-cron.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcpw45p-0txp3i.json b/.maestro/handoffs/hnd-mvcpw45p-0txp3i.json new file mode 100644 index 000000000..3726d188e --- /dev/null +++ b/.maestro/handoffs/hnd-mvcpw45p-0txp3i.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcpw45p-0txp3i", + "task_id": "tsk-mvcpexxx-4ohdhl", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T18:15:45.421Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-3-templates-preview.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcpyh8n-xr8446.json b/.maestro/handoffs/hnd-mvcpyh8n-xr8446.json new file mode 100644 index 000000000..bafa7c35b --- /dev/null +++ b/.maestro/handoffs/hnd-mvcpyh8n-xr8446.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcpyh8n-xr8446", + "task_id": "tsk-mvcpexoh-4d52ro", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T18:17:35.687Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-2-outbox-inbox.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcpztar-w3hc5j.json b/.maestro/handoffs/hnd-mvcpztar-w3hc5j.json new file mode 100644 index 000000000..653e90dde --- /dev/null +++ b/.maestro/handoffs/hnd-mvcpztar-w3hc5j.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcpztar-w3hc5j", + "task_id": "tsk-mvcpeyb9-t3b4bu", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T18:18:37.971Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e6-2-dashboard.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcqbavx-vvrnlc.json b/.maestro/handoffs/hnd-mvcqbavx-vvrnlc.json new file mode 100644 index 000000000..51c34fbcb --- /dev/null +++ b/.maestro/handoffs/hnd-mvcqbavx-vvrnlc.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcqbavx-vvrnlc", + "task_id": "tsk-mvcpeyei-jtr1wv", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T18:27:33.981Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e1-5-dev.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcqjayl-6b57lx.json b/.maestro/handoffs/hnd-mvcqjayl-6b57lx.json new file mode 100644 index 000000000..97f1b1b22 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcqjayl-6b57lx.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcqjayl-6b57lx", + "task_id": "tsk-mvcpexrp-ffsx4n", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T18:33:47.325Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-2-redis-streams.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcqjb23-4npqiz.json b/.maestro/handoffs/hnd-mvcqjb23-4npqiz.json new file mode 100644 index 000000000..d47a86d90 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcqjb23-4npqiz.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcqjb23-4npqiz", + "task_id": "tsk-mvcpexhy-k9wn2c", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T18:33:47.451Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-1-docs-labs.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcqkor9-hq8oxz.json b/.maestro/handoffs/hnd-mvcqkor9-hq8oxz.json new file mode 100644 index 000000000..b2be09bb4 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcqkor9-hq8oxz.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcqkor9-hq8oxz", + "task_id": "tsk-mvcpey14-2k0wis", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T18:34:51.861Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-3-deliver-later.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcqnczr-kyf3rw.json b/.maestro/handoffs/hnd-mvcqnczr-kyf3rw.json new file mode 100644 index 000000000..8ae664cbb --- /dev/null +++ b/.maestro/handoffs/hnd-mvcqnczr-kyf3rw.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcqnczr-kyf3rw", + "task_id": "tsk-mvcpeyhp-jmyzba", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T18:36:56.583Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e1-5-devcontainer.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcqw1h2-424dvt.json b/.maestro/handoffs/hnd-mvcqw1h2-424dvt.json new file mode 100644 index 000000000..a093fb3b7 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcqw1h2-424dvt.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcqw1h2-424dvt", + "task_id": "tsk-mvcpeyky-peqniz", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T18:43:41.558Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e1-3-generate-job.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcriam1-bqhjqh.json b/.maestro/handoffs/hnd-mvcriam1-bqhjqh.json new file mode 100644 index 000000000..3a9559e33 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcriam1-bqhjqh.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcriam1-bqhjqh", + "task_id": "tsk-mvcpey4b-b89h8y", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T19:00:59.833Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-auth8-accounts.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcrl4hr-od6djl.json b/.maestro/handoffs/hnd-mvcrl4hr-od6djl.json new file mode 100644 index 000000000..09c287111 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcrl4hr-od6djl.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcrl4hr-od6djl", + "task_id": "tsk-mvcrl0u6-q95oms", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T19:03:11.871Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-w3-kit-template.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcs34g2-4loqul.json b/.maestro/handoffs/hnd-mvcs34g2-4loqul.json new file mode 100644 index 000000000..aa89a5cd3 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcs34g2-4loqul.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcs34g2-4loqul", + "task_id": "tsk-mvcjv4mz-pvmh4b", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T19:17:11.618Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-t0-k2-k6.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcsu6cd-4i9s43.json b/.maestro/handoffs/hnd-mvcsu6cd-4i9s43.json new file mode 100644 index 000000000..78113c2d5 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcsu6cd-4i9s43.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcsu6cd-4i9s43", + "task_id": "tsk-mvcrl0xj-ldfumo", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T19:38:13.789Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-w3-ledger.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcsu6fr-rx1lcj.json b/.maestro/handoffs/hnd-mvcsu6fr-rx1lcj.json new file mode 100644 index 000000000..1a1fad530 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcsu6fr-rx1lcj.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcsu6fr-rx1lcj", + "task_id": "tsk-mvcrl146-fp6v3u", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T19:38:13.911Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-w3-level-audit.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvcyx5nd-xcvxfx.json b/.maestro/handoffs/hnd-mvcyx5nd-xcvxfx.json new file mode 100644 index 000000000..0d212c218 --- /dev/null +++ b/.maestro/handoffs/hnd-mvcyx5nd-xcvxfx.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvcyx5nd-xcvxfx", + "task_id": "tsk-mvcrl10t-m63g70", + "trigger_verb": "task:claim", + "created_at": "2026-10-17T22:28:30.553Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-w3-kpis.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvd9mq32-vs3dxb.json b/.maestro/handoffs/hnd-mvd9mq32-vs3dxb.json new file mode 100644 index 000000000..17bbebc2b --- /dev/null +++ b/.maestro/handoffs/hnd-mvd9mq32-vs3dxb.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvd9mq32-vs3dxb", + "task_id": "tsk-mvd9mln9-g1f798", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T03:28:19.598Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10.1/r101-http.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvd9mq6d-56bytn.json b/.maestro/handoffs/hnd-mvd9mq6d-56bytn.json new file mode 100644 index 000000000..3ad43e94e --- /dev/null +++ b/.maestro/handoffs/hnd-mvd9mq6d-56bytn.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvd9mq6d-56bytn", + "task_id": "tsk-mvd9mlqi-lnd416", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T03:28:19.717Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10.1/r101-mesh.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvd9mq9j-64lcpp.json b/.maestro/handoffs/hnd-mvd9mq9j-64lcpp.json new file mode 100644 index 000000000..a37e1ac0a --- /dev/null +++ b/.maestro/handoffs/hnd-mvd9mq9j-64lcpp.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvd9mq9j-64lcpp", + "task_id": "tsk-mvd9mltr-5gll5q", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T03:28:19.831Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10.1/r101-auth.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvd9mqct-s3qigy.json b/.maestro/handoffs/hnd-mvd9mqct-s3qigy.json new file mode 100644 index 000000000..5da6e8ce4 --- /dev/null +++ b/.maestro/handoffs/hnd-mvd9mqct-s3qigy.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvd9mqct-s3qigy", + "task_id": "tsk-mvd9mlx1-w6ietq", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T03:28:19.949Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.10.1/r101-client.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvdf5hzv-1vd86z.json b/.maestro/handoffs/hnd-mvdf5hzv-1vd86z.json new file mode 100644 index 000000000..39407c4ff --- /dev/null +++ b/.maestro/handoffs/hnd-mvdf5hzv-1vd86z.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvdf5hzv-1vd86z", + "task_id": "tsk-mvdf5hvl-qkcn8e", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T06:02:53.659Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-adr0013.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvdfbkz0-4rc5uf.json b/.maestro/handoffs/hnd-mvdfbkz0-4rc5uf.json new file mode 100644 index 000000000..d5282a323 --- /dev/null +++ b/.maestro/handoffs/hnd-mvdfbkz0-4rc5uf.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvdfbkz0-4rc5uf", + "task_id": "tsk-mvdfbkr7-8wkwg5", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T06:07:37.452Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-t0-skeletons.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvdfoc6y-xmudjx.json b/.maestro/handoffs/hnd-mvdfoc6y-xmudjx.json new file mode 100644 index 000000000..a41ac42f3 --- /dev/null +++ b/.maestro/handoffs/hnd-mvdfoc6y-xmudjx.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvdfoc6y-xmudjx", + "task_id": "tsk-mvdfoc36-i9tzlz", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T06:17:32.602Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-logging.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvdfsdqy-u2cjrp.json b/.maestro/handoffs/hnd-mvdfsdqy-u2cjrp.json new file mode 100644 index 000000000..ffc0424f5 --- /dev/null +++ b/.maestro/handoffs/hnd-mvdfsdqy-u2cjrp.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvdfsdqy-u2cjrp", + "task_id": "tsk-mvdfsdm7-rk163d", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T06:20:41.242Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-m22-rebase.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvdftr16-dnrzzq.json b/.maestro/handoffs/hnd-mvdftr16-dnrzzq.json new file mode 100644 index 000000000..9a07803d7 --- /dev/null +++ b/.maestro/handoffs/hnd-mvdftr16-dnrzzq.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvdftr16-dnrzzq", + "task_id": "tsk-mvdftqvl-77wo59", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T06:21:45.114Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e8-1-mailbox.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvdfwmhn-laeq8g.json b/.maestro/handoffs/hnd-mvdfwmhn-laeq8g.json new file mode 100644 index 000000000..41f736e83 --- /dev/null +++ b/.maestro/handoffs/hnd-mvdfwmhn-laeq8g.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvdfwmhn-laeq8g", + "task_id": "tsk-mvdfwlxi-vkqc20", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T06:23:59.195Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e2-5-stm-core.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvdgoahm-j72n5v.json b/.maestro/handoffs/hnd-mvdgoahm-j72n5v.json new file mode 100644 index 000000000..c86452733 --- /dev/null +++ b/.maestro/handoffs/hnd-mvdgoahm-j72n5v.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvdgoahm-j72n5v", + "task_id": "tsk-mvdgoa0t-18vdc1", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T06:45:30.010Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-tenancy.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvdgtkfl-rrer1t.json b/.maestro/handoffs/hnd-mvdgtkfl-rrer1t.json new file mode 100644 index 000000000..4baf0decb --- /dev/null +++ b/.maestro/handoffs/hnd-mvdgtkfl-rrer1t.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvdgtkfl-rrer1t", + "task_id": "tsk-mvdgtk7t-ly5lul", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T06:49:36.177Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-sql-redis.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvdgvhst-0wxhbe.json b/.maestro/handoffs/hnd-mvdgvhst-0wxhbe.json new file mode 100644 index 000000000..2194fd31b --- /dev/null +++ b/.maestro/handoffs/hnd-mvdgvhst-0wxhbe.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvdgvhst-0wxhbe", + "task_id": "tsk-mvdgvhpd-9mxdxa", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T06:51:06.077Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e7-5-engine.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvdh79oi-5t4vmp.json b/.maestro/handoffs/hnd-mvdh79oi-5t4vmp.json new file mode 100644 index 000000000..3627a0ebe --- /dev/null +++ b/.maestro/handoffs/hnd-mvdh79oi-5t4vmp.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvdh79oi-5t4vmp", + "task_id": "tsk-mvdh79dx-xb500e", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T07:00:15.426Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-fuzz-ci.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvdh8oum-bldf8i.json b/.maestro/handoffs/hnd-mvdh8oum-bldf8i.json new file mode 100644 index 000000000..5872bea3b --- /dev/null +++ b/.maestro/handoffs/hnd-mvdh8oum-bldf8i.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvdh8oum-bldf8i", + "task_id": "tsk-mvdh8or6-37dwa9", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T07:01:21.742Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e8-3-global-registry.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvdhyi3w-88v6t5.json b/.maestro/handoffs/hnd-mvdhyi3w-88v6t5.json new file mode 100644 index 000000000..8fbf49a3f --- /dev/null +++ b/.maestro/handoffs/hnd-mvdhyi3w-88v6t5.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvdhyi3w-88v6t5", + "task_id": "tsk-mvdhyh5g-5yq01o", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T07:21:26.060Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sc-sast-scorecard.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvdhzjyq-o3bz4i.json b/.maestro/handoffs/hnd-mvdhzjyq-o3bz4i.json new file mode 100644 index 000000000..dc098d9fb --- /dev/null +++ b/.maestro/handoffs/hnd-mvdhzjyq-o3bz4i.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvdhzjyq-o3bz4i", + "task_id": "tsk-mvdhzjr6-6xpm3i", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T07:22:15.122Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e1-9-dylint.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvdi4gnm-or3p2i.json b/.maestro/handoffs/hnd-mvdi4gnm-or3p2i.json new file mode 100644 index 000000000..f1776d3d0 --- /dev/null +++ b/.maestro/handoffs/hnd-mvdi4gnm-or3p2i.json @@ -0,0 +1,8 @@ +{ + "id": "hnd-mvdi4gnm-or3p2i", + "task_id": "tsk-mvdi4g74-udxxs3", + "trigger_verb": "task:claim", + "created_at": "2026-10-18T07:26:04.114Z", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e7-5-activities-timers.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mveorq6o-e28wi2.json b/.maestro/handoffs/hnd-mveorq6o-e28wi2.json new file mode 100644 index 000000000..daeabb007 --- /dev/null +++ b/.maestro/handoffs/hnd-mveorq6o-e28wi2.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mveorq6o-e28wi2", + "task_id": "tsk-mveorm3d-m113ii", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T03:19:53.424Z", + "agent_id": "r11-sec-csrf-headers", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-csrf-headers.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mveoueu4-u3in6l.json b/.maestro/handoffs/hnd-mveoueu4-u3in6l.json new file mode 100644 index 000000000..621df22cd --- /dev/null +++ b/.maestro/handoffs/hnd-mveoueu4-u3in6l.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mveoueu4-u3in6l", + "task_id": "tsk-mveouc1i-byube1", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T03:21:58.684Z", + "agent_id": "r11-sec-limits", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-limits.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mveovgh3-aes6qu.json b/.maestro/handoffs/hnd-mveovgh3-aes6qu.json new file mode 100644 index 000000000..9452fdc43 --- /dev/null +++ b/.maestro/handoffs/hnd-mveovgh3-aes6qu.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mveovgh3-aes6qu", + "task_id": "tsk-mveovcwy-q3fo5g", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T03:22:47.463Z", + "agent_id": "r11-sc-pin-permissions", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sc-pin-permissions.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mveovkga-prr8eq.json b/.maestro/handoffs/hnd-mveovkga-prr8eq.json new file mode 100644 index 000000000..9a3f26034 --- /dev/null +++ b/.maestro/handoffs/hnd-mveovkga-prr8eq.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mveovkga-prr8eq", + "task_id": "tsk-mveovhgv-tk9wbj", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T03:22:52.618Z", + "agent_id": "r11-e8-1-timers-hibernate", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e8-1-timers-hibernate.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mveovq6s-hyok5n.json b/.maestro/handoffs/hnd-mveovq6s-hyok5n.json new file mode 100644 index 000000000..1d25adcab --- /dev/null +++ b/.maestro/handoffs/hnd-mveovq6s-hyok5n.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mveovq6s-hyok5n", + "task_id": "tsk-mveovo0t-2qcudv", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T03:23:00.052Z", + "agent_id": "r11-sec-secrets", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-secrets.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mveox9ks-1r3cy1.json b/.maestro/handoffs/hnd-mveox9ks-1r3cy1.json new file mode 100644 index 000000000..ba6129a2a --- /dev/null +++ b/.maestro/handoffs/hnd-mveox9ks-1r3cy1.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mveox9ks-1r3cy1", + "task_id": "tsk-mveox57m-tvw0xp", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T03:24:11.836Z", + "agent_id": "r11-e2-5-stm-collections", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e2-5-stm-collections.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mveoxbsl-ar20k6.json b/.maestro/handoffs/hnd-mveoxbsl-ar20k6.json new file mode 100644 index 000000000..8c9913f1d --- /dev/null +++ b/.maestro/handoffs/hnd-mveoxbsl-ar20k6.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mveoxbsl-ar20k6", + "task_id": "tsk-mveox92p-bcbotq", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T03:24:14.709Z", + "agent_id": "r11-sec-h8-routing", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-h8-routing.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mveoxqc3-tglltz.json b/.maestro/handoffs/hnd-mveoxqc3-tglltz.json new file mode 100644 index 000000000..4b273c1c0 --- /dev/null +++ b/.maestro/handoffs/hnd-mveoxqc3-tglltz.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mveoxqc3-tglltz", + "task_id": "tsk-mveoxlor-3b9mxh", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T03:24:33.555Z", + "agent_id": "r11-e2-6-bench-ci", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e2-6-bench-ci.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mveoxtkq-lv8wud.json b/.maestro/handoffs/hnd-mveoxtkq-lv8wud.json new file mode 100644 index 000000000..a0317b5a0 --- /dev/null +++ b/.maestro/handoffs/hnd-mveoxtkq-lv8wud.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mveoxtkq-lv8wud", + "task_id": "tsk-mveoxp2g-hrduy2", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T03:24:37.754Z", + "agent_id": "r11-sc-dco-docs", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sc-dco-docs.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mveoxwda-d3ftoo.json b/.maestro/handoffs/hnd-mveoxwda-d3ftoo.json new file mode 100644 index 000000000..2a45d7c0c --- /dev/null +++ b/.maestro/handoffs/hnd-mveoxwda-d3ftoo.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mveoxwda-d3ftoo", + "task_id": "tsk-mveoxua6-li0n6j", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T03:24:41.374Z", + "agent_id": "r11-e8-3-quic-mtls", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e8-3-quic-mtls.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mveoxxlk-q6wkhh.json b/.maestro/handoffs/hnd-mveoxxlk-q6wkhh.json new file mode 100644 index 000000000..4ae056912 --- /dev/null +++ b/.maestro/handoffs/hnd-mveoxxlk-q6wkhh.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mveoxxlk-q6wkhh", + "task_id": "tsk-mveoxvjj-lwme2p", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T03:24:42.968Z", + "agent_id": "r11-sc-publish", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sc-publish.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvep8rov-ch6ejb.json b/.maestro/handoffs/hnd-mvep8rov-ch6ejb.json new file mode 100644 index 000000000..59af17a53 --- /dev/null +++ b/.maestro/handoffs/hnd-mvep8rov-ch6ejb.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvep8rov-ch6ejb", + "task_id": "tsk-mvep8nuq-do4lci", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T03:33:08.527Z", + "agent_id": "r11-e8-3-reliable-delivery", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e8-3-reliable-delivery.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvepmxyt-7093rp.json b/.maestro/handoffs/hnd-mvepmxyt-7093rp.json new file mode 100644 index 000000000..d0811ed6f --- /dev/null +++ b/.maestro/handoffs/hnd-mvepmxyt-7093rp.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvepmxyt-7093rp", + "task_id": "tsk-mvepmv97-fe5jxj", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T03:44:09.845Z", + "agent_id": "r11-e8-2-statem", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e8-2-statem.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvepyyol-8q4c6f.json b/.maestro/handoffs/hnd-mvepyyol-8q4c6f.json new file mode 100644 index 000000000..f05484c04 --- /dev/null +++ b/.maestro/handoffs/hnd-mvepyyol-8q4c6f.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvepyyol-8q4c6f", + "task_id": "tsk-mvepyvw7-ohtsdc", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T03:53:30.645Z", + "agent_id": "r11-sec-auth-state", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-auth-state.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvepzwqg-3qfxix.json b/.maestro/handoffs/hnd-mvepzwqg-3qfxix.json new file mode 100644 index 000000000..7e8c2f869 --- /dev/null +++ b/.maestro/handoffs/hnd-mvepzwqg-3qfxix.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvepzwqg-3qfxix", + "task_id": "tsk-mvepzuqe-owgjul", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T03:54:14.776Z", + "agent_id": "r11-e8-3-k8s-discovery", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e8-3-k8s-discovery.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mveq0u8w-04mvta.json b/.maestro/handoffs/hnd-mveq0u8w-04mvta.json new file mode 100644 index 000000000..63f6891c9 --- /dev/null +++ b/.maestro/handoffs/hnd-mveq0u8w-04mvta.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mveq0u8w-04mvta", + "task_id": "tsk-mveq0q6b-7wurki", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T03:54:58.208Z", + "agent_id": "r11-e8-3-tcp-tls", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e8-3-tcp-tls.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mveqc9ku-ih146m.json b/.maestro/handoffs/hnd-mveqc9ku-ih146m.json new file mode 100644 index 000000000..b6fb0d34b --- /dev/null +++ b/.maestro/handoffs/hnd-mveqc9ku-ih146m.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mveqc9ku-ih146m", + "task_id": "tsk-mveqc5k9-6weba4", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T04:03:51.294Z", + "agent_id": "r11-e7-5-sagas-promote", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e7-5-sagas-promote.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mverrqrl-kz3loy.json b/.maestro/handoffs/hnd-mverrqrl-kz3loy.json new file mode 100644 index 000000000..21a59c7c2 --- /dev/null +++ b/.maestro/handoffs/hnd-mverrqrl-kz3loy.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mverrqrl-kz3loy", + "task_id": "tsk-mverrnjm-v3bnoo", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T04:43:53.025Z", + "agent_id": "r11-sec-ops-surfaces", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-ops-surfaces.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvervfj5-f7s37a.json b/.maestro/handoffs/hnd-mvervfj5-f7s37a.json new file mode 100644 index 000000000..3ffef5fcc --- /dev/null +++ b/.maestro/handoffs/hnd-mvervfj5-f7s37a.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvervfj5-f7s37a", + "task_id": "tsk-mvervb9q-tizqjp", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T04:46:45.089Z", + "agent_id": "r11-sec-client-ssrf", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-client-ssrf.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvet362j-c5n0q4.json b/.maestro/handoffs/hnd-mvet362j-c5n0q4.json new file mode 100644 index 000000000..8532d206c --- /dev/null +++ b/.maestro/handoffs/hnd-mvet362j-c5n0q4.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvet362j-c5n0q4", + "task_id": "tsk-mvet31ys-gowf3l", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T05:20:45.691Z", + "agent_id": "r11-w3-kit-template", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-w3-kit-template.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mveutsko-5e3598.json b/.maestro/handoffs/hnd-mveutsko-5e3598.json new file mode 100644 index 000000000..c181b7267 --- /dev/null +++ b/.maestro/handoffs/hnd-mveutsko-5e3598.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mveutsko-5e3598", + "task_id": "tsk-mveutmw9-l3uhuv", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T06:09:27.528Z", + "agent_id": "r11-e11-4-compile-fail", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e11-4-compile-fail.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mveuytou-dg69ok.json b/.maestro/handoffs/hnd-mveuytou-dg69ok.json new file mode 100644 index 000000000..2f7f586ca --- /dev/null +++ b/.maestro/handoffs/hnd-mveuytou-dg69ok.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mveuytou-dg69ok", + "task_id": "tsk-mveuyqcl-h6cafd", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T06:13:22.254Z", + "agent_id": "r11-w3-ledger", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-w3-ledger.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvey8qiy-9k97jg.json b/.maestro/handoffs/hnd-mvey8qiy-9k97jg.json new file mode 100644 index 000000000..8fd64a7bf --- /dev/null +++ b/.maestro/handoffs/hnd-mvey8qiy-9k97jg.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvey8qiy-9k97jg", + "task_id": "tsk-mvey8ngt-tghgvt", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T07:45:03.562Z", + "agent_id": "r11-t0-migration-guide", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-t0-migration-guide.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvey93y7-xgkwdd.json b/.maestro/handoffs/hnd-mvey93y7-xgkwdd.json new file mode 100644 index 000000000..728408bb2 --- /dev/null +++ b/.maestro/handoffs/hnd-mvey93y7-xgkwdd.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvey93y7-xgkwdd", + "task_id": "tsk-mvey915n-8cs2b5", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T07:45:20.959Z", + "agent_id": "r11-w3-kpis", + "spec_path": "/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-w3-kpis.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvf4c7jq-hcrpup.json b/.maestro/handoffs/hnd-mvf4c7jq-hcrpup.json new file mode 100644 index 000000000..3811196a4 --- /dev/null +++ b/.maestro/handoffs/hnd-mvf4c7jq-hcrpup.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvf4c7jq-hcrpup", + "task_id": "tsk-mvf4c3oa-dc40fo", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T10:35:43.286Z", + "agent_id": "a0-roadmap", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-t0-adr0014.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvf4c7n4-h8zr34.json b/.maestro/handoffs/hnd-mvf4c7n4-h8zr34.json new file mode 100644 index 000000000..ef7de2c86 --- /dev/null +++ b/.maestro/handoffs/hnd-mvf4c7n4-h8zr34.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvf4c7n4-h8zr34", + "task_id": "tsk-mvf4c3s4-xn4847", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T10:35:43.408Z", + "agent_id": "a1-governance", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-t0-skeletons.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvf4xhps-rlvmox.json b/.maestro/handoffs/hnd-mvf4xhps-rlvmox.json new file mode 100644 index 000000000..fdf4f1dd1 --- /dev/null +++ b/.maestro/handoffs/hnd-mvf4xhps-rlvmox.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvf4xhps-rlvmox", + "task_id": "tsk-mvf4xez4-00j1za", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T10:52:16.240Z", + "agent_id": "b1-rpc", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-1-promote.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvf4xht7-w7txiw.json b/.maestro/handoffs/hnd-mvf4xht7-w7txiw.json new file mode 100644 index 000000000..9f4fb3e1d --- /dev/null +++ b/.maestro/handoffs/hnd-mvf4xht7-w7txiw.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvf4xht7-w7txiw", + "task_id": "tsk-mvf4xf42-qjodki", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T10:52:16.363Z", + "agent_id": "b2-axum", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-2-axum-ws-sse.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvf4xhwj-yuuhyt.json b/.maestro/handoffs/hnd-mvf4xhwj-yuuhyt.json new file mode 100644 index 000000000..129e536dd --- /dev/null +++ b/.maestro/handoffs/hnd-mvf4xhwj-yuuhyt.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvf4xhwj-yuuhyt", + "task_id": "tsk-mvf4xf8t-hyoe72", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T10:52:16.483Z", + "agent_id": "b3-quic", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-3-quic-endpoint.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvf70j04-ignopf.json b/.maestro/handoffs/hnd-mvf70j04-ignopf.json new file mode 100644 index 000000000..98f84b024 --- /dev/null +++ b/.maestro/handoffs/hnd-mvf70j04-ignopf.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvf70j04-ignopf", + "task_id": "tsk-mvf70f4h-isuu0c", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T11:50:37.108Z", + "agent_id": "c1-tls-h2", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-3-tls-h2.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvf70j3t-1647p5.json b/.maestro/handoffs/hnd-mvf70j3t-1647p5.json new file mode 100644 index 000000000..2d98848c3 --- /dev/null +++ b/.maestro/handoffs/hnd-mvf70j3t-1647p5.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvf70j3t-1647p5", + "task_id": "tsk-mvf70fjh-fmn5my", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T11:50:37.241Z", + "agent_id": "c2-h3-client", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-3-h3-client.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvf70jau-jdae3e.json b/.maestro/handoffs/hnd-mvf70jau-jdae3e.json new file mode 100644 index 000000000..1844183bc --- /dev/null +++ b/.maestro/handoffs/hnd-mvf70jau-jdae3e.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvf70jau-jdae3e", + "task_id": "tsk-mvf70fq3-r9ruuc", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T11:50:37.494Z", + "agent_id": "c3-acme", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-3-certs-acme.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvf7k37j-xa6yn0.json b/.maestro/handoffs/hnd-mvf7k37j-xa6yn0.json new file mode 100644 index 000000000..24ffcee22 --- /dev/null +++ b/.maestro/handoffs/hnd-mvf7k37j-xa6yn0.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvf7k37j-xa6yn0", + "task_id": "tsk-mvf7jpll-z8910m", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T12:05:49.759Z", + "agent_id": "c4-kinds", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-1-kinds.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvf9ru02-w16tdx.json b/.maestro/handoffs/hnd-mvf9ru02-w16tdx.json new file mode 100644 index 000000000..eecbf8349 --- /dev/null +++ b/.maestro/handoffs/hnd-mvf9ru02-w16tdx.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvf9ru02-w16tdx", + "task_id": "tsk-mvf9rld7-x1htmf", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T13:07:50.306Z", + "agent_id": "d1-rpc-ws-sse", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-2-rpc-ws-sse.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvf9ru3k-bc3z8d.json b/.maestro/handoffs/hnd-mvf9ru3k-bc3z8d.json new file mode 100644 index 000000000..02d11e8c8 --- /dev/null +++ b/.maestro/handoffs/hnd-mvf9ru3k-bc3z8d.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvf9ru3k-bc3z8d", + "task_id": "tsk-mvf9rlgq-6v0ddu", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T13:07:50.432Z", + "agent_id": "d2-batching", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-1-batching.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvf9ru73-dl7s09.json b/.maestro/handoffs/hnd-mvf9ru73-dl7s09.json new file mode 100644 index 000000000..b3ac771a0 --- /dev/null +++ b/.maestro/handoffs/hnd-mvf9ru73-dl7s09.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvf9ru73-dl7s09", + "task_id": "tsk-mvf9rlkh-oyyusj", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T13:07:50.559Z", + "agent_id": "d3-macro", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-1-macro.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvf9ruaj-y9f0rq.json b/.maestro/handoffs/hnd-mvf9ruaj-y9f0rq.json new file mode 100644 index 000000000..9654af9b3 --- /dev/null +++ b/.maestro/handoffs/hnd-mvf9ruaj-y9f0rq.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvf9ruaj-y9f0rq", + "task_id": "tsk-mvf9rlnr-ts175i", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T13:07:50.683Z", + "agent_id": "d4-postcard", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-1-postcard.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvf9rudx-ukmrm3.json b/.maestro/handoffs/hnd-mvf9rudx-ukmrm3.json new file mode 100644 index 000000000..e127223eb --- /dev/null +++ b/.maestro/handoffs/hnd-mvf9rudx-ukmrm3.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvf9rudx-ukmrm3", + "task_id": "tsk-mvf9rlqz-0bc6j8", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T13:07:50.805Z", + "agent_id": "d5-subscriptions", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-2-subscriptions.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvf9tz10-1wjgu2.json b/.maestro/handoffs/hnd-mvf9tz10-1wjgu2.json new file mode 100644 index 000000000..8321ab4f3 --- /dev/null +++ b/.maestro/handoffs/hnd-mvf9tz10-1wjgu2.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvf9tz10-1wjgu2", + "task_id": "tsk-mvf9twme-9avzxk", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T13:09:30.132Z", + "agent_id": "d6-h3-server", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-3-h3-server.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfamy84-dk5m97.json b/.maestro/handoffs/hnd-mvfamy84-dk5m97.json new file mode 100644 index 000000000..cfa0a495a --- /dev/null +++ b/.maestro/handoffs/hnd-mvfamy84-dk5m97.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfamy84-dk5m97", + "task_id": "tsk-mvfamuiv-lyz0jg", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T13:32:02.116Z", + "agent_id": "e1-channels", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-2-channels.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfc5l2f-u5kopa.json b/.maestro/handoffs/hnd-mvfc5l2f-u5kopa.json new file mode 100644 index 000000000..e6db258be --- /dev/null +++ b/.maestro/handoffs/hnd-mvfc5l2f-u5kopa.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfc5l2f-u5kopa", + "task_id": "tsk-mvfc5h86-z8ef14", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T14:14:31.143Z", + "agent_id": "e2-mesh-rpc", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-2-mesh-rpc.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfdi4tp-6rj4ti.json b/.maestro/handoffs/hnd-mvfdi4tp-6rj4ti.json new file mode 100644 index 000000000..5b3a1c0cb --- /dev/null +++ b/.maestro/handoffs/hnd-mvfdi4tp-6rj4ti.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfdi4tp-6rj4ti", + "task_id": "tsk-mvfdi1tt-t8vr7z", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T14:52:16.237Z", + "agent_id": "e3-openapi", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-4-openapi-asyncapi.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvffsve2-moiovn.json b/.maestro/handoffs/hnd-mvffsve2-moiovn.json new file mode 100644 index 000000000..d735d25bb --- /dev/null +++ b/.maestro/handoffs/hnd-mvffsve2-moiovn.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvffsve2-moiovn", + "task_id": "tsk-mvffsow8-ogb7fz", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T15:56:36.458Z", + "agent_id": "f1-h3-platform", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-f-h3-platform.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfiv7r1-eqp13y.json b/.maestro/handoffs/hnd-mvfiv7r1-eqp13y.json new file mode 100644 index 000000000..25b84bbb9 --- /dev/null +++ b/.maestro/handoffs/hnd-mvfiv7r1-eqp13y.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfiv7r1-eqp13y", + "task_id": "tsk-mvfiv513-vvk5an", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T17:22:24.637Z", + "agent_id": "e4-deploy-guide", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-3-deploy-guide.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfixpxa-dfovpz.json b/.maestro/handoffs/hnd-mvfixpxa-dfovpz.json new file mode 100644 index 000000000..a3e11565b --- /dev/null +++ b/.maestro/handoffs/hnd-mvfixpxa-dfovpz.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfixpxa-dfovpz", + "task_id": "tsk-mvfixl0v-di486t", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T17:24:21.502Z", + "agent_id": "f2-bidi-resume", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-2-bidi-resume.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfixq0m-cyzmzk.json b/.maestro/handoffs/hnd-mvfixq0m-cyzmzk.json new file mode 100644 index 000000000..6c8a7fa06 --- /dev/null +++ b/.maestro/handoffs/hnd-mvfixq0m-cyzmzk.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfixq0m-cyzmzk", + "task_id": "tsk-mvfixl4d-wu25gw", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T17:24:21.622Z", + "agent_id": "f3-benches", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-5-benches.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfl8u9a-1h9ez0.json b/.maestro/handoffs/hnd-mvfl8u9a-1h9ez0.json new file mode 100644 index 000000000..5faa71b33 --- /dev/null +++ b/.maestro/handoffs/hnd-mvfl8u9a-1h9ez0.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfl8u9a-1h9ez0", + "task_id": "tsk-mvfl8a7k-641iow", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T18:28:59.566Z", + "agent_id": "g1-gen-ts", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-4-gen-ts.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfl8uck-05ow73.json b/.maestro/handoffs/hnd-mvfl8uck-05ow73.json new file mode 100644 index 000000000..b1537bf98 --- /dev/null +++ b/.maestro/handoffs/hnd-mvfl8uck-05ow73.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfl8uck-05ow73", + "task_id": "tsk-mvfl8acb-0806yq", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T18:28:59.684Z", + "agent_id": "g2-webtransport", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-3-webtransport.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfl8ufu-d0ys04.json b/.maestro/handoffs/hnd-mvfl8ufu-d0ys04.json new file mode 100644 index 000000000..90491dcdb --- /dev/null +++ b/.maestro/handoffs/hnd-mvfl8ufu-d0ys04.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfl8ufu-d0ys04", + "task_id": "tsk-mvfl8agk-vm3y4j", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T18:28:59.802Z", + "agent_id": "g3-fuzz-security", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-5-fuzz-security.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfnsfmi-3foa18.json b/.maestro/handoffs/hnd-mvfnsfmi-3foa18.json new file mode 100644 index 000000000..c4b72a6a4 --- /dev/null +++ b/.maestro/handoffs/hnd-mvfnsfmi-3foa18.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfnsfmi-3foa18", + "task_id": "tsk-mvfns901-uz5ej3", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T19:40:12.954Z", + "agent_id": "g4-rpc-check", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-4-rpc-check.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfo19vx-6d7juf.json b/.maestro/handoffs/hnd-mvfo19vx-6d7juf.json new file mode 100644 index 000000000..e1ea7c6c2 --- /dev/null +++ b/.maestro/handoffs/hnd-mvfo19vx-6d7juf.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfo19vx-6d7juf", + "task_id": "tsk-mvfo16u4-pdz4fg", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T19:47:05.421Z", + "agent_id": "h1-kit-template", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-w6-kit-template.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfoia1c-2yr46a.json b/.maestro/handoffs/hnd-mvfoia1c-2yr46a.json new file mode 100644 index 000000000..747106d8f --- /dev/null +++ b/.maestro/handoffs/hnd-mvfoia1c-2yr46a.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfoia1c-2yr46a", + "task_id": "tsk-mvfohzqk-xg47t7", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T20:00:18.768Z", + "agent_id": "h2-client-batching", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-f-client-batching.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfopmkt-uacfpv.json b/.maestro/handoffs/hnd-mvfopmkt-uacfpv.json new file mode 100644 index 000000000..49b1a5217 --- /dev/null +++ b/.maestro/handoffs/hnd-mvfopmkt-uacfpv.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfopmkt-uacfpv", + "task_id": "tsk-mvfixl4d-wu25gw", + "trigger_verb": "task:block", + "created_at": "2026-10-19T20:06:01.613Z", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-5-benches.md", + "reason": "D5: rpc/unary_json - rpc/axum_json_handler measured +3,399 ns (clean run; +3.38..+3.59 us over 5 runs, +3,501 ns on 82beb0f2) > 3,000 ns budget. Per the leaf AC the delta gate is not written and the budget not loosened; owner decision needed (release spec Open question 2). Everything else committed in 9f422370 (batch pair 0.111, secure stack +2,648 ns, h3/h2 0.065 userspace, soak 1.040 PASS).", + "to_agent": "claude-code" +} diff --git a/.maestro/handoffs/hnd-mvfoquw2-hxgwko.json b/.maestro/handoffs/hnd-mvfoquw2-hxgwko.json new file mode 100644 index 000000000..c8d382689 --- /dev/null +++ b/.maestro/handoffs/hnd-mvfoquw2-hxgwko.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfoquw2-hxgwko", + "task_id": "tsk-mvfoqs1j-gik5ls", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T20:06:59.042Z", + "agent_id": "h3-unary-perf", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-f-unary-perf.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfr8bys-1y3gcf.json b/.maestro/handoffs/hnd-mvfr8bys-1y3gcf.json new file mode 100644 index 000000000..542a8c6ed --- /dev/null +++ b/.maestro/handoffs/hnd-mvfr8bys-1y3gcf.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfr8bys-1y3gcf", + "task_id": "tsk-mvfr897j-gvxts8", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T21:16:33.556Z", + "agent_id": "i1-relay", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-w7-relay.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfr8c25-iso6zn.json b/.maestro/handoffs/hnd-mvfr8c25-iso6zn.json new file mode 100644 index 000000000..833d43f91 --- /dev/null +++ b/.maestro/handoffs/hnd-mvfr8c25-iso6zn.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfr8c25-iso6zn", + "task_id": "tsk-mvfr89bs-sr3cs4", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T21:16:33.677Z", + "agent_id": "i2-ledger", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-w7-ledger.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfut7k6-n8w03d.json b/.maestro/handoffs/hnd-mvfut7k6-n8w03d.json new file mode 100644 index 000000000..dc394d222 --- /dev/null +++ b/.maestro/handoffs/hnd-mvfut7k6-n8w03d.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfut7k6-n8w03d", + "task_id": "tsk-mvfusz4r-iiqduh", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T22:56:46.470Z", + "agent_id": "j1-level-audit", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-w8-level-audit.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfut7nh-z4d33s.json b/.maestro/handoffs/hnd-mvfut7nh-z4d33s.json new file mode 100644 index 000000000..437117ce2 --- /dev/null +++ b/.maestro/handoffs/hnd-mvfut7nh-z4d33s.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfut7nh-z4d33s", + "task_id": "tsk-mvfusz8h-6pkxkz", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T22:56:46.589Z", + "agent_id": "j2-kpis", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-w8-kpis.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfut7qu-gfx0k2.json b/.maestro/handoffs/hnd-mvfut7qu-gfx0k2.json new file mode 100644 index 000000000..3c91805e2 --- /dev/null +++ b/.maestro/handoffs/hnd-mvfut7qu-gfx0k2.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfut7qu-gfx0k2", + "task_id": "tsk-mvfuszbz-xpikgu", + "trigger_verb": "task:claim", + "created_at": "2026-10-19T22:56:46.710Z", + "agent_id": "j3-ts-cursor", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-f-ts-cursor.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvfyq2gw-jnzct2.json b/.maestro/handoffs/hnd-mvfyq2gw-jnzct2.json new file mode 100644 index 000000000..a566294d6 --- /dev/null +++ b/.maestro/handoffs/hnd-mvfyq2gw-jnzct2.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvfyq2gw-jnzct2", + "task_id": "tsk-mvfypvdw-vae18k", + "trigger_verb": "task:claim", + "created_at": "2026-10-20T00:46:18.368Z", + "agent_id": "k1-rpc-spans", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-f-rpc-spans.md", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvg7didq-expbem.json b/.maestro/handoffs/hnd-mvg7didq-expbem.json new file mode 100644 index 000000000..3f3c72bba --- /dev/null +++ b/.maestro/handoffs/hnd-mvg7didq-expbem.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvg7didq-expbem", + "task_id": "tsk-mvfohzqk-xg47t7", + "trigger_verb": "task:block", + "created_at": "2026-10-20T04:48:29.006Z", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-f-client-batching.md", + "reason": "Deferred to 0.13: ~135 lines (per-env batch resolver table) because id_effect lacks a public run-env-free-effect-under-any-env helper; patch saved at .maestro/specs/releases/0.13/f27-client-batching.patch (applies on a344bd9e)", + "to_agent": "cli" +} diff --git a/.maestro/handoffs/hnd-mvg7dwx5-3f47z9.json b/.maestro/handoffs/hnd-mvg7dwx5-3f47z9.json new file mode 100644 index 000000000..81ac0572e --- /dev/null +++ b/.maestro/handoffs/hnd-mvg7dwx5-3f47z9.json @@ -0,0 +1,9 @@ +{ + "id": "hnd-mvg7dwx5-3f47z9", + "task_id": "tsk-mvg7dsl7-vj0q93", + "trigger_verb": "task:claim", + "created_at": "2026-10-20T04:48:47.849Z", + "agent_id": "l1-exit", + "spec_path": "/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-t0-migration-guide.md", + "to_agent": "cli" +} diff --git a/.maestro/missions/release-0-10.execution.md b/.maestro/missions/release-0-10.execution.md index 4d4e28977..8d4e6b918 100644 --- a/.maestro/missions/release-0-10.execution.md +++ b/.maestro/missions/release-0-10.execution.md @@ -95,7 +95,11 @@ owner's email or other personal data to external services. hook on the root fiber; wire request spans with `RequestIdLayer::span_hook(telemetry.span_hook())` in `r10-w3-kit-template` (service-pg-otel variant). `server_layer_with_readiness` is only exported from `server::lifecycle`. -- Bugs, id_effect_node (pre-existing, found by r10-e7-1-cron; fix before exit): +- FIXED (c75f9f0d, 9a9a498a, 3e9cec38): the singleton/lease bugs below, figment error redaction, + derive(Schema) hygiene + single unsupported-field error. New from those fixes: derive(Schema) + uses `field_ident.to_string()` as the wire key, so `r#type` is sent as "r#type" (should be + "type"); `Profiles::load_for` TOML parse errors don't name the file. +- Bugs, id_effect_node (pre-existing, found by r10-e7-1-cron; fixed in c75f9f0d): `ClusterSingleton::resign` clears `local_pid` only when release succeeds, so after a lost lease (`HeldBy`) it keeps the old pid; `PgAdvisoryLease::try_acquire` treats its in-memory `held` map as ownership without pinging, so after its session died the same holder "re-acquires". Also: @@ -109,6 +113,19 @@ owner's email or other personal data to external services. - D1 checkpoint applied 2026-10-17 (dx.lint target 0.10.0 -> 0.11.0; planned 0.10.0 E 73.8 #2, RT 76.8 #1, red 12). TODO: add its PLAN-OF-PLANS §11 row once the other session's uncommitted PLAN-OF-PLANS edit (L14: 1.0 postponed, 0.13 / 0.14 inserted) is committed - do not mix them. +- KPIs (from 07f02ce9): K6 budget 120 s is a proposal - owner to confirm at exit. r10-w3-kpis: + run idle without CARGO_BUILD_JOBS (it caps K6), re-measure K1 idle, K3 with the job (emit_json + K3 strings still say "none at 0.9"), update moon.yml:205 dx-kpis description (still "0.9", + K1/K3/K4/K5 only). PLAN-OF-PLANS:336 names docs/scorecard/results/.dx.json but the script + writes docs/benchmarks/dx-kpis-.json. Accounts: moon.yml of id_effect_auth / mail + lack migrations/** and templates/** inputs; ch30-03 is not doctested. +- Hardening batch (A: logger process-wide redaction + console CSRF + csrf_exempt_bearer + OTel + layers; B: InstrumentedSqlClient forwarding, SqlClient for Arc/Box, Inbox::handler, storage + Bytes, retry_now for failed; C: CI pg coverage (H1) + container port-race retry, 940b229c). + Leftover: a stray Maestro evidence record evd-1792271996399-ca0655 against a nonexistent task + tsk-000000 (placeholder, created by mistake; cannot be deleted with the tools). Logger minimum + level / annotations / spans are still per-thread (functional gap, 0.11). The console's + __Host-csrf cookie is Secure (needs HTTPS or localhost). - Index hygiene: something (likely a Maestro command) staged ~330 files incl. other agents' WIP during wave 1. Pathspec commits are unaffected; never run a bare `git commit`. - Fiber registry bench numbers came from a loaded machine: re-measure idle in `r10-w3-kpis`. diff --git a/.maestro/policies/.pending-loosenings.json b/.maestro/policies/.pending-loosenings.json new file mode 100644 index 000000000..46ac9cd90 --- /dev/null +++ b/.maestro/policies/.pending-loosenings.json @@ -0,0 +1,5 @@ +{ + "headSha": "f6d730760da0ec0c181e90675118b9277cebe6d4", + "generatedAt": "2026-10-15T19:40:58.403Z", + "items": [] +} diff --git a/.maestro/tasks/NOW.md b/.maestro/tasks/NOW.md index b11135323..1aad20a2e 100644 --- a/.maestro/tasks/NOW.md +++ b/.maestro/tasks/NOW.md @@ -1,20 +1,19 @@ # NOW -Updated: 2026-10-15T19:00:30.803Z +Updated: 2026-10-20T05:21:34.609Z ## In flight (0) None. -## Ready to pick up (1) -### tsk-mv799sew-y9wf9g . W5: Migrate assay and solana to crates.io 0.6; decide webapp archive/migrate -Mission: pln-mv799j4f-1r1u6b -Spec: /data/Code/rust/id_effect/.maestro/specs/id-effect-consolidation-0-6.md - +## Ready to pick up (0) +None. ## Ready to ship (0) None. -## Blocked (0) -None. +## Blocked (1) +### tsk-mvfohzqk-xg47t7 . r12-f-client-batching +Reason: Deferred to 0.13: ~135 lines (per-env batch resolver table) because id_effect lacks a public run-env-free-effect-under-any-env helper; patch saved at .maestro/specs/releases/0.13/f27-client-batching.patch (applies on a344bd9e) + ## Stuck (0) None. diff --git a/.maestro/tasks/contracts/c-003692.json b/.maestro/tasks/contracts/c-003692.json new file mode 100644 index 000000000..7e071e600 --- /dev/null +++ b/.maestro/tasks/contracts/c-003692.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-003692", + "taskId": "tsk-mvbh3yue-83n9vp", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:46:07.533Z", + "lockedAt": "2026-10-16T21:46:07.533Z", + "intent": "r09-e5-2b-totp", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-5c6679", + "text": "Enrol (secret + otpauth URI), verify with ±1 step skew under the executor clock (TestExecutor-testable), recovery codes hashed with argon2.", + "kind": "manual" + }, + { + "id": "dw-d59366", + "text": "Gate: `cargo nextest run -p id_effect_auth --all-features`", + "kind": "manual" + }, + { + "id": "dw-26c6b5", + "text": "Owns: `crates/id_effect_auth/src/totp.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-02ad50.json b/.maestro/tasks/contracts/c-02ad50.json new file mode 100644 index 000000000..7df69050a --- /dev/null +++ b/.maestro/tasks/contracts/c-02ad50.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-02ad50", + "taskId": "tsk-mvbm09jg-c92j9a", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T23:39:19.663Z", + "lockedAt": "2026-10-16T23:39:19.663Z", + "intent": "r09-w3-tutorial", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-461aad", + "text": "book/src/tutorial-zero-to-production.md follows the 0.9 service template: migrations through id_effect_sql::migrate (migrations/ applied on start, id-effect migrate new / status / check), no include_str! migration constants; the typed route and Problem+JSON answer; the middleware stack.", + "kind": "manual" + }, + { + "id": "dw-980ba1", + "text": "Every rust block still compiles in examples/book-snippets (cargo test -p book_snippets --doc); the tutorial text matches the generated project file by file (verify by generating one with id-effect new and comparing).", + "kind": "manual" + }, + { + "id": "dw-89d99a", + "text": "dx KPI K1 (time to a running service) is unaffected; scripts/dx-kpis.sh still passes its tutorial-related steps if any.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/tasks/contracts/c-05c7ce.json b/.maestro/tasks/contracts/c-05c7ce.json new file mode 100644 index 000000000..00f1baa6c --- /dev/null +++ b/.maestro/tasks/contracts/c-05c7ce.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-05c7ce", + "taskId": "tsk-mvfixl4d-wu25gw", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T17:24:21.620Z", + "lockedAt": "2026-10-19T17:24:21.620Z", + "intent": "r12-e12-5-benches", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-1f17a7", + "text": "Criterion benches in crates/id_effect_rpc/benches/gate_rpc.rs: rpc/unary_json (a #[rpc::service] query, JSON, small struct, through the in-process tower path as the 0.11 gate_request bench), rpc/axum_json_handler (a raw axum handler doing the same serde JSON decode and encode of the same struct; the baseline), rpc/unary_postcard, rpc/batch_50 and rpc/sequential_50 (50 calls over loopback TCP with keep-alive, sequential awaits).", + "kind": "manual" + }, + { + "id": "dw-1c3498", + "text": "D5: a delta gate rpc/unary_json − rpc/axum_json_handler ≤ 3,000 ns (same run and runner; ratio printed for information) is written into docs/benchmarks/gates.toml only after the first measurement is recorded in the leaf evidence; if the measurement exceeds 3 µs the leaf stops and asks the owner (release spec Open question 2) instead of loosening it.", + "kind": "manual" + }, + { + "id": "dw-1b39ee", + "text": "Pair gate rpc/batch_50 / rpc/sequential_50 ≤ 0.20 (≥ 5× faster); the 0.11 secure-stack delta gate (≤ 5 µs) is unchanged.", + "kind": "manual" + }, + { + "id": "dw-0e8ba6", + "text": "Soak: examples/streams_soak.rs opens 10,000 concurrent WebSocket server streams on one node with heartbeats and samples RSS; pass when RSS at minute 10 is ≤ 1.05 × RSS at minute 1 and every stream received every heartbeat; a 60 s variant runs in PR CI, the 10-minute run in bench.yml.", + "kind": "manual" + }, + { + "id": "dw-31646e", + "text": "D9: benches/h3_loss.rs measures p99 of 64 multiplexed 16 KiB responses over one connection, HTTP/2 over TLS vs HTTP/3, with tc netem loss 1% delay 5ms on loopback (bench.yml job with sudo); pair gate h3_p99 / h2_p99 ≤ 1.00 (noise 0.05); results published in docs/benchmarks/v0.12.json and README.", + "kind": "manual" + }, + { + "id": "dw-18e866", + "text": "Gate: `bash scripts/bench-run.sh` then `bash scripts/bench-compare.sh docs/benchmarks/v0.11.json ` (no budget/delta/pair FAIL); `cargo run --release -p id_effect_rpc --example streams_soak -- --streams 10000 --minutes 10`; `bash scripts/check-workflows.sh`", + "kind": "manual" + }, + { + "id": "dw-113b66", + "text": "Owns: `crates/id_effect_rpc/benches/{gate_rpc.rs,h3_loss.rs}`, `crates/id_effect_rpc/examples/streams_soak.rs`, `docs/benchmarks/{gates.toml,README.md,v0.12.json}`, `scripts/bench-run.sh`, `.github/workflows/bench.yml`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "f3-benches", + "lockedBy": "f3-benches", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-0aa267.json b/.maestro/tasks/contracts/c-0aa267.json new file mode 100644 index 000000000..d6ffa867c --- /dev/null +++ b/.maestro/tasks/contracts/c-0aa267.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 2, + "id": "c-0aa267", + "taskId": "tsk-mvbl55h5-z4i872", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T23:15:06.300Z", + "lockedAt": "2026-10-16T23:15:06.300Z", + "intent": "r09-sql-tls", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-868de5", + "text": "sqlx is built with rustls TLS for the pg and mysql features (aws-lc-rs provider, matching the rustls / jsonwebtoken already in the tree; no OpenSSL). `sslmode` (pg) and `ssl-mode` (mysql) in the URL are honoured, including verify-full / VERIFY_IDENTITY with a custom root CA.", + "kind": "manual" + }, + { + "id": "dw-4770c9", + "text": "PgPoolConfig / MySqlPoolConfig expose the TLS mode and root certificate explicitly (not only through the URL), with a documented default. The default must not silently downgrade: document what `prefer` means and recommend `require` / verify modes for production.", + "kind": "manual" + }, + { + "id": "dw-cddf16", + "text": "Live tests against TLS-enabled PostgreSQL and MySQL containers (self-signed CA generated in the test): a verify-full connection succeeds, a wrong CA fails with a typed error, and a server without TLS fails under `require`. SKIP without Docker; fail under ID_EFFECT_REQUIRE_DOCKER=1.", + "kind": "manual" + }, + { + "id": "dw-d5c631", + "text": "Book ch28-01 Backends: the plaintext note is replaced by a TLS section; CHANGELOG; MSRV 1.94; licences MIT/Apache/ISC.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-0ace4c.json b/.maestro/tasks/contracts/c-0ace4c.json new file mode 100644 index 000000000..ce833087d --- /dev/null +++ b/.maestro/tasks/contracts/c-0ace4c.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-0ace4c", + "taskId": "tsk-mveovcwy-q3fo5g", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:22:47.461Z", + "lockedAt": "2026-10-19T03:22:47.461Z", + "intent": "r11-sc-pin-permissions", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-b4b085", + "text": "S1: every `uses:` in ci.yml, audit.yml, docs.yml and dependabot-auto-merge.yml is pinned to a full 40-character commit SHA with a `# vX.Y.Z` comment (today tags, e.g. actions/checkout@v7 at ci.yml:25); dtolnay/rust-toolchain@stable / @master (ci.yml:195, :134) replaced by a pinned SHA with an explicit toolchain input; rust-toolchain.toml pins the default toolchain; the moon installer `curl … | bash` (ci.yml:75, docs.yml:26) replaced by a pinned release download checked against its sha256; .github/dependabot.yml keeps the SHA pins updated.", + "kind": "manual" + }, + { + "id": "dw-86c6ca", + "text": "S2: every workflow it owns has a top-level `permissions: contents: read` (or `{}`) with job-level grants only where needed; dependabot-auto-merge.yml no longer approves its own PRs (`gh pr review --approve` at :27 removed) and only enables auto-merge for patch/minor updates after required checks and a CODEOWNERS review.", + "kind": "manual" + }, + { + "id": "dw-7c44b3", + "text": "S5: Cargo.lock is committed (.gitignore:71 entry removed, 0.11.0.md D12) and CI builds use `--locked`; the minimal-versions job stays.", + "kind": "manual" + }, + { + "id": "dw-919a3d", + "text": "pg-integration job gains `-p id_effect_workflow` (for E7.5).", + "kind": "manual" + }, + { + "id": "dw-cc828d", + "text": "scripts/check-workflows.sh fails on an unpinned `uses:`, a workflow without top-level permissions, or `curl … | sh/bash`, has --self-test cases `s1_every_action_pinned_by_sha`, `s2_every_workflow_least_privilege`, `s2_dependabot_does_not_self_approve`, and runs as a step in ci.yml over all of .github/workflows (other leaves' new workflow files must pass it).", + "kind": "manual" + }, + { + "id": "dw-a6ab21", + "text": "Gate: `bash scripts/check-workflows.sh --self-test && bash scripts/check-workflows.sh`; `cargo build --workspace --locked`; `actionlint` on the owned workflows", + "kind": "manual" + }, + { + "id": "dw-765750", + "text": "Owns: `.github/workflows/{ci.yml,audit.yml,docs.yml,dependabot-auto-merge.yml}`, `.github/dependabot.yml`, `rust-toolchain.toml`, `.gitignore`, `Cargo.lock`, `scripts/check-workflows.sh`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sc-pin-permissions", + "lockedBy": "r11-sc-pin-permissions", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-0fb2aa.json b/.maestro/tasks/contracts/c-0fb2aa.json new file mode 100644 index 000000000..75af8779f --- /dev/null +++ b/.maestro/tasks/contracts/c-0fb2aa.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-0fb2aa", + "taskId": "tsk-mvf70fjh-fmn5my", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T11:50:37.239Z", + "lockedAt": "2026-10-19T11:50:37.239Z", + "intent": "r12-e12-3-h3-client", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3bc483", + "text": "HttpClientConfig::http3(bool) (opt-in for the client): requests go over h3 + h3-quinn when an Alt-Svc entry for the origin is known; the client never uses reqwest HTTP/3 and the workspace never sets --cfg reqwest_unstable.", + "kind": "manual" + }, + { + "id": "dw-ccda32", + "text": "Alt-Svc cache: max-age honoured, bounded entries (LRU), cleared on a network-change error; fallback to HTTP/2 when the QUIC handshake does not complete within 300 ms, with a 5-minute negative cache per origin; the HTTP/2 request is never delayed by more than the fallback timer.", + "kind": "manual" + }, + { + "id": "dw-f22c76", + "text": "The 0.11 SSRF guard (SsrfPolicy, crates/id_effect_platform/src/http/ssrf.rs) checks every resolved address on the h3 path too; timeouts, retries and the body cap (DEFAULT_MAX_BODY_BYTES, http/mod.rs:68) apply unchanged; TLS uses the platform webpki roots and verifies the server name.", + "kind": "manual" + }, + { + "id": "dw-0522df", + "text": "Tests `alt_svc_upgrades_next_request_to_h3`, `udp_blackhole_falls_back_to_h2_within_budget`, `negative_cache_skips_h3_after_failure`, `h3_path_enforces_ssrf_policy`, `h3_body_cap_enforced`, `h3_rejects_wrong_server_name`; book part6/ch29-08-http3-client.md.", + "kind": "manual" + }, + { + "id": "dw-1a7464", + "text": "Gate: `cargo nextest run -p id_effect_platform --features http3`; `cargo nextest run -p id_effect_platform --all-features`; `! git grep -n reqwest_unstable -- '*.toml' '*.rs' '*.yml' '*.nix'`", + "kind": "manual" + }, + { + "id": "dw-e82485", + "text": "Owns: `crates/id_effect_platform/src/http/{h3.rs,alt_svc.rs,mod.rs}`, `crates/id_effect_platform/tests/http3_client.rs`, `crates/id_effect/book/src/part6/ch29-08-http3-client.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "c2-h3-client", + "lockedBy": "c2-h3-client", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-101f8f.json b/.maestro/tasks/contracts/c-101f8f.json new file mode 100644 index 000000000..7bfe16968 --- /dev/null +++ b/.maestro/tasks/contracts/c-101f8f.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-101f8f", + "taskId": "tsk-mvdf5hvl-qkcn8e", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:02:53.657Z", + "lockedAt": "2026-10-18T06:02:53.657Z", + "intent": "r11-sec-adr0013", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-38ce77", + "text": "docs/adrs/0013-secure-by-default.md (status Proposed; the owner moves it to Accepted, 0.11.0.md D22) states the ten rules R1–R10 exactly as the 0.11.0.md table \"ADR 0013 — the ten rules\", each with the findings it closes, its enforcement point(s) and the leaf that implements it; it defines \"closed\" for a finding as \"the default path is safe and every escape is a named, recorded dangerous_*/insecure_* opt-out that fails the prod posture check\" (pending the owner's answer to Open question 1).", + "kind": "manual" + }, + { + "id": "dw-c66c50", + "text": "id_effect::observability::posture: record_dangerous(id: &'static str, reopens: &'static str) records an opt-out once per id, process-wide; dangerous_opt_outs() lists them; no cost on paths that never call it; documented as the hook every dangerous_*/insecure_* API in the workspace calls.", + "kind": "manual" + }, + { + "id": "dw-35f848", + "text": "Tests `posture_is_empty_by_default`, `posture_records_each_opt_out_once`, `posture_lists_reason_text`.", + "kind": "manual" + }, + { + "id": "dw-da3eb0", + "text": "Gate: `cargo nextest run -p id_effect --test posture`; `cargo nextest run -p id_effect --test kernel_alloc`", + "kind": "manual" + }, + { + "id": "dw-f1b0b8", + "text": "Owns: `docs/adrs/0013-secure-by-default.md`, `crates/id_effect/src/observability/{posture.rs,mod.rs}`, `crates/id_effect/tests/posture.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-10ff0e.json b/.maestro/tasks/contracts/c-10ff0e.json new file mode 100644 index 000000000..128c4407d --- /dev/null +++ b/.maestro/tasks/contracts/c-10ff0e.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-10ff0e", + "taskId": "tsk-mvcjv4gc-nokr7e", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:28:18.449Z", + "lockedAt": "2026-10-17T15:28:18.449Z", + "intent": "r10-sqlt-pg-wire", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-26a1ce", + "text": "`TIMESTAMPTZ`/`TIMESTAMP`/`DATE`/`TIME` bind and decode through own sqlx `Type`/`Encode`/`Decode` impls on the binary wire format (i64 µs / i32 days since 2000-01-01) into jiff; `±infinity` and out-of-range values map to `SqlError` (no panic); the conformance suite date-time cases pass unchanged; new tests `pg_wire_datetime_round_trip_bounds`, `pg_infinity_is_an_error`.", + "kind": "manual" + }, + { + "id": "dw-c6788c", + "text": "Gate: ` -p id_effect_sql --all-features`", + "kind": "manual" + }, + { + "id": "dw-a51969", + "text": "Owns: `crates/id_effect_sql/src/pg/{value.rs,datetime.rs}`, `src/value/time_conv.rs` (deleted or pg-only replacement)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-127d7d.json b/.maestro/tasks/contracts/c-127d7d.json new file mode 100644 index 000000000..2f20586c8 --- /dev/null +++ b/.maestro/tasks/contracts/c-127d7d.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-127d7d", + "taskId": "tsk-mvfixl0v-di486t", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T17:24:21.500Z", + "lockedAt": "2026-10-19T17:24:21.500Z", + "intent": "r12-e12-2-bidi-resume", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-e142e3", + "text": "web.rpc#4 (bidirectional): Duplex over WebSocket with credit-based backpressure in both directions; memory per stream bounded by the credit window.", + "kind": "manual" + }, + { + "id": "dw-e42978", + "text": "Resume: every server-to-client message carries a cursor; after a reconnect the client sends session id + last cursor and the server replays from a bounded per-session buffer (default 1,024 messages / 1 MiB / 30 s, config); a cursor older than the buffer yields the typed ResumeError::Expired so the client restarts the stream; subscriptions resume from the event store instead (r12-e12-2-subscriptions).", + "kind": "manual" + }, + { + "id": "dw-757e83", + "text": "Security (ADR 0013 R1): session ids are 128-bit random and bound to the authenticated principal; resuming under another principal is refused and counted.", + "kind": "manual" + }, + { + "id": "dw-2cf9b5", + "text": "Tests `bidi_backpressure_bounds_memory`, `resume_after_disconnect_no_gaps_no_duplicates` (proptest over random disconnect points, 1,000 cases, TestClock), `resume_after_buffer_overrun_is_expired`, `resume_under_another_principal_refused`, `session_buffer_released_after_retention`; `resume::decode_cursor` is public for the fuzz target; book part6/ch33-07-two-way-streams.md.", + "kind": "manual" + }, + { + "id": "dw-75c141", + "text": "Gate: `PROPTEST_CASES=1000 cargo nextest run -p id_effect_rpc --all-features --test resume_property`; `cargo nextest run -p id_effect_rpc --all-features`", + "kind": "manual" + }, + { + "id": "dw-101b13", + "text": "Owns: `crates/id_effect_rpc/src/{duplex.rs,resume.rs}`, `crates/id_effect_rpc/src/transport/ws.rs` (resume handshake, after r12-e12-2-rpc-ws-sse), `crates/id_effect_rpc/tests/{bidi.rs,resume_property.rs}`, `crates/id_effect/book/src/part6/ch33-07-two-way-streams.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "f2-bidi-resume", + "lockedBy": "f2-bidi-resume", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-12b6e9.json b/.maestro/tasks/contracts/c-12b6e9.json new file mode 100644 index 000000000..9a97ba231 --- /dev/null +++ b/.maestro/tasks/contracts/c-12b6e9.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-12b6e9", + "taskId": "tsk-mvb8iij2-kzsw5d", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T17:21:36.375Z", + "lockedAt": "2026-10-16T17:21:36.375Z", + "intent": "0.8 W3-E — book snippets", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-4ebdfa", + "text": "Every chapter file under crates/id_effect/book/src that contains a rust fence is included in the examples/book-snippets harness (commit e7e7ac1: one module per part; #[cfg(doctest)] #[doc = include_str!(..)] struct per chapter), except the two already compiled by crates/id_effect/src/lib.rs (appendix-e-idiom-guide.md, part4/ch15-05-interleavings.md).", + "kind": "manual" + }, + { + "id": "dw-a36036", + "text": "At least 80 % of all rust fences in the book compile and run (or are no_run) via cargo test --doc -p book_snippets; snippets that describe code that no longer exists are corrected to the 0.8 API (the book must describe what the code does today); a snippet left rust,ignore has a one-line HTML comment reason directly above it.", + "kind": "manual" + }, + { + "id": "dw-955823", + "text": "Hidden setup lines (# lines) keep the rendered snippet readable; mdbook build still succeeds (moon run :book or mdbook build crates/id_effect/book).", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-13f98e.json b/.maestro/tasks/contracts/c-13f98e.json new file mode 100644 index 000000000..bfbfa7184 --- /dev/null +++ b/.maestro/tasks/contracts/c-13f98e.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": 2, + "id": "c-13f98e", + "taskId": "tsk-mva026ub-1u86pn", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-15T20:37:09.561Z", + "lockedAt": "2026-10-15T20:37:09.561Z", + "intent": "http-showcase DDD rewrite", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-21218a", + "text": "src/ is layer-first: domain/, application/, infrastructure/, interface/http/, bootstrap/; lib.rs and every mod.rs only declare modules and re-export.", + "kind": "manual" + }, + { + "id": "dw-b7a162", + "text": "One construct per file: every struct, enum, trait, type alias, free function (use case, handler, layer constructor) has its own file; a type's inherent impls live with it; unit tests stay in a #[cfg(test)] block of the file they test; test doubles live in infrastructure/doubles/ under cfg(test).", + "kind": "manual" + }, + { + "id": "dw-0b9246", + "text": "Domain: TodoId and Title are brands/value objects; Todo holds a Title (not String) and owns its behaviour (rename, complete, reopen, apply(TodoChanges)); domain types derive EffectData where they qualify; no axum/sql types in domain/.", + "kind": "manual" + }, + { + "id": "dw-40f8db", + "text": "Errors are typed per use case (Or<..> of domain errors and the store error); HTTP status mapping lives only in interface/http; SqlError mapping lives only in infrastructure/postgres.", + "kind": "manual" + }, + { + "id": "dw-f02a1f", + "text": "Request bodies decode through #[derive(Schema)], reporting every bad field in one 422; the wire format and every status code of the README tables are unchanged.", + "kind": "manual" + }, + { + "id": "dw-2d97b1", + "text": "Store selection is layer composition (in-memory vs scoped Postgres provider that migrates on acquire), not a hand-written match; id_effect_resilience CircuitBreaker wraps store calls alongside the Schedule retry + timeout; id_effect_logger emits a structured startup line (bind address, store kind).", + "kind": "manual" + }, + { + "id": "dw-8947b4", + "text": "Public API kept as re-exports used by tests/http_pg.rs and the README, or the callers updated in the same change.", + "kind": "manual" + }, + { + "id": "dw-278f98", + "text": "Gates green: cargo clippy -p http_showcase --all-targets --all-features -- -D warnings; #![deny(missing_docs)]; cargo nextest run -p http_showcase with DATABASE_URL against devenv Postgres and ID_EFFECT_REQUIRE_PG=1; cargo test --doc -p http_showcase.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-147f9f.json b/.maestro/tasks/contracts/c-147f9f.json new file mode 100644 index 000000000..60a7f35d7 --- /dev/null +++ b/.maestro/tasks/contracts/c-147f9f.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-147f9f", + "taskId": "tsk-mvfr897j-gvxts8", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T21:16:33.554Z", + "lockedAt": "2026-10-19T21:16:33.554Z", + "intent": "r12-w7-relay", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-832962", + "text": "examples/reference/relay is built only through id_effect_kit (features rpc, rpc-ws, http3, webtransport, cluster, mesh-mtls, events, sql-pg, auth, authz): chat rooms are channels topics, membership and presence per room, messages sent over a two-way RPC stream, room history as an events-backed subscription on Postgres, node-to-node calls over mesh RPC, three nodes on real QUIC (loopback ports, the shared endpoint of D7 with mutual TLS for idm/1).", + "kind": "manual" + }, + { + "id": "dw-ba831c", + "text": "Clients in CI: the Rust client, the generated TypeScript client under Deno, and Chromium over CDP (WebTransport and WebSocket) with ID_EFFECT_REQUIRE_BROWSER=1.", + "kind": "manual" + }, + { + "id": "dw-a378c1", + "text": "Prod posture clean with zero accepted risks; every procedure declared public or guarded; join denied without room membership; id-effect rpc check runs against rpc.contract.json at the last tag; the CI job reference-relay runs with Postgres and runs once more with UDP dropped by iptables to prove TCP fallback.", + "kind": "manual" + }, + { + "id": "dw-e741b0", + "text": "Tier: the Relay depends on id_effect_rpc, so its tier line moves to platform (ADR 0012 rule 4, check P of scripts/tier-conformance.sh); web.rpc and web.realtime reach level production.", + "kind": "manual" + }, + { + "id": "dw-983317", + "text": "Tests `relay_three_nodes_chat_over_http3`, `relay_three_nodes_chat_over_websocket`, `relay_presence_converges_after_node_loss`, `relay_resume_after_client_reconnect_no_gaps`, `relay_udp_blocked_falls_back_to_tcp`, `relay_join_denied_without_membership`, `relay_browser_chat_round_trip`, `relay_prod_posture_is_clean`, `relay_contract_check_passes`.", + "kind": "manual" + }, + { + "id": "dw-61ac6d", + "text": "Gate: `ID_EFFECT_REQUIRE_DOCKER=1 ID_EFFECT_REQUIRE_BROWSER=1 cargo nextest run -p relay`; `bash scripts/tier-conformance.sh` (all PASS); CI job `reference-relay` green", + "kind": "manual" + }, + { + "id": "dw-a1d695", + "text": "Owns: `examples/reference/relay/**`, `.github/workflows/ci.yml` (the `reference-relay` job and its `ci-pass` needs entry), the tier line in `crates/id_effect_rpc/Cargo.toml`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "i1-relay", + "lockedBy": "i1-relay", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-16e5d7.json b/.maestro/tasks/contracts/c-16e5d7.json new file mode 100644 index 000000000..fdcd31563 --- /dev/null +++ b/.maestro/tasks/contracts/c-16e5d7.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-16e5d7", + "taskId": "tsk-mvervb9q-tizqjp", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T04:46:45.087Z", + "lockedAt": "2026-10-19T04:46:45.087Z", + "intent": "r11-sec-client-ssrf", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-fedb8d", + "text": "M3 (0.11.0.md D10): HttpClientConfig::default() turns on an SsrfPolicy enforced by a custom reqwest DNS resolver that checks every resolved address (DNS-rebinding safe) and by a check on literal-IP URLs: loopback, RFC 1918, CGNAT 100.64/10, link-local 169.254/16 and fe80::/10 (incl. 169.254.169.254 and fd00:ec2::254), ULA fc00::/7, multicast, unspecified and IPv4-mapped forms are refused; schemes http and https only.", + "kind": "manual" + }, + { + "id": "dw-b6a9d2", + "text": "Redirects off by default (crates/id_effect_platform/src/http/mod.rs:299-302 builds the client without a redirect policy today, so reqwest follows up to 10); follow_redirects(max) is opt-in and re-checks the policy on every hop; allow_hosts / allow_cidrs allowlist, settable from config; dangerous_allow_private_networks() records a posture opt-out. MIGRATION-0.11 section \"SSRF default\".", + "kind": "manual" + }, + { + "id": "dw-9b96d5", + "text": "Existing tests that call wiremock on 127.0.0.1 through HttpClient (id_effect_platform, id_effect_mail, id_effect_auth oidc, Ledger) use the allowlist; the Ledger test profile allowlists its fakes in config/test.toml.", + "kind": "manual" + }, + { + "id": "dw-66d0ca", + "text": "Regression tests `m3_ssrf_blocks_loopback_private_and_metadata`, `m3_ssrf_checks_every_resolved_address`, `m3_ipv4_mapped_ipv6_is_blocked`, `m3_redirects_not_followed_by_default`, `m3_followed_redirect_to_private_ip_is_blocked`, `m3_allowlist_permits_named_host`.", + "kind": "manual" + }, + { + "id": "dw-e38aa4", + "text": "Gate: `cargo nextest run -p id_effect_platform -p id_effect_mail -p id_effect_auth --all-features`; `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p ledger` (Docker required)", + "kind": "manual" + }, + { + "id": "dw-5b959d", + "text": "Owns: `crates/id_effect_platform/{src/http/**,Cargo.toml,tests/ssrf.rs}`, fixture lines in existing files under `crates/id_effect_mail/tests/**`, `crates/id_effect_auth/tests/**` and `examples/reference/ledger/tests/**`, `examples/reference/ledger/config/test.toml`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sec-client-ssrf", + "lockedBy": "r11-sec-client-ssrf", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-18d82e.json b/.maestro/tasks/contracts/c-18d82e.json new file mode 100644 index 000000000..820782ea7 --- /dev/null +++ b/.maestro/tasks/contracts/c-18d82e.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-18d82e", + "taskId": "tsk-mveoxlor-3b9mxh", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:24:33.553Z", + "lockedAt": "2026-10-19T03:24:33.553Z", + "intent": "r11-e2-6-bench-ci", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-dd6fb6", + "text": "test.qa#8: .github/workflows/bench.yml runs scripts/bench-run.sh for base and head on the same runner (dedicated `bench` runner label if available, otherwise ubuntu-latest) on pushes to main and on pull requests labelled perf, then scripts/bench-compare.sh against docs/benchmarks/gates.toml; a failed budget gate fails the job; SHA-pinned, permissions read.", + "kind": "manual" + }, + { + "id": "dw-f8ca30", + "text": "crates/id_effect_axum/benches/gate_request.rs gains a secure_stack case (SecurityLayer::recommended, rate limit, load shedding, authentication + guard, default log redaction) next to the bare request; gates.toml gains a `kind = \"pair\"` gate secure_stack / bare ≤ 1.15 (an intra-run ratio, which bench-compare.sh learns, with --self-test cases).", + "kind": "manual" + }, + { + "id": "dw-1a91f3", + "text": "docs/benchmarks/README.md documents the job and the pair gate. The secure-stack figure is re-measured with the final stack by r11-w3-kpis.", + "kind": "manual" + }, + { + "id": "dw-c1b9bd", + "text": "Gate: `bash scripts/bench-compare.sh --self-test`; `bash scripts/bench-run.sh --only id_effect_axum:gate_request`; `bash scripts/check-workflows.sh`", + "kind": "manual" + }, + { + "id": "dw-8908fd", + "text": "Owns: `.github/workflows/bench.yml`, `crates/id_effect_axum/benches/gate_request.rs`, `docs/benchmarks/{gates.toml,README.md}`, `scripts/bench-compare.sh`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e2-6-bench-ci", + "lockedBy": "r11-e2-6-bench-ci", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-19cd27.json b/.maestro/tasks/contracts/c-19cd27.json new file mode 100644 index 000000000..42c951f64 --- /dev/null +++ b/.maestro/tasks/contracts/c-19cd27.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-19cd27", + "taskId": "tsk-mvbp968a-2m4p9i", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T01:10:16.716Z", + "lockedAt": "2026-10-17T01:10:16.716Z", + "intent": "r09-w3-gaps", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-76b702", + "text": "R1: book ch30-02-authorization.md gains a \"data-layer scoping\" section (Repository::for_current_tenant, the tenant filter on every statement, fail-closed) with a compiling snippet; cites the Ledger test organisations_never_see_each_other.", + "kind": "manual" + }, + { + "id": "dw-202003", + "text": "R2: ch28-03-repositories.md shows a join, ordering and paging with sea-query + Page; crates/id_effect_sql/tests/query.rs gains a join test.", + "kind": "manual" + }, + { + "id": "dw-35f9d5", + "text": "R3: ch28-04-redis.md describes RedisKv / RedisHash / RedisJson as a typed key-value and JSON document store, with a compiling snippet.", + "kind": "manual" + }, + { + "id": "dw-18b05d", + "text": "H4: crates/id_effect_sql/examples/020_typed_rows.rs (runs without a database: TestSqlClient or SQLite) showing SqlValue, params!, typed get, FromRow; [[example]] entry.", + "kind": "manual" + }, + { + "id": "dw-00a30e", + "text": "G2 (eco.tenancy #7): a RateLimitKey keyed by the request tenant (from RequestCaps / a header / a closure over request extensions — whatever fits without id_effect_axum depending on id_effect_sql), with a test; ch30-07 no longer says per-tenant limits are not provided; ch29-03 mentions it.", + "kind": "manual" + }, + { + "id": "dw-437bf0", + "text": "G4 (test.integration #8): a contract-testing helper in id_effect_test (feature http + openapi-aware: assert a TestResponse conforms to the response schema an OpenApi document declares for that operation and status), a test, a section in ch15-08, and use in Ledger tests/http.rs.", + "kind": "manual" + }, + { + "id": "dw-a93d6c", + "text": "All touched crates: nextest, clippy -D warnings, fmt; book_snippets doctests pass.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/tasks/contracts/c-1a0e33.json b/.maestro/tasks/contracts/c-1a0e33.json new file mode 100644 index 000000000..99b199701 --- /dev/null +++ b/.maestro/tasks/contracts/c-1a0e33.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-1a0e33", + "taskId": "tsk-mvbdsp4f-i8nl5p", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:31:58.880Z", + "lockedAt": "2026-10-16T20:31:58.880Z", + "intent": "E5.2a credentials and JWT", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-7f69fc", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-8c4cbc", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-272680", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-be0f8f", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-24ef03", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-1fe3df", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-951c86", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-1ba8bb.json b/.maestro/tasks/contracts/c-1ba8bb.json new file mode 100644 index 000000000..4cdffb198 --- /dev/null +++ b/.maestro/tasks/contracts/c-1ba8bb.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-1ba8bb", + "taskId": "tsk-mvdftqvl-77wo59", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:21:45.103Z", + "lockedAt": "2026-10-18T06:21:45.103Z", + "intent": "r11-e8-1-mailbox", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-184026", + "text": "M10 (0.11.0.md D17): every Mailbox is bounded (crates/id_effect_process/src/mailbox.rs:41 today says \"Unbounded\"), default 10,000 user messages, configurable per spawn; system messages (exit signals, monitor downs, kill) bypass the bound; a full mailbox makes local sends fail with SendError::Full (SendError becomes #[non_exhaustive], msg.rs:114) and Addr::send_now return false (addr.rs:66-71); remote delivery to a full mailbox (crates/id_effect_node/src/registry.rs:208,220,234) faults the remote call and drops + counts a remote cast.", + "kind": "manual" + }, + { + "id": "dw-0f4501", + "text": "fault.actors#7 selective receive: ProcessCtx::receive(matcher, timeout) returns the first message the matcher accepts and keeps every skipped message in arrival order for the normal loop; the scan is bounded by the mailbox capacity; a timeout yields a typed error.", + "kind": "manual" + }, + { + "id": "dw-4d1950", + "text": "Regression tests `m10_bounded_mailbox_rejects_when_full`, `m10_remote_send_to_full_mailbox_faults`, `m10_system_messages_bypass_bound`; feature tests `selective_receive_keeps_order_of_skipped_messages`, `selective_receive_times_out`. MIGRATION-0.11 section for SendError.", + "kind": "manual" + }, + { + "id": "dw-e90c4e", + "text": "Gate: `cargo nextest run -p id_effect_process -p id_effect_node --all-features`; `cargo clippy -p id_effect_process --all-targets --all-features -- -D warnings`", + "kind": "manual" + }, + { + "id": "dw-6217ef", + "text": "Owns: `crates/id_effect_process/src/{mailbox.rs,addr.rs,msg.rs,node.rs,lib.rs}`, `crates/id_effect_node/src/registry.rs`, `crates/id_effect_process/tests/{mailbox_bounds.rs,selective_receive.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-1c636c.json b/.maestro/tasks/contracts/c-1c636c.json new file mode 100644 index 000000000..017447d2d --- /dev/null +++ b/.maestro/tasks/contracts/c-1c636c.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-1c636c", + "taskId": "tsk-mvcjv4jp-snazrp", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:28:18.324Z", + "lockedAt": "2026-10-17T15:28:18.324Z", + "intent": "r10-t0-adr-skeletons", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-31c76d", + "text": "Amendment 6 names the six crates + tier (D5); each skeleton has README with a `rust` fence, one example, `#![deny(missing_docs)]`, one test, tier `integration`; `--print-order` lists `id_effect_events`, `id_effect_messaging`, `id_effect_storage` after `id_effect_sql`/`id_effect_platform`, and `id_effect_jobs` (after `id_effect_node`), `id_effect_mail` (after jobs, auth), `id_effect_console` (after jobs, authz) before `id_effect_kit`; crates.io availability of the six names recorded in the leaf evidence; `sec.authn.targets[\"0.10.0\"] == 88`.", + "kind": "manual" + }, + { + "id": "dw-094e8c", + "text": "Gate: `bash scripts/tier-conformance.sh id_effect_jobs id_effect_messaging id_effect_mail id_effect_console id_effect_storage` PASS; `bash scripts/validate-crate-packaging.sh`; `bash scripts/scorecard.sh --targets 0.10.0` prints E 74.1", + "kind": "manual" + }, + { + "id": "dw-248cb3", + "text": "Owns: `docs/adrs/0010-crate-tiers.md` (Amendment 6), root `Cargo.toml` members, `crates/id_effect_{jobs,messaging,mail,console,storage}/` skeletons, `scripts/validate-crate-packaging.sh` (`LEVEL`, `ORDER`), `.github/workflows/ci.yml` (`-p` for the new crates in the pg job at `:203` and the containers job at `:229`), `deny.toml`, `docs/scorecard/scorecard.json` (D6 target line only)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-1e07fc.json b/.maestro/tasks/contracts/c-1e07fc.json new file mode 100644 index 000000000..415797050 --- /dev/null +++ b/.maestro/tasks/contracts/c-1e07fc.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 2, + "id": "c-1e07fc", + "taskId": "tsk-mvb8sxid-wpkrtg", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T17:29:39.347Z", + "lockedAt": "2026-10-16T17:29:39.347Z", + "intent": "0.8 W3-B — id_effect_tool and `id-effect new`", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-bf45cd", + "text": "New crate crates/id_effect_tool (integration tier per ADR 0010 Amendment 4; version.workspace, readme, deny(missing_docs), forbid(unsafe_code)) publishing the binary id-effect ([[bin]] name = \"id-effect\", thin src/bin over a tested lib). No dependency on id_effect_kit. Registered: workspace member, moon project + moon.yml, validate-crate-packaging LEVEL/ORDER after id_effect_kit; tier-conformance PASS.", + "kind": "manual" + }, + { + "id": "dw-ac158f", + "text": "`id-effect new --template service|worker|cli|cluster [--dest DIR] [--db none|pg] [--otel|--no-otel] [--local-workspace PATH]` renders templates embedded in the binary (build.rs-generated include_str! table or explicit table with a test that it matches the template dir; {{name}}/{{crate_name}}/{{id_effect_version}} substitution; Cargo.toml.template and _github naming so cargo package keeps them), refuses a non-empty destination, and writes for every template: Cargo.toml depending on id_effect_kit (+ id_effect, since its macros expand to ::id_effect), src, tests, Dockerfile, compose.yaml, .github/workflows/ci.yml, .env.example, README. --local-workspace writes [patch.crates-io] paths to the local id_effect* crates.", + "kind": "manual" + }, + { + "id": "dw-6c4b03", + "text": "Templates: service = axum app with SecurityLayer::recommended, /health + /ready (HealthRegistry), /metrics + http_metrics_layer, OTel when --otel, SQL client + a migration when --db pg (default --db none so it boots with no services); worker = CronRegistry jobs with history; cli = run_main + completions (from examples/cli-minimal); cluster = id_effect_node mesh node. The orphaned crates/id_effect_cli/templates/deploy-kamal is moved into the tool (Dockerfile/Kamal) and removed from id_effect_cli.", + "kind": "manual" + }, + { + "id": "dw-cea6c3", + "text": "scripts/template-smoke.sh generates each template into a temp dir outside the repo with --local-workspace and runs cargo test there (and cargo build); a ci.yml `templates` job runs it as a matrix over the 4 templates and docker-builds the service template; job added to ci-pass.needs; actionlint clean. cargo package -p id_effect_tool --list includes every template file.", + "kind": "manual" + }, + { + "id": "dw-2f76cf", + "text": "Unit tests for rendering, substitution, refusal on non-empty dest, option handling; clippy -D warnings, rustdoc -D warnings; coverage: bin excluded via the coverage IGNORE mechanism with a justification, lib tested.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-1fb1ff.json b/.maestro/tasks/contracts/c-1fb1ff.json new file mode 100644 index 000000000..bd3ec5a09 --- /dev/null +++ b/.maestro/tasks/contracts/c-1fb1ff.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-1fb1ff", + "taskId": "tsk-mvf7jpll-z8910m", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T12:05:49.757Z", + "lockedAt": "2026-10-19T12:05:49.757Z", + "intent": "r12-e12-1-kinds", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-9fe2c9", + "text": "Six kinds per the design note: query (GET /rpc/?input= with ETag and If-None-Match → 304, or POST; retry-safe), mutation (POST; optional Idempotency-Key replayed from an IdempotencyStore trait with a bounded in-memory store, TTL 24 h, 100,000 keys, D14), server stream (NDJSON response), client stream (chunked NDJSON request with item and byte caps), and the runtime types Duplex and Subscription with an in-process transport (their wire mappings come in waves 3–4).", + "kind": "manual" + }, + { + "id": "dw-6b0ea2", + "text": "Typed errors: the handler E becomes a tagged union {\"_tag\": ...} in the contract and on the wire; defects (panics, Die) become InternalError { id } carrying only the correlation id; no message or backtrace reaches the client (ADR 0013 R3).", + "kind": "manual" + }, + { + "id": "dw-6733e0", + "text": "Cancellation: a client disconnect or cancel interrupts the handler fiber and its scope finalizers run (the 0.11 inline executor DropPolicy::Interrupt path); a per-call deadline header is capped by the server maximum.", + "kind": "manual" + }, + { + "id": "dw-0c0fac", + "text": "Browser CSRF (ADR 0013 R9): mutations require Content-Type application/json (or the postcard type) and the x-rpc header; the 0.11 CSRF layer applies to cookie sessions.", + "kind": "manual" + }, + { + "id": "dw-da9598", + "text": "Tests `query_get_returns_etag_and_304`, `mutation_idempotency_key_replays_result`, `client_stream_over_cap_rejected`, `typed_error_reaches_client_as_tagged_union`, `defect_becomes_internal_error_without_message`, `client_disconnect_interrupts_handler_and_runs_finalizers`, `deadline_capped_by_server_max`, `r9_rpc_mutation_without_custom_header_refused`, `r9_rpc_form_content_type_refused`; book part6/ch33-01-procedures.md.", + "kind": "manual" + }, + { + "id": "dw-65cc93", + "text": "Gate: `cargo nextest run -p id_effect_rpc --all-features`; `cargo clippy -p id_effect_rpc --all-targets --all-features -- -D warnings`", + "kind": "manual" + }, + { + "id": "dw-8fb969", + "text": "Owns: `crates/id_effect_rpc/src/{kinds.rs,handler.rs,cancel.rs,error_union.rs,idempotency.rs}`, `crates/id_effect_rpc/src/server/{mod.rs,dispatch.rs,http.rs}`, `crates/id_effect_rpc/tests/{kinds_http.rs,cancellation.rs,errors.rs}`, `crates/id_effect/book/src/part6/ch33-01-procedures.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "c4-kinds", + "lockedBy": "c4-kinds", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-2123cb.json b/.maestro/tasks/contracts/c-2123cb.json new file mode 100644 index 000000000..70b920bcd --- /dev/null +++ b/.maestro/tasks/contracts/c-2123cb.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-2123cb", + "taskId": "tsk-mvey915n-8cs2b5", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T07:45:20.957Z", + "lockedAt": "2026-10-19T07:45:20.957Z", + "intent": "r11-w3-kpis", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-e06723", + "text": "docs/security/findings.json registers all 41 findings of the 2026-10-18 review (H1–H8, M1–M20, L1–L6, S1–S7) with severity, status (closed / accepted), the release and leaf that closed it, and the regression test or gate.", + "kind": "manual" + }, + { + "id": "dw-b55f79", + "text": "scripts/security-kpis.sh (with --self-test) writes docs/security/kpis-0.11.json: open highs (0), OpenSSF Scorecard score (from the scorecard workflow result, ≥ 7.5), Best Practices status (Passing), SLSA level and the count of published crates with a verified attestation (all of them), fuzz targets in CI (≥ 8) and open crashes (0), compile-fail security cases (≥ 15), secure-stack / bare bench ratio (≤ 1.15, final stack).", + "kind": "manual" + }, + { + "id": "dw-876d74", + "text": "DX KPIs: `DX_KPIS_RELEASE=0.11 bash scripts/dx-kpis.sh` writes docs/benchmarks/dx-kpis-0.11.json with K1 ≤ 180 s, K2 ≤ 8 s, K4 ≥ 17/20, K5 ≥ 80 %, K3 and K6 recorded.", + "kind": "manual" + }, + { + "id": "dw-b0ed32", + "text": "Gate: `bash scripts/security-kpis.sh --self-test && bash scripts/security-kpis.sh 0.11.0`; `DX_KPIS_RELEASE=0.11 bash scripts/dx-kpis.sh`", + "kind": "manual" + }, + { + "id": "dw-9e6fec", + "text": "Owns: `docs/security/{findings.json,kpis-0.11.json}`, `scripts/security-kpis.sh`, `docs/benchmarks/dx-kpis-0.11.json`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-w3-kpis", + "lockedBy": "r11-w3-kpis", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/tasks/contracts/c-2136ca.json b/.maestro/tasks/contracts/c-2136ca.json new file mode 100644 index 000000000..657b25f1d --- /dev/null +++ b/.maestro/tasks/contracts/c-2136ca.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-2136ca", + "taskId": "tsk-mvdgtk7t-ly5lul", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:49:36.067Z", + "lockedAt": "2026-10-18T06:49:36.067Z", + "intent": "r11-sec-sql-redis", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-046de0", + "text": "M16 (0.11.0.md D9): the effect-level query and execute (crates/id_effect_sql/src/client.rs:195, :207, today impl Into) take impl Into, where Sql: From<&'static str>; string literals still compile and a String does not; query_raw and execute_raw take RawSql and need RawSqlCapability in R, provided only by RawSqlCapability::layer_dangerous() (records a posture opt-out; the capability has no public constructor). Internal callers that build SQL (repository, migrate, query builder) use a crate-private trusted constructor. The SqlClient trait's &str methods stay as the driver boundary. MIGRATION-0.11 section \"Sql/RawSql\".", + "kind": "manual" + }, + { + "id": "dw-6575fc", + "text": "M17: clear() on a Redis store whose namespace is empty (crates/id_effect_redis/src/client.rs:355-367 turns \"\" into the glob \"*\"; store.rs:90-91, :287, :423, :569) returns RedisError::EmptyNamespace; the explicit flush is clear_all_dangerous() (records a posture opt-out).", + "kind": "manual" + }, + { + "id": "dw-f1ed0d", + "text": "Regression tests `m16_query_with_string_does_not_compile` (trybuild, crates/id_effect_sql/tests/ui), `m16_query_raw_requires_capability` (trybuild), `m16_literal_query_still_compiles`, `m17_clear_with_empty_namespace_is_an_error`, `m17_clear_only_deletes_namespace_keys` (Docker Redis).", + "kind": "manual" + }, + { + "id": "dw-d85695", + "text": "Gate: `cargo nextest run -p id_effect_sql --test ui`; `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p id_effect_sql --all-features` (Postgres required); `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p id_effect_redis --all-features` (Docker required); `cargo build --workspace --all-targets`", + "kind": "manual" + }, + { + "id": "dw-57b143", + "text": "Owns: `crates/id_effect_sql/**` (incl. new `src/raw.rs` and `tests/ui/m16_*`), `crates/id_effect_redis/src/{store.rs,client.rs}`, `crates/id_effect_redis/tests/store_clear.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-246560.json b/.maestro/tasks/contracts/c-246560.json new file mode 100644 index 000000000..cce03dc16 --- /dev/null +++ b/.maestro/tasks/contracts/c-246560.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-246560", + "taskId": "tsk-mvblcbgd-lktftw", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T23:39:20.074Z", + "lockedAt": "2026-10-16T23:39:20.074Z", + "intent": "r09-w3-ledger", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-cf4ad0", + "text": "See below.", + "kind": "manual" + }, + { + "id": "dw-cf30e0", + "text": "Gate: `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p ledger`; `bash scripts/tier-conformance.sh` (all PASS)", + "kind": "manual" + }, + { + "id": "dw-0e883b", + "text": "Owns: `examples/reference/ledger/**`, root `Cargo.toml` member, `.github/workflows/ci.yml` (`reference-ledger` job), tier flips to `platform` in the new crates' manifests", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-24b4dd.json b/.maestro/tasks/contracts/c-24b4dd.json new file mode 100644 index 000000000..6375c3aa0 --- /dev/null +++ b/.maestro/tasks/contracts/c-24b4dd.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-24b4dd", + "taskId": "tsk-mvcjv42t-2myvtg", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T17:36:25.209Z", + "lockedAt": "2026-10-17T17:36:25.209Z", + "intent": "r10-e7-4-snapshots-subscriptions", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f3425f", + "text": "`SnapshotStore` + aggregate load = snapshot + tail (#6); global position column and `CatchUpSubscription` with a stored checkpoint, resuming after restart (#8). Tests `aggregate_loads_from_snapshot_plus_tail`, `subscription_resumes_from_checkpoint`. Chapter + example + integration test.", + "kind": "manual" + }, + { + "id": "dw-ab6e05", + "text": "Gate: same + `cargo test --doc -p book_snippets`; `bash scripts/tier-conformance.sh id_effect_events`", + "kind": "manual" + }, + { + "id": "dw-ccad75", + "text": "Owns: `crates/id_effect_events/src/{snapshot.rs,subscription.rs}`, `book/src/part5/ch23-00-events-and-projections.md` (de-labs, `SUMMARY.md:151`), `crates/id_effect_events/examples/010_account.rs`, `crates/id_effect_events/tests/pg_events.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-24f7a7.json b/.maestro/tasks/contracts/c-24f7a7.json new file mode 100644 index 000000000..2bd2ecca1 --- /dev/null +++ b/.maestro/tasks/contracts/c-24f7a7.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-24f7a7", + "taskId": "tsk-mvbdsp4f-spcif4", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:51:52.834Z", + "lockedAt": "2026-10-16T20:51:52.834Z", + "intent": "E3.1a savepoints, isolation levels, query_stream", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3565d4", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-3dfa3d", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-66b34e", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-02f334", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-91385a", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-a09b90", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-e5c8d6", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-2618bf.json b/.maestro/tasks/contracts/c-2618bf.json new file mode 100644 index 000000000..b0c209766 --- /dev/null +++ b/.maestro/tasks/contracts/c-2618bf.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-2618bf", + "taskId": "tsk-mvd9mlx1-w6ietq", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T03:28:19.947Z", + "lockedAt": "2026-10-18T03:28:19.947Z", + "intent": "r101-client", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-8d9eb4", + "text": "Closes M2 as specified in the Findings table of .maestro/specs/releases/0.10.1.md, within the patch-release rules there (additive API only; stricter defaults with a named dangerous/insecure opt-out). One regression test per finding, named after it (for example h1_per_ip_limit_sees_the_peer).", + "kind": "manual" + }, + { + "id": "dw-502f56", + "text": "Gate: cargo test -p id_effect_platform --all-features; cargo clippy -p id_effect_platform --all-features --all-targets -- -D warnings", + "kind": "manual" + }, + { + "id": "dw-dc8861", + "text": "Owns: crates/id_effect_platform/src/http/**, crates/id_effect_platform/tests/**", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-2a2a87.json b/.maestro/tasks/contracts/c-2a2a87.json new file mode 100644 index 000000000..87e855969 --- /dev/null +++ b/.maestro/tasks/contracts/c-2a2a87.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-2a2a87", + "taskId": "tsk-mvb0jmcb-ajdki6", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T13:38:34.996Z", + "lockedAt": "2026-10-16T13:38:34.996Z", + "intent": "0.8 E2.3 — schema", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-4f6cc4", + "text": "data.jsonschema 0/8 -> 4/8, level tested: JSON Schema (2020-12) export for any Schema/HasSchema type (#1; first establish what crates/id_effect/src/schema/json_schema_export.rs already does and why the 0.7 scorecard did not credit it), the same schema value used for validation and export (#4), descriptions/examples/deprecation annotations emitted (#5), constraints reflected (minLength, pattern, enum, ranges) (#6); golden tests per schema kind; validated against a JSON Schema validator crate in dev-dependencies if one is already in the tree, else golden only.", + "kind": "manual" + }, + { + "id": "dw-9a2f2f", + "text": "data.schema 6/8 -> 7/8: struct/record decoding accumulates all field errors in one pass instead of fail-fast (#6, parse.rs ~:500), or built-in validators email/uuid/url/length/range/regex (#8) — do #6, add #8 if cheap; existing parse tests keep passing; MIGRATION-0.8 text if error shapes change.", + "kind": "manual" + }, + { + "id": "dw-319293", + "text": "Book schema chapter updated; clippy -D warnings, docs, tests green for id_effect.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-2a710f.json b/.maestro/tasks/contracts/c-2a710f.json new file mode 100644 index 000000000..34a28397d --- /dev/null +++ b/.maestro/tasks/contracts/c-2a710f.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-2a710f", + "taskId": "tsk-mvbh3yr2-bsovx7", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:22:21.291Z", + "lockedAt": "2026-10-16T21:22:21.291Z", + "intent": "r09-e5-2b-oidc", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-ea850d", + "text": "Discovery, authorization-code + PKCE, state/nonce in the session, ID-token validation, callback handler; `OAuthClient` implemented for it. Integration test logs in against a Keycloak container (D5).", + "kind": "manual" + }, + { + "id": "dw-229005", + "text": "Gate: `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p id_effect_auth --features oidc`", + "kind": "manual" + }, + { + "id": "dw-6e9c48", + "text": "Owns: `crates/id_effect_auth/src/oidc.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-2c1844.json b/.maestro/tasks/contracts/c-2c1844.json new file mode 100644 index 000000000..cbfef53e3 --- /dev/null +++ b/.maestro/tasks/contracts/c-2c1844.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-2c1844", + "taskId": "tsk-mvaw7lqp-9fes2a", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T11:37:09.169Z", + "lockedAt": "2026-10-16T11:37:09.169Z", + "intent": "Scorecard method 2.1", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-59b3b7", + "text": "Rows sec.supply, test.qa, eco.stability, eco.backing, eco.community, eco.production and eco.license carry \"process\": true; on them every framework level is production when it meets at least one check and absent otherwise; --validate fails otherwise (fixture bad-process-level).", + "kind": "manual" + }, + { + "id": "dw-5e239f", + "text": "method_version is \"2.1\"; METHOD §7.4 states the rule, §5.2 and §8 are updated; self-test passes with the new numbers.", + "kind": "manual" + }, + { + "id": "dw-965cf6", + "text": "data.jsonschema 0.8.0 target is 50 (owner decision D1, 2026-10-16); PLAN-OF-PLANS §1/§6 and the 0.8 release spec carry the recomputed targets.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-2dbc7a.json b/.maestro/tasks/contracts/c-2dbc7a.json new file mode 100644 index 000000000..4b03a49e6 --- /dev/null +++ b/.maestro/tasks/contracts/c-2dbc7a.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-2dbc7a", + "taskId": "tsk-mvblcbjl-ijn35n", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T00:58:35.210Z", + "lockedAt": "2026-10-17T00:58:35.210Z", + "intent": "r09-w3-k3", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-06884e", + "text": "K3 = added non-blank lines of the scripted \"invoice\" resource diff (model, migration, routes, policy, test) applied to Ledger, recorded.", + "kind": "manual" + }, + { + "id": "dw-ef62e5", + "text": "Gate: `bash scripts/dx-kpis.sh --skip-k1` prints K3", + "kind": "manual" + }, + { + "id": "dw-7aaf3c", + "text": "Owns: `scripts/dx-kpis.sh` (K3), the 0.9 dx results file", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-2dfbca.json b/.maestro/tasks/contracts/c-2dfbca.json new file mode 100644 index 000000000..6308c0ec8 --- /dev/null +++ b/.maestro/tasks/contracts/c-2dfbca.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-2dfbca", + "taskId": "tsk-mvcpeyhp-jmyzba", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:36:56.581Z", + "lockedAt": "2026-10-17T18:36:56.581Z", + "intent": "r10-e1-5-devcontainer", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-00ec6d", + "text": "Generated projects ship `.devcontainer/devcontainer.json` (+ compose services) (#8); template smoke builds it with `devcontainer read-configuration`-free JSON validation in the test; chapter.", + "kind": "manual" + }, + { + "id": "dw-1a8996", + "text": "Gate: `cargo nextest run -p id_effect_tool`; `bash scripts/template-smoke.sh`", + "kind": "manual" + }, + { + "id": "dw-f2aedc", + "text": "Owns: `crates/id_effect_tool/templates/common/_devcontainer/**`, `src/template.rs` (`FILES`), `crates/id_effect_tool/tests/bin.rs`, `book/src/part6/ch34-02-dev.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-2e2040.json b/.maestro/tasks/contracts/c-2e2040.json new file mode 100644 index 000000000..caef0b7c7 --- /dev/null +++ b/.maestro/tasks/contracts/c-2e2040.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-2e2040", + "taskId": "tsk-mvf4c3oa-dc40fo", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T10:35:43.284Z", + "lockedAt": "2026-10-19T10:35:43.284Z", + "intent": "r12-t0-adr0014", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-0845c6", + "text": "docs/adrs/0014-one-call-surface.md (status Proposed) records: RPC is the first-party call surface and REST + OpenAPI stays the public API style; GraphQL is a non-goal (L15); HTTP/3 is a standard transport with TCP fallback always on and one config flag (server.http3.enabled) to turn it off (D4); crate placement (D3); the shared QUIC endpoint in id_effect_platform (D6) and its client-authentication rule (D7); TLS + HTTP/2 on TCP (D10); the contract artifact (D11); encodings (D13); and every row of the release spec Security table as a rule with its enforcement point.", + "kind": "manual" + }, + { + "id": "dw-497fd4", + "text": "D5 recorded in the ADR and in PLAN-OF-PLANS §5 T12 and §6 row 0.12.0: the unary gate is an absolute delta (rpc/unary_json − rpc/axum_json_handler ≤ 3 µs, same run and runner; the ratio is informational) with the reason (0.11 inline request fibers, bf7be935: bare request 0.90 µs, so 1.10× leaves about 90 ns).", + "kind": "manual" + }, + { + "id": "dw-f02991", + "text": "docs/scorecard/scorecard.json: web.rpc target 0.12.0 88 → 100 (D2); data.orm, web.ui, web.admin, ai.llm, eco.i18n, eco.tenancy target key 0.12.0 → 0.13.0 (D1, values unchanged); no other row changes. PLAN-OF-PLANS §6 rows 0.12.0 and 0.13.0 restated from scorecard.sh --targets (0.12.0: E 80.6 #1, RT 84.4, red ≤ 7; 0.13.0: E 84.0, RT 86.2, red ≤ 3) and a §11 change-log row names D1, D2, D5 and this leaf.", + "kind": "manual" + }, + { + "id": "dw-09285d", + "text": "If the owner overturns D1 before this leaf merges, only the web.rpc change is made and the release spec scorecard block switches to stored_plan_if_D1_overturned.", + "kind": "manual" + }, + { + "id": "dw-d603fb", + "text": "Gate: `bash scripts/scorecard.sh --validate --self-test`; `bash scripts/scorecard.sh --targets 0.12.0` prints E 80.6 (#1), RT 84.4 (#1), <40 = 7; `bash scripts/scorecard.sh --targets 0.13.0` prints E 84.0; `bash .tmp/plan12/scope.sh` reproduces the release spec table", + "kind": "manual" + }, + { + "id": "dw-ad56e9", + "text": "Owns: `docs/adrs/0014-one-call-surface.md`, `docs/scorecard/scorecard.json` (targets only), `.maestro/specs/roadmap/PLAN-OF-PLANS.md` (§5 T12, §6, §11), the `scorecard:` block of `.maestro/specs/releases/0.12.0.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "a0-roadmap", + "lockedBy": "a0-roadmap", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-322722.json b/.maestro/tasks/contracts/c-322722.json new file mode 100644 index 000000000..6ddcb611b --- /dev/null +++ b/.maestro/tasks/contracts/c-322722.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-322722", + "taskId": "tsk-mvb0jm49-3ytgwg", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T13:38:34.732Z", + "lockedAt": "2026-10-16T13:38:34.732Z", + "intent": "0.8 E2.1c+r — cron and resilience", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-351d3d", + "text": "msg.cron 2/8 -> 6/8: cron-expression schedules (#1) with timezone- and DST-aware next-fire computation (#3, via the existing jiff-based scheduling/datetime.rs), overlap/missed-run policy skip|catch-up (#5), run monitoring hooks: success/failure callbacks and run history (#7), declarative central registration of named cron jobs (#8); built on Schedule/fibers (effect-native), tested with TestExecutor virtual time including a DST transition and a missed-run case.", + "kind": "manual" + }, + { + "id": "dw-29cfb2", + "text": "fault.resilience 6/8 level tested -> 7/8 level documented: fallback policy (#7) in id_effect_resilience, plus #8 if cheap (policies compose into one reusable pipeline with telemetry); book chapter section and a runnable example; integration test.", + "kind": "manual" + }, + { + "id": "dw-d41ec8", + "text": "Book: cron chapter or section and resilience section; examples runnable; clippy -D warnings, docs, tests green for touched crates.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-3254b7.json b/.maestro/tasks/contracts/c-3254b7.json new file mode 100644 index 000000000..07dba8166 --- /dev/null +++ b/.maestro/tasks/contracts/c-3254b7.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-3254b7", + "taskId": "tsk-mvbdsp4f-ua6x6x", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:39:31.238Z", + "lockedAt": "2026-10-16T20:39:31.238Z", + "intent": "E3.1a ripple compile fixes and typed-SQL book chapter", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-143b3f", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-d7b871", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-4cb182", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-cd469e", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-6b5f39", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-0bd153", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-3e7941", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-334ee9.json b/.maestro/tasks/contracts/c-334ee9.json new file mode 100644 index 000000000..603dac9ba --- /dev/null +++ b/.maestro/tasks/contracts/c-334ee9.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-334ee9", + "taskId": "tsk-mvbdsp4f-ex9rcm", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T19:49:47.039Z", + "lockedAt": "2026-10-16T19:49:47.039Z", + "intent": "E4.1 typed route descriptor and registry", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-2a74bb", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-1dd4b7", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-c629e9", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-0947d7", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-9c9bba", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-8d29b2", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-8477d7", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-3565d2.json b/.maestro/tasks/contracts/c-3565d2.json new file mode 100644 index 000000000..d13bf775f --- /dev/null +++ b/.maestro/tasks/contracts/c-3565d2.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-3565d2", + "taskId": "tsk-mveoxvjj-lwme2p", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:24:42.967Z", + "lockedAt": "2026-10-19T03:24:42.967Z", + "intent": "r11-sc-publish", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-366d55", + "text": "S3: crates.io trusted publishing over GitHub OIDC (rust-lang/crates-io-auth-action, SHA-pinned); `CARGO_REGISTRY_TOKEN` (publish.yml:113) removed from every workflow. Configuring the trusted publisher and deleting the secret are owner actions (0.11.0.md D15) recorded with dates in the leaf evidence.", + "kind": "manual" + }, + { + "id": "dw-2f0be0", + "text": "S5 (0.11.0.md D13): the build job runs `cargo package --locked` for every crate from `scripts/validate-crate-packaging.sh --print-order`; SLSA Build L3 provenance is produced by the slsa-framework/slsa-github-generator generic reusable workflow over those .crate files and the SBOMs (replacing the re-download at publish.yml:160-166 and the attestation at :169); `cargo publish` publishes the same tree.", + "kind": "manual" + }, + { + "id": "dw-79f44a", + "text": "Verify job after publish: for every crate, download it from static.crates.io, check its sha256 equals the attested subject digest and run slsa-verifier verify-artifact; the release fails on any mismatch (exit KPI \"a verified attestation per crate\", test `s5_attested_digest_matches_published_crate`).", + "kind": "manual" + }, + { + "id": "dw-393e5d", + "text": "S6: publish refuses an unsigned tag (`s6_release_requires_signed_tag`); top-level permissions read, job-level id-token/attestations only where needed; publishes lints/id_effect_lints (r11-e1-9-dylint) with its pinned nightly after the workspace crates; docs/RELEASING.md documents the flow and the owner steps.", + "kind": "manual" + }, + { + "id": "dw-cd402f", + "text": "Gate: `bash scripts/check-workflows.sh`; `! grep -rn CARGO_REGISTRY_TOKEN .github`; `actionlint .github/workflows/publish.yml`; a workflow_dispatch run with dry_run=true recorded (run URL in the evidence)", + "kind": "manual" + }, + { + "id": "dw-1589fc", + "text": "Owns: `.github/workflows/publish.yml`, `.github/scripts/publish-crate.sh`, `docs/RELEASING.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sc-publish", + "lockedBy": "r11-sc-publish", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-3628a6.json b/.maestro/tasks/contracts/c-3628a6.json new file mode 100644 index 000000000..0086ebc1f --- /dev/null +++ b/.maestro/tasks/contracts/c-3628a6.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-3628a6", + "taskId": "tsk-mvbdsp4f-p6l6nv", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T19:49:47.164Z", + "lockedAt": "2026-10-16T19:49:47.164Z", + "intent": "E5.4 config profiles", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-8ee7ba", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-608073", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-ac4057", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-846105", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-472ad6", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-61f658", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-0c5a9a", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-380a8e.json b/.maestro/tasks/contracts/c-380a8e.json new file mode 100644 index 000000000..a395561be --- /dev/null +++ b/.maestro/tasks/contracts/c-380a8e.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-380a8e", + "taskId": "tsk-mvcpexet-7d5r2f", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:14:19.227Z", + "lockedAt": "2026-10-17T18:14:19.227Z", + "intent": "r10-e7-1-cron", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3da639", + "text": "`CronRegistry` entries enqueue a job per tick with `unique_key = name@tick`, so N schedulers enqueue once; leader election by `ClusterSingleton` over `PgAdvisoryLease`. Tests `cron_tick_enqueues_once_across_two_schedulers`, `lease_loss_hands_over_schedule`.", + "kind": "manual" + }, + { + "id": "dw-d5d927", + "text": "Gate: ` -p id_effect_jobs --features cron`", + "kind": "manual" + }, + { + "id": "dw-891930", + "text": "Owns: `crates/id_effect_jobs/src/cron.rs` (feature `cron`, dep `id_effect_node/pg`)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-3bc0d9.json b/.maestro/tasks/contracts/c-3bc0d9.json new file mode 100644 index 000000000..63ff532cf --- /dev/null +++ b/.maestro/tasks/contracts/c-3bc0d9.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-3bc0d9", + "taskId": "tsk-mvbh3y45-3zte0e", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T22:13:17.969Z", + "lockedAt": "2026-10-16T22:13:17.969Z", + "intent": "r09-e3-3-repository", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-7aa410", + "text": "`#[derive(Entity)]` (table/columns, #1) and `Repository` CRUD; `#[entity(version)]` column → update fails with `SqlError::StaleVersion` (#5); `#[entity(scope = …)]` global filters (soft delete, tenant/owner) applied to every generated query (#8). Tests `stale_version_is_rejected`, `global_filter_hides_soft_deleted`, `tenant_filter_scopes_every_query`.", + "kind": "manual" + }, + { + "id": "dw-744785", + "text": "Gate: pg + sqlite nextest", + "kind": "manual" + }, + { + "id": "dw-67e0a3", + "text": "Owns: `crates/id_effect_proc_macro/src/repository.rs` + derive line, `crates/id_effect_sql/src/repository.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-3d5ca3.json b/.maestro/tasks/contracts/c-3d5ca3.json new file mode 100644 index 000000000..26476f57c --- /dev/null +++ b/.maestro/tasks/contracts/c-3d5ca3.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-3d5ca3", + "taskId": "tsk-mvf9rlqz-0bc6j8", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T13:07:50.801Z", + "lockedAt": "2026-10-19T13:07:50.801Z", + "intent": "r12-e12-2-subscriptions", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-d834ef", + "text": "A Subscription procedure can be backed by an id_effect_events EventLog (crates/id_effect_events/src/subscription.rs:39 read_all(after, limit)): the cursor is the global position, the stream replays from the cursor and then tails live events without a gap at the hand-over; an optional projection filter narrows the stream.", + "kind": "manual" + }, + { + "id": "dw-398adb", + "text": "Interrupting the subscriber releases the reader (no leaked polling fiber); the read batch size and the poll interval are bounded config.", + "kind": "manual" + }, + { + "id": "dw-1a90dd", + "text": "Tests `subscription_replays_from_cursor_then_tails`, `subscription_resume_has_no_gap_or_duplicate`, `subscription_interrupt_releases_reader` (memory EventLog), and the same three against Postgres under ID_EFFECT_REQUIRE_PG=1 (own container, never the ambient DATABASE_URL); book part6/ch33-08-subscriptions.md.", + "kind": "manual" + }, + { + "id": "dw-da26cc", + "text": "Gate: `cargo nextest run -p id_effect_rpc --features events`; `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p id_effect_rpc --features events --test subscription_events`", + "kind": "manual" + }, + { + "id": "dw-34d66a", + "text": "Owns: `crates/id_effect_rpc/src/subscription/{mod.rs,events.rs}`, `crates/id_effect_rpc/tests/subscription_events.rs`, `crates/id_effect/book/src/part6/ch33-08-subscriptions.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "d5-subscriptions", + "lockedBy": "d5-subscriptions", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-3e3e19.json b/.maestro/tasks/contracts/c-3e3e19.json new file mode 100644 index 000000000..62d9415b0 --- /dev/null +++ b/.maestro/tasks/contracts/c-3e3e19.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-3e3e19", + "taskId": "tsk-mvcjv3bz-jrd70e", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T17:57:38.522Z", + "lockedAt": "2026-10-17T17:57:38.522Z", + "intent": "r10-e4-4-container-docs", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-1087b2", + "text": "S3-compatible container pinned per D11; the conformance tests run against it; chapter + example.", + "kind": "manual" + }, + { + "id": "dw-bfff75", + "text": "Gate: ` -p id_effect_storage -p id_effect_test --all-features`; `bash scripts/tier-conformance.sh id_effect_storage`", + "kind": "manual" + }, + { + "id": "dw-cbfa8a", + "text": "Owns: `crates/id_effect_test/src/containers/s3.rs` (+ one `mod` line), `crates/id_effect_storage/tests/s3.rs`, `book/src/part6/ch28-05-storage.md`, `crates/id_effect_storage/examples/010_storage.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-3e4a1c.json b/.maestro/tasks/contracts/c-3e4a1c.json new file mode 100644 index 000000000..ceb7554e7 --- /dev/null +++ b/.maestro/tasks/contracts/c-3e4a1c.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 2, + "id": "c-3e4a1c", + "taskId": "tsk-mveutmw9-l3uhuv", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T06:09:27.526Z", + "lockedAt": "2026-10-19T06:09:27.526Z", + "intent": "r11-e11-4-compile-fail", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-eab9ea", + "text": "tests/security_compile_fail (unpublished, created by r11-t0-skeletons) runs trybuild over at least 15 cases, each file named after its rule or finding and listed in docs/security/compile-fail.md with its ADR 0013 rule: h8_route_without_access_decision, h8_undecided_endpoint_into_router, h8_guard_needs_route_guard_type, h8_public_and_guard_together, m16_query_with_string, m16_execute_with_string, m16_query_raw_without_capability, m16_raw_sql_capability_has_no_public_constructor, m13_secret_of_non_zeroize_type, l3_generic_secret_equality_removed, secret_is_not_serialize, r8_plaintext_tcp_without_feature, r10_accepted_risk_without_reason, m19_cross_tenant_without_opt_out, deny_deprecated_redaction_policy_new, deny_deprecated_route_unchecked (spares beyond 15 allowed).", + "kind": "manual" + }, + { + "id": "dw-6d3029", + "text": "Every `.stderr` snapshot is checked in against the toolchain pinned in rust-toolchain.toml; cases that the API answers with a `help:` note keep it in the snapshot.", + "kind": "manual" + }, + { + "id": "dw-bdb28f", + "text": "Gate: `cargo nextest run -p security_compile_fail`; `ls tests/security_compile_fail/ui/*.rs | wc -l` ≥ 15", + "kind": "manual" + }, + { + "id": "dw-acae7b", + "text": "Owns: `tests/security_compile_fail/**`, `docs/security/compile-fail.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e11-4-compile-fail", + "lockedBy": "r11-e11-4-compile-fail", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-3f05f7.json b/.maestro/tasks/contracts/c-3f05f7.json new file mode 100644 index 000000000..4593ae8cd --- /dev/null +++ b/.maestro/tasks/contracts/c-3f05f7.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-3f05f7", + "taskId": "tsk-mvfl8agk-vm3y4j", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T18:28:59.801Z", + "lockedAt": "2026-10-19T18:28:59.801Z", + "intent": "r12-e12-5-fuzz-security", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-de9f26", + "text": "At least 5 new cargo-fuzz targets (planned 6) with seed corpora: rpc_wire_request (JSON envelope), rpc_batch (wire::decode_batch), rpc_postcard_frame (codec::postcard::decode_frame), rpc_duplex_frame (transport::frame::decode, shared by WebSocket and WebTransport), rpc_resume_cursor (resume::decode_cursor), sse_last_event_id; each builds on the pinned nightly and runs 5 minutes in ClusterFuzzLite PR mode with no crash; the nightly batch includes them.", + "kind": "manual" + }, + { + "id": "dw-acb556", + "text": "Compile-fail security cases in tests/security_compile_fail: r1_rpc_procedure_without_access_decision (a #[rpc::service] method without #[public] or #[guard]) and r10_zero_rtt_on_mutation (enabling 0-RTT on a mutation does not compile).", + "kind": "manual" + }, + { + "id": "dw-b085d5", + "text": "crates/id_effect_rpc/tests/security_surface.rs asserts that every test id in the release spec Security table exists in the workspace (fails on a missing id) and runs the cross-crate cases (Origin, CSRF header, batch limit, stream cap) against one server.", + "kind": "manual" + }, + { + "id": "dw-39d224", + "text": "THREAT-MODEL.md gains a call-surface section (assets, entry points: /rpc, WebSocket, SSE, HTTP/3, WebTransport, mesh RPC; threats and the test that covers each); docs/security/fuzzing.md lists the new targets.", + "kind": "manual" + }, + { + "id": "dw-d61977", + "text": "Gate: `cd fuzz && cargo +nightly- fuzz build`; `cargo nextest run -p security_compile_fail`; `cargo nextest run -p id_effect_rpc --all-features --test security_surface`", + "kind": "manual" + }, + { + "id": "dw-5bdd09", + "text": "Owns: `fuzz/**`, `tests/security_compile_fail/**`, `crates/id_effect_rpc/tests/security_surface.rs`, `THREAT-MODEL.md`, `docs/security/fuzzing.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "g3-fuzz-security", + "lockedBy": "g3-fuzz-security", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-3f3d66.json b/.maestro/tasks/contracts/c-3f3d66.json new file mode 100644 index 000000000..fc9cb5fda --- /dev/null +++ b/.maestro/tasks/contracts/c-3f3d66.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-3f3d66", + "taskId": "tsk-mvcpeyb9-t3b4bu", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:18:37.969Z", + "lockedAt": "2026-10-17T18:18:37.969Z", + "intent": "r10-e6-2-dashboard", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-beea03", + "text": "Static page (`include_str!`, no JS toolchain, L12) polling the JSON API: fibers, supervisor tree, metrics, health, log-level form (#3). Integration test over a socket fetches `/_console` and every API route with and without the guard. Chapter + example.", + "kind": "manual" + }, + { + "id": "dw-aa7dc9", + "text": "Gate: same + `cargo test --doc -p book_snippets`; `bash scripts/tier-conformance.sh id_effect_console`", + "kind": "manual" + }, + { + "id": "dw-3b6c2d", + "text": "Owns: `crates/id_effect_console/src/ui/{index.html,mod.rs}`, `book/src/part6/ch27-03-console.md`, `crates/id_effect_console/examples/010_console.rs`, `crates/id_effect_console/tests/console.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-413616.json b/.maestro/tasks/contracts/c-413616.json new file mode 100644 index 000000000..7617f0f51 --- /dev/null +++ b/.maestro/tasks/contracts/c-413616.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-413616", + "taskId": "tsk-mvbdsp4f-bcinb5", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T19:53:30.031Z", + "lockedAt": "2026-10-16T19:53:30.031Z", + "intent": "E5.3 id_effect_authz policies as capabilities", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-7cb720", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-b450f3", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-443c04", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-c7bc53", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-ded337", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-a903d2", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-f7592f", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-436b66.json b/.maestro/tasks/contracts/c-436b66.json new file mode 100644 index 000000000..7dc93fca4 --- /dev/null +++ b/.maestro/tasks/contracts/c-436b66.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-436b66", + "taskId": "tsk-mvcjv3m4-28cr81", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:28:18.695Z", + "lockedAt": "2026-10-17T15:28:18.695Z", + "intent": "r10-e6-4-redaction-sampling", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-abbeeb", + "text": "`RedactionPolicy` (field names, globs, `Secret` values) applied once in `emit_record` before fan-out (#7); `SamplingBackend` (ratio per level + token-bucket rate limit, counts dropped lines) (#8); `LogRecord` gains `target` and becomes `#[non_exhaustive]` (MIGRATION-0.10). First integration tests `redacted_field_never_reaches_any_backend`, `rate_limit_drops_and_counts`.", + "kind": "manual" + }, + { + "id": "dw-a31e39", + "text": "Gate: `cargo nextest run -p id_effect_logger --all-features`", + "kind": "manual" + }, + { + "id": "dw-6e847b", + "text": "Owns: `crates/id_effect_logger/src/{lib.rs,pipeline.rs,redact.rs,sample.rs}`, `crates/id_effect_logger/tests/pipeline.rs`, `book/src/part6/ch27-00-observability.md` (section)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-44b421.json b/.maestro/tasks/contracts/c-44b421.json new file mode 100644 index 000000000..a4917fa94 --- /dev/null +++ b/.maestro/tasks/contracts/c-44b421.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 2, + "id": "c-44b421", + "taskId": "tsk-mvey8ngt-tghgvt", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T07:45:03.560Z", + "lockedAt": "2026-10-19T07:45:03.560Z", + "intent": "r11-t0-migration-guide", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-ded175", + "text": "docs/MIGRATION-0.11.md has one section per break reported by `cargo semver-checks` against 0.10.1, each with its shim or replacement (L9): typed routes need .public()/.guard() (shim Router::route_unchecked); query/execute take Sql (escape hatch query_raw + RawSqlCapability); id_effect_process Next/Info/SendError become #[non_exhaustive] with new variants; RedactionPolicy::new → RedactionPolicy::empty; Secret requires Zeroize and compares in constant time; id_effect_fp::stm → id_effect::stm; HttpClient SSRF guard on and redirects off by default; plaintext TCP behind feature insecure-plaintext-tcp; masked CSRF token format; mesh protocol version bump (older nodes refused).", + "kind": "manual" + }, + { + "id": "dw-112876", + "text": "CHANGELOG 0.11.0 has a Scorecard section (index deltas from REPORT-0.11.0.md) and a Security section (findings closed with their regression tests, and every new dangerous_*/insecure_* opt-out).", + "kind": "manual" + }, + { + "id": "dw-ac1d7a", + "text": "Gate: `cargo semver-checks` output is a subset of the MIGRATION-0.11 list (the comparison is attached to the leaf evidence)", + "kind": "manual" + }, + { + "id": "dw-c7dd7c", + "text": "Owns: `docs/MIGRATION-0.11.md`, `CHANGELOG.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-t0-migration-guide", + "lockedBy": "r11-t0-migration-guide", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/tasks/contracts/c-4537bf.json b/.maestro/tasks/contracts/c-4537bf.json new file mode 100644 index 000000000..18be3f115 --- /dev/null +++ b/.maestro/tasks/contracts/c-4537bf.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-4537bf", + "taskId": "tsk-mvfamuiv-lyz0jg", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T13:32:02.114Z", + "lockedAt": "2026-10-19T13:32:02.114Z", + "intent": "r12-e12-2-channels", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-eaef62", + "text": "web.realtime#3: id_effect_rpc::channels topics with join and leave, mapped to mesh process groups (ProcessGroups / OrSwot, crates/id_effect_node/src/groups.rs:30,126) by one hub process per node; subscribers are RPC subscriptions (WebSocket, SSE, later WebTransport).", + "kind": "manual" + }, + { + "id": "dw-54cf86", + "text": "web.realtime#5: a broadcast on any node reaches the subscribers on every node; delivery order per publisher is preserved; a node leaving removes its members.", + "kind": "manual" + }, + { + "id": "dw-a3bdd0", + "text": "web.realtime#4: presence with per-connection metadata, join/leave diffs pushed to subscribers, convergence after a node goes down (monitor-driven cleanup).", + "kind": "manual" + }, + { + "id": "dw-a51e2d", + "text": "web.realtime#6 (join half) and ADR 0013 R1: joining a topic goes through a JoinPolicy; the default policy denies; an adapter runs an id_effect_authz policy without making id_effect_rpc depend on axum-only authz items.", + "kind": "manual" + }, + { + "id": "dw-4db6e3", + "text": "Tests `three_node_broadcast_under_test_clock` (in-memory mesh transport), `presence_converges_after_node_down`, `channel_join_denied_by_default_policy`, `leave_stops_delivery`, `publisher_order_preserved_per_topic`; example channels_chat.rs; book part6/ch33-09-channels-presence.md. No id_effect_channels crate (L15).", + "kind": "manual" + }, + { + "id": "dw-590e14", + "text": "Gate: `cargo nextest run -p id_effect_rpc --features channels`; `cargo run -p id_effect_rpc --features channels --example channels_chat -- --self-test`", + "kind": "manual" + }, + { + "id": "dw-cf91bb", + "text": "Owns: `crates/id_effect_rpc/src/channels/**`, `crates/id_effect_rpc/tests/{channels_three_nodes.rs,presence.rs}`, `crates/id_effect_rpc/examples/channels_chat.rs`, `crates/id_effect/book/src/part6/ch33-09-channels-presence.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "e1-channels", + "lockedBy": "e1-channels", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-46323a.json b/.maestro/tasks/contracts/c-46323a.json new file mode 100644 index 000000000..0aaebc6d9 --- /dev/null +++ b/.maestro/tasks/contracts/c-46323a.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-46323a", + "taskId": "tsk-mvfusz4r-iiqduh", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T22:56:46.468Z", + "lockedAt": "2026-10-19T22:56:46.468Z", + "intent": "r12-w8-level-audit", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-da0f37", + "text": "For web.rpc (#1–#8), web.realtime (#1–#8) and dist.tls (#1–#8): every planned check id maps to an existing path at HEAD, a named test that passes, a book chapter and a runnable example (METHOD §7.2); level production is evidenced by the reference-relay and reference-ledger CI jobs; the evidence map is attached to the leaf evidence.", + "kind": "manual" + }, + { + "id": "dw-a04f10", + "text": "dist.tls is re-checked after the mesh moved onto the shared endpoint (r12-e12-3-quic-endpoint): #2 #3 #4 #5 #7 still met on both endpoint shapes.", + "kind": "manual" + }, + { + "id": "dw-c15637", + "text": "Every gap becomes a Maestro task before exit; no file is edited by this leaf.", + "kind": "manual" + }, + { + "id": "dw-e78c0a", + "text": "Gate: `jq -r '.rows[] | select(.id==\"web.rpc\" or .id==\"web.realtime\" or .id==\"dist.tls\") | .checks[].id' docs/scorecard/scorecard.json` → each id present in the evidence map with a path that exists (`test -e`) and a test that passes (`cargo nextest run -E 'test()'`)", + "kind": "manual" + }, + { + "id": "dw-9e2eba", + "text": "Owns: none (read-only)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "j1-level-audit", + "lockedBy": "j1-level-audit", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/tasks/contracts/c-46ad51.json b/.maestro/tasks/contracts/c-46ad51.json new file mode 100644 index 000000000..d82780fd6 --- /dev/null +++ b/.maestro/tasks/contracts/c-46ad51.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 2, + "id": "c-46ad51", + "taskId": "tsk-mvawkapy-lzvg52", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T18:57:30.578Z", + "lockedAt": "2026-10-16T18:57:30.578Z", + "intent": "0.8 E0.1 relicense to MIT", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-62e32f", + "text": "Precondition recorded as evidence before any change: consent from every non-owner copyright holder (Justin Carter, commit 8eb9b05 / #79) or those lines rewritten; owner confirms IDClear has no claim on the 4 commits made with the idclear.com address (#67, #71, #72, #73).", + "kind": "manual" + }, + { + "id": "dw-dd6be3", + "text": "LICENSE at repo root with the MIT text (head -1 = MIT License); license = \"MIT\" in Cargo.toml:32 [workspace.package], labs/Cargo.toml:23 and labs/id_effect_lint/Cargo.toml:7; cargo metadata shows license MIT for every publishable package in both workspaces.", + "kind": "manual" + }, + { + "id": "dw-935102", + "text": "README badge (README.md:19) and License footer (README.md:244-246) and labs/id_effect_lint/README.md:31-33 updated; git grep CC-BY-SA outside docs/scorecard, .maestro and CHANGELOG returns nothing; CHANGELOG notes that versions <= 0.7.0 remain CC-BY-SA-4.0.", + "kind": "manual" + }, + { + "id": "dw-64c6cc", + "text": "CONTRIBUTING.md exists with a DCO (Signed-off-by) requirement; cargo package -p id_effect --list succeeds.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvawbp5i-g5m6ot", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-48c6ea.json b/.maestro/tasks/contracts/c-48c6ea.json new file mode 100644 index 000000000..f572e2b1a --- /dev/null +++ b/.maestro/tasks/contracts/c-48c6ea.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-48c6ea", + "taskId": "tsk-mvbbmmz0-vebdrd", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T18:48:42.656Z", + "lockedAt": "2026-10-16T18:48:42.656Z", + "intent": "0.8 E0.1a — remove the labs obix outbox adapter", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3a5918", + "text": "Owner decision 2026-10-16: instead of seeking consent for commit 8eb9b05 (#79, Justin Carter), remove the code that still carries it. labs/id_effect_jobs/src/obix_outbox.rs (ObixOutbox, JobsOutboxEvent) is deleted with its lib.rs mod/re-exports, its tests in tests/pg_integration.rs, and its use in examples (010/020); the obix feature keeps ObixInbox only (or is reshaped if the inbox depends on removed items); README, moon.yml and the book (part6 ch31) no longer mention the obix outbox adapter; the 0.10 jobs battery (PLAN-OF-PLANS E7) is noted as the place an outbox relay returns.", + "kind": "manual" + }, + { + "id": "dw-7bda45", + "text": "git blame -C -C -M over every tracked file in the working tree attributes 0 lines to Justin Carter (scripted check over labs/ and crates/id_effect/book/).", + "kind": "manual" + }, + { + "id": "dw-a987b0", + "text": "labs: cargo check --manifest-path labs/Cargo.toml --workspace --all-targets and with --features obix for id_effect_jobs; cargo test for id_effect_jobs (non-pg tests); clippy -D warnings for id_effect_jobs; book still builds (moon run :book).", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-4930bc.json b/.maestro/tasks/contracts/c-4930bc.json new file mode 100644 index 000000000..b49496084 --- /dev/null +++ b/.maestro/tasks/contracts/c-4930bc.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-4930bc", + "taskId": "tsk-mvcpexut-0na9la", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:02:39.004Z", + "lockedAt": "2026-10-17T18:02:39.004Z", + "intent": "r10-e7-3-transports", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-44f265", + "text": "`Mailer` capability; `SmtpMailer` (lettre per D9, pooled, rustls) and `SendGridMailer` over `HttpClient` (#1); attachments and inline `cid:` images (#8); `TestMailbox` with `assert_sent_to/subject/contains` (#4). Tests: wiremock SendGrid request shape and error mapping; MIME structure of attachments/inline; `cargo deny check` green.", + "kind": "manual" + }, + { + "id": "dw-d44b73", + "text": "Gate: `cargo nextest run -p id_effect_mail --all-features`; `cargo deny check`", + "kind": "manual" + }, + { + "id": "dw-a8b5bb", + "text": "Owns: `crates/id_effect_mail/src/{message.rs,mailer.rs,smtp.rs,sendgrid.rs,testing.rs}`, `crates/id_effect_mail/Cargo.toml`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-4a73f5.json b/.maestro/tasks/contracts/c-4a73f5.json new file mode 100644 index 000000000..b8f08fe9e --- /dev/null +++ b/.maestro/tasks/contracts/c-4a73f5.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-4a73f5", + "taskId": "tsk-mvbdsp4f-souo6g", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:03:16.658Z", + "lockedAt": "2026-10-16T20:03:16.658Z", + "intent": "E5.4 Vault secret provider (feature vault)", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f6be43", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-11728d", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-9765a6", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-fc0f8f", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-441f75", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-bf1528", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-a01e61", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-4d02a6.json b/.maestro/tasks/contracts/c-4d02a6.json new file mode 100644 index 000000000..825717b53 --- /dev/null +++ b/.maestro/tasks/contracts/c-4d02a6.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 2, + "id": "c-4d02a6", + "taskId": "tsk-mvf4xez4-00j1za", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T10:52:16.238Z", + "lockedAt": "2026-10-19T10:52:16.238Z", + "intent": "r12-e12-1-promote", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-767d3c", + "text": "crates/id_effect_rpc passes tier integration: publish = false removed, [package.metadata.id_effect] tier = \"integration\", #![deny(missing_docs)], README with a runnable rust fence, coverage at the QUALITY_BAR floor, test density ≥ 1 test per 80 lines; labs wording removed from book part2/ch07-12-rpc-boundaries.md and part6/ch29-00-api-boundaries.md; new chapter part6/ch33-00-one-call-surface.md (overview, links to ch33-01..12).", + "kind": "manual" + }, + { + "id": "dw-2459ee", + "text": "Cargo.toml declares, once, every 0.12 feature and optional dependency later leaves need (client, postcard, ws, sse, events, mesh, channels, http3, webtransport, test-util; id_effect_rpc_macros, postcard, id_effect_events, id_effect_node, id_effect_axum features ws/http3/webtransport, the tungstenite major that axum ws uses), so no later leaf edits Cargo.toml.", + "kind": "manual" + }, + { + "id": "dw-3e64f5", + "text": "Module layout: contract (serde model of a service contract: services, versions, procedures, kinds, input/output/error JSON Schemas, access decision; D11), codec/{mod,json}.rs (Codec trait + JSON), server/{mod,dispatch,http}.rs, client/{mod,http}.rs, wire (envelope types). src/codegen.rs (trait stub strings) is removed. lib.rs becomes an append-only shared file for later leaves (one pub mod / pub use line each).", + "kind": "manual" + }, + { + "id": "dw-5fe507", + "text": "H8: the /rpc mount is a declared typed route; the low-level RpcGroup requires .public() or .guard(g) per procedure (typestate as id_effect_axum routes) and an unknown tag answers 404 Problem.", + "kind": "manual" + }, + { + "id": "dw-7ac803", + "text": "Design note .maestro/specs/releases/0.12/r12-rpc-design.md: contract model, the six kinds and their HTTP mapping, the WebSocket/WebTransport frame format (call id, kind, flags, cursor), the cursor format, the batch body, the error union encoding, codec negotiation names. It is reviewed (owner or coordinator) before r12-e12-1-kinds starts; later leaves follow it.", + "kind": "manual" + }, + { + "id": "dw-75f963", + "text": "Tests `rpc_unregistered_tag_is_404_problem`, `rpc_group_requires_access_decision`, `contract_json_round_trips`, plus the moved tests still green.", + "kind": "manual" + }, + { + "id": "dw-4bb9b2", + "text": "Gate: `bash scripts/tier-conformance.sh id_effect_rpc`; `cargo nextest run -p id_effect_rpc --all-features`; `cargo test -p id_effect_rpc --doc`; `cargo clippy -p id_effect_rpc --all-targets --all-features -- -D warnings`", + "kind": "manual" + }, + { + "id": "dw-c57486", + "text": "Owns: `crates/id_effect_rpc/**` (all files in wave 1), `.maestro/specs/releases/0.12/r12-rpc-design.md`, `crates/id_effect/book/src/part2/ch07-12-rpc-boundaries.md`, `crates/id_effect/book/src/part6/ch29-00-api-boundaries.md`, `crates/id_effect/book/src/part6/ch33-00-one-call-surface.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "b1-rpc", + "lockedBy": "b1-rpc", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-4efb50.json b/.maestro/tasks/contracts/c-4efb50.json new file mode 100644 index 000000000..9b9a7539c --- /dev/null +++ b/.maestro/tasks/contracts/c-4efb50.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-4efb50", + "taskId": "tsk-mvcpexhy-k9wn2c", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:33:47.449Z", + "lockedAt": "2026-10-17T18:33:47.449Z", + "intent": "r10-e7-1-docs-labs", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-88112a", + "text": "Chapter + example + integration test cover #2–#6 #8; labs crate removed (no apalis/obix/job/rdkafka anywhere in `cargo metadata` of the root and labs workspaces); `grep -rniw stub crates/id_effect_jobs crates/id_effect_messaging` finds nothing; the labs CI job runs without `--exclude`. **Blocked by `r10-e7-2-outbox-inbox`** (msg.brokers#8 must exist before ObixInbox goes).", + "kind": "manual" + }, + { + "id": "dw-2faa34", + "text": "Gate: `cargo test --doc -p book_snippets`; `bash scripts/tier-conformance.sh id_effect_jobs`; labs CI job", + "kind": "manual" + }, + { + "id": "dw-170057", + "text": "Owns: `book/src/part6/ch31-00-async-messaging.md` (de-labs), `book/src/part6/ch31-01-jobs.md`, `SUMMARY.md` line, `crates/id_effect_jobs/examples/010_jobs.rs`, `crates/id_effect_jobs/tests/pg_queue.rs`, `labs/id_effect_jobs/**` (removed), `labs/Cargo.toml`, `.github/workflows/ci.yml` labs job (`:301-306`)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-521413.json b/.maestro/tasks/contracts/c-521413.json index 7a9404bcd..4188c4ad2 100644 --- a/.maestro/tasks/contracts/c-521413.json +++ b/.maestro/tasks/contracts/c-521413.json @@ -1,69 +1,67 @@ { - "schemaVersion": 2, - "id": "c-521413", - "taskId": "tsk-mv9xeqip-pixe4f", - "repoRoot": ".", - "status": "locked", - "createdAt": "2026-10-15T21:35:19.936Z", - "lockedAt": "2026-10-15T21:35:19.936Z", - "intent": "W1: Method v2 table-stakes checklists for all 82 rows; --method 2; v1 vs v2 report", - "scope": { - "filesExpected": [ - "**/*" - ], - "filesForbidden": [] - }, - "doneWhen": [ - { - "id": "dw-3229f5", - "text": "docs/adrs/0012-platform-vision.md (status Accepted) supersedes the ADR 0010 vision statement, adds the \"platform\" tier (integration gates + reference-app consumer), and amends Rule 4 so CI-tested reference apps count as consumers; ADR 0010 gets a \"Superseded in part by 0012\" header line; README scope paragraph (lines 3-5) no longer says \"not a batteries-included web framework\".", - "kind": "manual" - }, - { - "id": "dw-0c1f44", - "text": "docs/scorecard/ contains METHOD.md, scorecard.json (moved from .maestro/specs/roadmap/scorecard/), results/v0.6.0.json and results/v0.7.0.json; scripts/scorecard.sh (bash + jq, Requires header, exit 1 FAIL / 2 misuse) implements METHOD §5 and §5.1; `scripts/scorecard.sh --self-test` reproduces every number in METHOD §5.2 exactly (one-decimal string compare).", - "kind": "manual" - }, - { - "id": "dw-bd8cf8", - "text": "scorecard.sh subcommands work - `--version ` (indices + KPIs), `--targets ` (planned indices), `--diff ` (row deltas, exit 1 on any row ≤ −5 without `adr` note), `--compare-targets ` (exit 1 if E < target − 1.0), `--report ` (writes REPORT-.md), `--html` (writes docs/scorecard/index.html from the JSON).", - "kind": "manual" - }, - { - "id": "dw-75ab58", - "text": "scorecard.json validates against docs/scorecard/scorecard.schema.json (row ids unique, 10 competitor scores, scores in 1..100, every group/track referenced exists); invalid fixtures under docs/scorecard/fixtures/ make the validator exit 1.", - "kind": "manual" - }, - { - "id": "dw-11bc67", - "text": "moon task `scorecard` and a CI step in ci.yml run `scorecard.sh --self-test` and schema validation on every PR.", - "kind": "manual" - }, - { - "id": "dw-70419c", - "text": ".maestro/specs/roadmap/PLAN-OF-PLANS.md links to docs/scorecard/ paths (no stale .maestro scorecard paths remain - grep returns 0).", - "kind": "manual" - }, - { - "id": "dw-fdf6b1", - "text": "Stale task tsk-mv799sew-y9wf9g (W5, migrate downstream to 0.6) is abandoned with reason \"superseded by PLAN-OF-PLANS E0.5 (downstream migration to 0.7+)\"; mission pln-mv799j4f-1r1u6b is closed.", - "kind": "manual" - }, - { - "id": "dw-1de7f0", - "text": "Method v2 table-stakes checklists exist for all 82 rows in scorecard.json (a checks list with 4-8 entries each); scorecard.sh --method 2 recomputes 0.6.0 and 0.7.0; REPORT-method-v2.md shows v1 vs v2 side by side for both versions.", - "kind": "manual" - } - ], - "amendments": [], - "createdBy": "claude-opus", - "lockedBy": "claude-opus", - "configSnapshot": { - "strict": false, - "overlapPolicy": "annotate", - "rebaseFallback": "best-effort", - "staleReclaimContractPolicy": "inherit" - }, - "missionId": "pln-mv9xenpd-d2lpkn", - "riskClass": "high" + "schemaVersion": 2, + "id": "c-521413", + "taskId": "tsk-mv9xeqip-pixe4f", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-15T21:35:19.936Z", + "lockedAt": "2026-10-15T21:35:19.936Z", + "intent": "W1: Method v2 table-stakes checklists for all 82 rows; --method 2; v1 vs v2 report", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3229f5", + "text": "docs/adrs/0012-platform-vision.md (status Accepted) supersedes the ADR 0010 vision statement, adds the \"platform\" tier (integration gates + reference-app consumer), and amends Rule 4 so CI-tested reference apps count as consumers; ADR 0010 gets a \"Superseded in part by 0012\" header line; README scope paragraph (lines 3-5) no longer says \"not a batteries-included web framework\".", + "kind": "manual" + }, + { + "id": "dw-0c1f44", + "text": "docs/scorecard/ contains METHOD.md, scorecard.json (moved from .maestro/specs/roadmap/scorecard/), results/v0.6.0.json and results/v0.7.0.json; scripts/scorecard.sh (bash + jq, Requires header, exit 1 FAIL / 2 misuse) implements METHOD §5 and §5.1; `scripts/scorecard.sh --self-test` reproduces every number in METHOD §5.2 exactly (one-decimal string compare).", + "kind": "manual" + }, + { + "id": "dw-bd8cf8", + "text": "scorecard.sh subcommands work - `--version ` (indices + KPIs), `--targets ` (planned indices), `--diff ` (row deltas, exit 1 on any row ≤ −5 without `adr` note), `--compare-targets ` (exit 1 if E < target − 1.0), `--report ` (writes REPORT-.md), `--html` (writes docs/scorecard/index.html from the JSON).", + "kind": "manual" + }, + { + "id": "dw-75ab58", + "text": "scorecard.json validates against docs/scorecard/scorecard.schema.json (row ids unique, 10 competitor scores, scores in 1..100, every group/track referenced exists); invalid fixtures under docs/scorecard/fixtures/ make the validator exit 1.", + "kind": "manual" + }, + { + "id": "dw-11bc67", + "text": "moon task `scorecard` and a CI step in ci.yml run `scorecard.sh --self-test` and schema validation on every PR.", + "kind": "manual" + }, + { + "id": "dw-70419c", + "text": ".maestro/specs/roadmap/PLAN-OF-PLANS.md links to docs/scorecard/ paths (no stale .maestro scorecard paths remain - grep returns 0).", + "kind": "manual" + }, + { + "id": "dw-fdf6b1", + "text": "Stale task tsk-mv799sew-y9wf9g (W5, migrate downstream to 0.6) is abandoned with reason \"superseded by PLAN-OF-PLANS E0.5 (downstream migration to 0.7+)\"; mission pln-mv799j4f-1r1u6b is closed.", + "kind": "manual" + }, + { + "id": "dw-1de7f0", + "text": "Method v2 table-stakes checklists exist for all 82 rows in scorecard.json (a checks list with 4-8 entries each); scorecard.sh --method 2 recomputes 0.6.0 and 0.7.0; REPORT-method-v2.md shows v1 vs v2 side by side for both versions.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "claude-opus", + "lockedBy": "claude-opus", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mv9xenpd-d2lpkn", + "riskClass": "high" } diff --git a/.maestro/tasks/contracts/c-53f8c8.json b/.maestro/tasks/contracts/c-53f8c8.json new file mode 100644 index 000000000..b3dbf2225 --- /dev/null +++ b/.maestro/tasks/contracts/c-53f8c8.json @@ -0,0 +1,56 @@ +{ + "schemaVersion": 2, + "id": "c-53f8c8", + "taskId": "tsk-mvaoq06i-s0i7vr", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T08:07:30.864Z", + "lockedAt": "2026-10-16T08:07:30.864Z", + "intent": "Scorecard method re-base to 2.0", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f3e7b8", + "text": "Step 1 (alignment): every competitor v2 level/met cell is re-judged with one rule: a check met only through a third-party library counts at production level only if the framework's own docs recommend that library, otherwise the row is scored at labs (or without that check, whichever scores higher). The per-check classification is committed as docs/scorecard/alignment-2.0.json.", + "kind": "manual" + }, + { + "id": "dw-77c1a9", + "text": "Step 2 (method 1.1): every id_effect v1 score above the cap of its v2 level is lowered to that cap; the v1 numbers are kept per row under rows[].v1 {competitors, id_effect, targets} and remain reproducible with scripts/scorecard.sh --method 1.", + "kind": "manual" + }, + { + "id": "dw-a364a5", + "text": "Step 3 (method 2.0): method_version is \"2.0\"; rows[].competitors and rows[].id_effect hold the checklist scores and --validate fails when a stored score differs from its checklist score; rows[].targets are re-based to the v2 scale by one documented rule; --method 2 is the default.", + "kind": "manual" + }, + { + "id": "dw-a0ae06", + "text": "METHOD.md §5.2 and §7, PLAN-OF-PLANS §1 and §6, results/v0.6.0.json, results/v0.7.0.json, REPORT-0.7.0.md, REPORT-method-v2.md and index.html are regenerated or re-stated with 2.0 numbers; the v1 numbers are kept in a history table.", + "kind": "manual" + }, + { + "id": "dw-6e75f3", + "text": "bash scripts/scorecard.sh --validate --self-test passes, including a fixture that fails on a stored score that differs from its checklist score; the CI scorecard step passes unchanged or is updated in the same change.", + "kind": "manual" + }, + { + "id": "dw-a593ac", + "text": "Step 4: the owner accepts the re-based numbers before 0.8 scope selection (§7.1.3) uses them.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-545f94.json b/.maestro/tasks/contracts/c-545f94.json new file mode 100644 index 000000000..d8bc9df8b --- /dev/null +++ b/.maestro/tasks/contracts/c-545f94.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-545f94", + "taskId": "tsk-mvcjv4mz-pvmh4b", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T19:17:11.614Z", + "lockedAt": "2026-10-17T19:17:11.614Z", + "intent": "r10-t0-k2-k6", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-a2672c", + "text": "`dx-kpis.sh` measures K2 = median incremental `cargo build` after editing one handler of a generated `service` project (method of `scripts/compile-time.sh`: wrappers unset, 3 runs) with `K2_BUDGET_S` default 8 (fails above), and K6 = cold `cargo build --release` of the same project (recorded; the budget value is written into the README per PLAN-OF-PLANS §8); `DX_KPIS_RELEASE` default 0.10; self-test covers K2/K6 parsing. Run once in wave 1 to get the baseline before the kit grows.", + "kind": "manual" + }, + { + "id": "dw-3b5283", + "text": "Gate: `bash scripts/dx-kpis.sh --self-test`; `DX_KPIS_RELEASE=0.10 bash scripts/dx-kpis.sh` prints K2", + "kind": "manual" + }, + { + "id": "dw-c8c495", + "text": "Owns: `scripts/dx-kpis.sh`, `docs/benchmarks/README.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-56fc6b.json b/.maestro/tasks/contracts/c-56fc6b.json new file mode 100644 index 000000000..aea4098d0 --- /dev/null +++ b/.maestro/tasks/contracts/c-56fc6b.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-56fc6b", + "taskId": "tsk-mveovo0t-2qcudv", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:23:00.050Z", + "lockedAt": "2026-10-19T03:23:00.050Z", + "intent": "r11-sec-secrets", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-8fe2cf", + "text": "M13: Secret zeroizes its value on drop (crates/id_effect_config/src/secret.rs:16-48 has no Drop today); SecretString and SecretBytes aliases; id_effect_auth Password (crates/id_effect_auth/src/password.rs:110-119) zeroizes on drop. MIGRATION-0.11 section (the new bound).", + "kind": "manual" + }, + { + "id": "dw-14f298", + "text": "L3: equality on Secret and Secret> uses subtle::ConstantTimeEq; the generic PartialEq impl (secret.rs:55-58) is removed (MIGRATION-0.11).", + "kind": "manual" + }, + { + "id": "dw-0f4c47", + "text": "M14: Session (crates/id_effect_auth/src/session/mod.rs:105, derived Debug) gets a manual Debug that prints the id prefix, user id and timestamps and only the attribute keys, never values.", + "kind": "manual" + }, + { + "id": "dw-0e9dc1", + "text": "Regression tests `m13_secret_is_zeroized_on_drop` (a recording Zeroize type), `m13_password_is_zeroized_on_drop`, `m14_session_debug_hides_attr_values_and_tokens`, `l3_secret_eq_uses_constant_time_compare`.", + "kind": "manual" + }, + { + "id": "dw-f45b36", + "text": "Gate: `cargo nextest run -p id_effect_config -p id_effect_auth --all-features`; `cargo deny check` (zeroize, subtle)", + "kind": "manual" + }, + { + "id": "dw-3d18a3", + "text": "Owns: `crates/id_effect_config/{src/secret.rs,Cargo.toml,tests/secret.rs}`, `crates/id_effect_auth/{src/password.rs,src/session/mod.rs,Cargo.toml}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sec-secrets", + "lockedBy": "r11-sec-secrets", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-58c9b3.json b/.maestro/tasks/contracts/c-58c9b3.json new file mode 100644 index 000000000..3628d6db2 --- /dev/null +++ b/.maestro/tasks/contracts/c-58c9b3.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-58c9b3", + "taskId": "tsk-mvcpey4b-b89h8y", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T19:00:59.831Z", + "lockedAt": "2026-10-17T19:00:59.831Z", + "intent": "r10-auth8-accounts", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-285b7f", + "text": "Registration, email verification, forgot/reset password; single-use expiring tokens stored as SHA-256, compared in constant time (pattern of `src/apikey.rs`); reset re-hashes through `PasswordHasher` and revokes sessions; forgot-password answers identically for unknown accounts; `AccountNotifier` trait implemented by `id_effect_mail`. Tests `reset_token_is_single_use`, `expired_token_rejected`, `token_stored_hashed`, `forgot_password_response_is_uniform`, `reset_revokes_sessions`.", + "kind": "manual" + }, + { + "id": "dw-831261", + "text": "Gate: ` -p id_effect_auth --features accounts`; `-p id_effect_mail --features auth`", + "kind": "manual" + }, + { + "id": "dw-b5cbe7", + "text": "Owns: `crates/id_effect_auth/src/account/**` (feature `accounts` = `pg` + `axum`), `crates/id_effect_auth/migrations/` (token table), `crates/id_effect_mail/src/auth.rs` (feature `auth`), `book/src/part6/ch30-03-passwords-sessions.md` (section)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-59c955.json b/.maestro/tasks/contracts/c-59c955.json new file mode 100644 index 000000000..3e82d3c1e --- /dev/null +++ b/.maestro/tasks/contracts/c-59c955.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-59c955", + "taskId": "tsk-mverrnjm-v3bnoo", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T04:43:53.023Z", + "lockedAt": "2026-10-19T04:43:53.023Z", + "intent": "r11-sec-ops-surfaces", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-dd06cb", + "text": "M7: DEFAULT_SCALAR_URL (crates/id_effect_axum/src/openapi/ui.rs:69, @1) pinned to an exact version with its SRI sha384 integrity set by default (:103 is None today); ApiDocs is not mounted under the prod profile unless guarded or explicitly public_dangerous() (posture).", + "kind": "manual" + }, + { + "id": "dw-66dc74", + "text": "M6: the service template's local_operator shortcut (templates/service/src/console.rs.template:71-79) exists only in the dev profile; the console checks Host and Origin against console.allowed_hosts (default: the bind address) and answers 403 otherwise; outside dev the console mounts only behind a non-loopback guard; the Ledger enables the console in dev.toml only (base.toml:72-73 sets enabled = true for every profile today).", + "kind": "manual" + }, + { + "id": "dw-64b769", + "text": "M4 (template): BodyLimitLayer and TimeoutLayer wrap SecurityLayer (templates/service/src/lib.rs.template:134-137). L2 (metrics): the template serves /metrics on the console listener or behind a guard, not on the public router (:131); id_effect_axum metrics gains a guarded router helper.", + "kind": "manual" + }, + { + "id": "dw-7aa2bf", + "text": "Regression tests `m7_scalar_bundle_pinned_with_integrity`, `m7_docs_disabled_in_prod_unless_guarded`, `m6_console_rejects_foreign_host_and_origin`, `m6_loopback_operator_only_in_dev`, `m4_template_limit_layers_wrap_security_layer`, `l2_template_metrics_not_public` (`m6_ledger_console_disabled_in_prod_profile` lives in r11-w3-ledger).", + "kind": "manual" + }, + { + "id": "dw-c3603d", + "text": "Gate: `cargo nextest run -p id_effect_axum -p id_effect_console -p id_effect_tool --all-features`; `bash scripts/template-smoke.sh`", + "kind": "manual" + }, + { + "id": "dw-a16acf", + "text": "Owns: `crates/id_effect_axum/src/{openapi/ui.rs,openapi/mod.rs,metrics.rs}`, `crates/id_effect_axum/tests/openapi_ui.rs`, `crates/id_effect_console/src/{api.rs,lib.rs}`, `crates/id_effect_console/tests/host_origin.rs`, `crates/id_effect_tool/templates/service/{src/console.rs.template,src/lib.rs.template,config/**}`, `crates/id_effect_tool/tests/template_security.rs`, `examples/reference/ledger/config/{base.toml,dev.toml,prod.toml}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sec-ops-surfaces", + "lockedBy": "r11-sec-ops-surfaces", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-59e3f5.json b/.maestro/tasks/contracts/c-59e3f5.json new file mode 100644 index 000000000..e426e257f --- /dev/null +++ b/.maestro/tasks/contracts/c-59e3f5.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-59e3f5", + "taskId": "tsk-mvfoqs1j-gik5ls", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T20:06:59.040Z", + "lockedAt": "2026-10-19T20:06:59.040Z", + "intent": "r12-f-unary-perf", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-0c4d86", + "text": "D5: `rpc/unary_json` − `rpc/axum_json_handler` ≤ 3,000 ns on the r12-e12-5-benches harness (measured +3,399 ns at 9f422370, Maestro evidence evd-1792438306570-c6acf9), median of 5 runs at load < 4, recorded in docs/benchmarks/v0.12.json; then the D5 `[[gate]]` table in docs/benchmarks/gates.toml (as docs/benchmarks/README.md \"Unary budget (D5)\" shows) is enabled. The budget itself is not raised.", + "kind": "manual" + }, + { + "id": "dw-d5570e", + "text": "Method: profile the unary path (perf / cargo-flamegraph or criterion + counting allocations) and remove per-call work that is not security-relevant — double JSON (de)serialisation, per-call allocations, repeated header parsing, boxed futures, guard-chain clones. No security check is removed or weakened (x-rpc, Content-Type, guards, deadline clamp, InternalError redaction, idempotency); secure-stack gate stays green (≤ +5 µs).", + "kind": "manual" + }, + { + "id": "dw-0c8128", + "text": "F48: the axum host listener and `serve_router*` set `TCP_NODELAY` on accepted TCP connections (WebSocket p99 41-42 ms → ~1 ms measured by benches); test asserts nodelay on an accepted socket.", + "kind": "manual" + }, + { + "id": "dw-fac02a", + "text": "Gate: `cargo nextest run -p id_effect_rpc -p id_effect_axum --all-features`; clippy -D warnings; bench-run + bench-compare for rpc and axum gates; coverage of changed code ≥ 96% regions.", + "kind": "manual" + }, + { + "id": "dw-f25b49", + "text": "Owns: `crates/id_effect_rpc/src/{server/**,handler.rs,registry.rs,codec/json.rs,wire.rs}`, `crates/id_effect_axum/src/server/{connection.rs,lifecycle.rs,bootstrap.rs}` (nodelay only), `docs/benchmarks/{gates.toml,v0.12.json,README.md}`, CHANGELOG own hunk.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "h3-unary-perf", + "lockedBy": "h3-unary-perf", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-5a67b2.json b/.maestro/tasks/contracts/c-5a67b2.json new file mode 100644 index 000000000..6c0b33a39 --- /dev/null +++ b/.maestro/tasks/contracts/c-5a67b2.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-5a67b2", + "taskId": "tsk-mvcpexoh-4d52ro", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:17:35.685Z", + "lockedAt": "2026-10-17T18:17:35.685Z", + "intent": "r10-e7-2-outbox-inbox", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-90e368", + "text": "`Outbox::stage(&SqlTransaction, msg)` (#7); relay claims rows with `SKIP LOCKED`, publishes, marks sent, retries with `Schedule`; `Inbox::once(&SqlTransaction, consumer, message_id, f)` dedups inside the handler's transaction (#8). Tests `outbox_row_rolls_back_with_tx`, `relay_publishes_each_row_once_across_two_relays`, `inbox_skips_duplicate_delivery`.", + "kind": "manual" + }, + { + "id": "dw-f26ff2", + "text": "Gate: ` -p id_effect_messaging --all-features`", + "kind": "manual" + }, + { + "id": "dw-7aaf2c", + "text": "Owns: `crates/id_effect_messaging/src/{outbox.rs,inbox.rs,relay.rs}`, `crates/id_effect_messaging/migrations/`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-5dd9e6.json b/.maestro/tasks/contracts/c-5dd9e6.json new file mode 100644 index 000000000..3783d5615 --- /dev/null +++ b/.maestro/tasks/contracts/c-5dd9e6.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-5dd9e6", + "taskId": "tsk-mvcrl0u6-q95oms", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T19:03:11.869Z", + "lockedAt": "2026-10-17T19:03:11.869Z", + "intent": "r10-w3-kit-template", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3bc69d", + "text": "Kit features `jobs`, `jobs-cron`, `messaging`, `redis-streams`, `mail`, `console`, `storage`, `events`, `auth-accounts`; `full` updated; the service template gains opt-in (off by default) jobs + console wiring so K2 stays lean; `id_effect_kit` tier → `platform`.", + "kind": "manual" + }, + { + "id": "dw-eaaf0b", + "text": "Gate: kit tests; CI template matrix; `bash scripts/tier-conformance.sh id_effect_kit`", + "kind": "manual" + }, + { + "id": "dw-83e626", + "text": "Owns: `crates/id_effect_kit/Cargo.toml` + `src/prelude.rs`, `crates/id_effect_tool/templates/service/**`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-5e5f1b.json b/.maestro/tasks/contracts/c-5e5f1b.json new file mode 100644 index 000000000..6b5aa2dc8 --- /dev/null +++ b/.maestro/tasks/contracts/c-5e5f1b.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-5e5f1b", + "taskId": "tsk-mvdhzjr6-6xpm3i", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T07:22:15.120Z", + "lockedAt": "2026-10-18T07:22:15.120Z", + "intent": "r11-e1-9-dylint", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f5122b", + "text": "labs/id_effect_lint (publish = false at Cargo.toml:2, nightly rustc_private at :22) moves to lints/id_effect_lints, outside the main workspace with its own rust-toolchain.toml (pinned nightly); the rustc lint plugins are ported to dylint_linting; the ast-grep rules stay usable (sgconfig.yml).", + "kind": "manual" + }, + { + "id": "dw-a848aa", + "text": "dx.lint#3: installable as a package — published to crates.io as id_effect_lints (r11-sc-publish adds the publish step; crate name availability recorded) and loadable through `[workspace.metadata.dylint] libraries = [{ git = ..., tag = ... }]`; #4: runs through `cargo dylint` (the clippy-style driver); #5: at least three lints emit MachineApplicable suggestions that `cargo dylint --fix` applies.", + "kind": "manual" + }, + { + "id": "dw-654636", + "text": "New lint raw_sql_non_literal warns when a SqlClient query/execute (&str) receives a non-literal (0.11.0.md D9, ADR 0013 R5 second enforcement point).", + "kind": "manual" + }, + { + "id": "dw-f3be79", + "text": "dylint_testing UI tests per lint; .github/workflows/lint.yml runs `cargo dylint --all` on the workspace (SHA-pinned, permissions read); book appendix-d-workspace-tooling.md documents install and use; level labs → documented (5/8 + #5 = 6/8 planned, target 63).", + "kind": "manual" + }, + { + "id": "dw-7a35e1", + "text": "Gate: `cd lints/id_effect_lints && cargo test`; `cargo dylint --path lints/id_effect_lints --all -- --workspace`; `bash scripts/check-workflows.sh`", + "kind": "manual" + }, + { + "id": "dw-5855e3", + "text": "Owns: `lints/id_effect_lints/**`, `labs/id_effect_lint/**` (deleted), `.github/workflows/lint.yml`, `crates/id_effect/book/src/appendix-d-workspace-tooling.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-616b6d.json b/.maestro/tasks/contracts/c-616b6d.json new file mode 100644 index 000000000..08b4c4c35 --- /dev/null +++ b/.maestro/tasks/contracts/c-616b6d.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 2, + "id": "c-616b6d", + "taskId": "tsk-mvdgoa0t-18vdc1", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:45:30.008Z", + "lockedAt": "2026-10-18T06:45:30.008Z", + "intent": "r11-sec-tenancy", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-a8613c", + "text": "M19: when the request has a current tenant, resource policies built for a Tenanted resource (crates/id_effect_authz/src/resource.rs, Tenanted/same_tenant are opt-in today) include the tenant match automatically; crossing tenants needs cross_tenant_dangerous(reason), which records a posture opt-out; resources that are not Tenanted are unaffected.", + "kind": "manual" + }, + { + "id": "dw-9ad85d", + "text": "Regression tests `m19_tenanted_resource_requires_tenant_match_by_default`, `m19_cross_tenant_requires_named_opt_out`, `m19_untenanted_resource_unaffected`; book part6/ch30-07-audit-tenancy.md section.", + "kind": "manual" + }, + { + "id": "dw-3e33af", + "text": "Gate: `cargo nextest run -p id_effect_authz --all-features`", + "kind": "manual" + }, + { + "id": "dw-cb5208", + "text": "Owns: `crates/id_effect_authz/src/{resource.rs,policy.rs,lib.rs}`, `crates/id_effect_authz/tests/tenancy.rs`, `crates/id_effect/book/src/part6/ch30-07-audit-tenancy.md` (section)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-64de72.json b/.maestro/tasks/contracts/c-64de72.json new file mode 100644 index 000000000..bef78117e --- /dev/null +++ b/.maestro/tasks/contracts/c-64de72.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-64de72", + "taskId": "tsk-mvbdsp4f-xmojqz", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T19:49:46.762Z", + "lockedAt": "2026-10-16T19:49:46.762Z", + "intent": "T0 ADR 0010 amendment 5, new crate skeletons, packaging/publish order", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-ee9da0", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-1cc3ec", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-acfd24", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-26f03d", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-3f4b24", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-de775e", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-5c6fb8", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-65b606.json b/.maestro/tasks/contracts/c-65b606.json new file mode 100644 index 000000000..7b73caa86 --- /dev/null +++ b/.maestro/tasks/contracts/c-65b606.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-65b606", + "taskId": "tsk-mvcpexrp-ffsx4n", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:33:47.323Z", + "lockedAt": "2026-10-17T18:33:47.323Z", + "intent": "r10-e7-2-redis-streams", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-6866bb", + "text": "`RedisStreamsBroker: MessageBroker` — `XADD`, `XREADGROUP COUNT = prefetch`, `XACK`, `XAUTOCLAIM` of idle pending entries (redelivery), DLQ stream after `max_deliveries` (#3 #5 #6). Container tests `consumer_group_ack_and_redelivery`, `poison_message_moves_to_dlq`, `outbox_relay_to_streams_end_to_end`. Chapter + example.", + "kind": "manual" + }, + { + "id": "dw-7238f2", + "text": "Gate: ` -p id_effect_redis --features streams`; `bash scripts/tier-conformance.sh id_effect_messaging`", + "kind": "manual" + }, + { + "id": "dw-94c49f", + "text": "Owns: `crates/id_effect_redis/src/streams.rs`, `crates/id_effect_redis/Cargo.toml` (feature `streams`), `book/src/part6/ch31-02-brokers.md`, `crates/id_effect_redis/examples/030_streams.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-65c93f.json b/.maestro/tasks/contracts/c-65c93f.json new file mode 100644 index 000000000..abcb1e2b3 --- /dev/null +++ b/.maestro/tasks/contracts/c-65c93f.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-65c93f", + "taskId": "tsk-mvf9rlgq-6v0ddu", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T13:07:50.430Z", + "lockedAt": "2026-10-19T13:07:50.430Z", + "intent": "r12-e12-1-batching", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-fbdc50", + "text": "POST /rpc with a JSON array body runs a batch: ids echoed, results in request order, each call authorized, rate-limited, traced and failing on its own; bounded parallelism through the compute admission (crates/id_effect/src/compute/admission.rs), an ordered flag runs calls in sequence.", + "kind": "manual" + }, + { + "id": "dw-e251c6", + "text": "One id_effect::request::RequestCache (crates/id_effect/src/request.rs:309) is scoped per batch, so duplicate loads through a RequestResolver are merged (the DataLoader equivalent).", + "kind": "manual" + }, + { + "id": "dw-53d1e3", + "text": "Finite defaults (ADR 0013 R4): at most 50 calls and 1 MiB per batch (config), 413 Problem otherwise; the rate limiter is charged N tokens for a batch of N (id_effect_axum middleware/rate_limit.rs gains a per-request charge).", + "kind": "manual" + }, + { + "id": "dw-61a99b", + "text": "The Rust client batches calls made in the same tick into one request (bounded window, opt-out per call).", + "kind": "manual" + }, + { + "id": "dw-ee5a2e", + "text": "Tests `batch_results_in_request_order_each_fails_alone`, `batch_dedups_duplicate_loads_through_request_cache`, `ordered_batch_runs_sequentially`, `r4_batch_over_limit_rejected_413`, `r4_batch_charges_n_rate_limit_tokens`, `client_batches_same_tick_calls`; `wire::decode_batch` is public for the fuzz target; book part6/ch33-02-batching.md.", + "kind": "manual" + }, + { + "id": "dw-c68e20", + "text": "Gate: `cargo nextest run -p id_effect_rpc -p id_effect_axum --all-features`", + "kind": "manual" + }, + { + "id": "dw-48c791", + "text": "Owns: `crates/id_effect_rpc/src/{batch.rs,client/http.rs}`, `crates/id_effect_rpc/src/server/http.rs` (batch branch, after r12-e12-1-kinds), `crates/id_effect_axum/src/middleware/rate_limit.rs`, `crates/id_effect_rpc/tests/batching.rs`, `crates/id_effect/book/src/part6/ch33-02-batching.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "d2-batching", + "lockedBy": "d2-batching", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-66740d.json b/.maestro/tasks/contracts/c-66740d.json new file mode 100644 index 000000000..50350a1a8 --- /dev/null +++ b/.maestro/tasks/contracts/c-66740d.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-66740d", + "taskId": "tsk-mvcpexl4-f87btw", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:04:41.386Z", + "lockedAt": "2026-10-17T18:04:41.386Z", + "intent": "r10-e7-2-broker-api", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-525054", + "text": "`MessageBroker` per D7 (#4); `MemoryBroker` with consumer groups, redelivery and a dead-letter topic after `max_deliveries` (#5), bounded prefetch + concurrency (#6). Tests `nack_redelivers_then_dead_letters`, `prefetch_bounds_in_flight`, `two_groups_each_get_every_message`.", + "kind": "manual" + }, + { + "id": "dw-35518f", + "text": "Gate: `cargo nextest run -p id_effect_messaging`", + "kind": "manual" + }, + { + "id": "dw-68d7d3", + "text": "Owns: `crates/id_effect_messaging/src/{broker.rs,delivery.rs,memory.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-66b5da.json b/.maestro/tasks/contracts/c-66b5da.json new file mode 100644 index 000000000..b77931d4a --- /dev/null +++ b/.maestro/tasks/contracts/c-66b5da.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-66b5da", + "taskId": "tsk-mvcjv3wa-40y1j9", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:41:54.327Z", + "lockedAt": "2026-10-17T15:41:54.327Z", + "intent": "r10-e7-1-queue-core", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-870b81", + "text": "`Job` trait (Schema-encoded payload, name, queue, priority, max_attempts, unique_key, run_at); `JobQueue` capability with `PgJobQueue` and `MemoryJobQueue`; `enqueue` and `enqueue_in(&SqlTransaction)` (#8); dequeue `FOR UPDATE SKIP LOCKED` ordered by priority then run_at (#4, #5); states queued/running/succeeded/failed/dead, next attempt from a core `Schedule`, `dead` after max_attempts (#2); partial unique index on `(queue, unique_key)` where state is queued/running (#3); `Worker` with per-queue concurrency (#6), heartbeat + visibility timeout re-queues jobs of a crashed worker (#1), drains on shutdown. Tests `skip_locked_two_workers_never_double_run`, `retry_backoff_then_dead`, `unique_key_dedups_while_pending`, `priority_order_within_queue`, `run_at_respected_under_test_clock`, `enqueue_in_rolls_back_with_tx`, `per_queue_concurrency_limit`, `crashed_worker_job_is_requeued`. **API + schema reviewed in a design PR before E7.3/E1.3r start.**", + "kind": "manual" + }, + { + "id": "dw-da6792", + "text": "Gate: ` -p id_effect_jobs --all-features`", + "kind": "manual" + }, + { + "id": "dw-adb24a", + "text": "Owns: `crates/id_effect_jobs/src/{job.rs,queue.rs,pg.rs,memory.rs,worker.rs}`, `crates/id_effect_jobs/migrations/`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-670353.json b/.maestro/tasks/contracts/c-670353.json new file mode 100644 index 000000000..8b247bf22 --- /dev/null +++ b/.maestro/tasks/contracts/c-670353.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 2, + "id": "c-670353", + "taskId": "tsk-mvf9twme-9avzxk", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T13:09:30.130Z", + "lockedAt": "2026-10-19T13:09:30.130Z", + "intent": "r12-e12-3-h3-server", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-ffa2ad", + "text": "axum feature http3: an adapter feeds h3 (=0.0.8) + h3-quinn (=0.0.10) requests from the platform QuicEndpoint (ALPN h3) into the same axum Router as a tower Service, so every typed route, middleware, guard and RPC endpoint works over HTTP/3 unchanged; the adapter is one module (server/h3.rs) so the backend can be swapped once hyper ships HTTP/3.", + "kind": "manual" + }, + { + "id": "dw-41fe67", + "text": "Alt-Svc: h3=\":\"; ma=86400 is added to TLS TCP responses when HTTP/3 is enabled; TCP and UDP listen on the same port number; graceful shutdown drains both (GOAWAY on h3, the 0.11 drain on TCP); server.http3.enabled = false turns HTTP/3 and Alt-Svc off (one flag, D4).", + "kind": "manual" + }, + { + "id": "dw-99ad5e", + "text": "Limits mirror ConnectionLimits for HTTP/3 (connections, request header timeout, requests per connection, QUIC stream credits); the client IP comes from the QUIC peer address through the 0.10.1 trusted-proxy rules; 0-RTT off, dangerous_allow_zero_rtt_for_queries() allows it for safe methods only and records a posture opt-out.", + "kind": "manual" + }, + { + "id": "dw-79eb76", + "text": "Spike first (D9): one HTTP/2 vs HTTP/3 p99 measurement under netem loss 1% delay 5ms with 64 multiplexed streams, recorded in the leaf evidence before the adapter is finished.", + "kind": "manual" + }, + { + "id": "dw-30753a", + "text": "Conformance: a CI job http3-conformance runs h3spec (release binary pinned by sha256) and curl --http3 (container image pinned by digest) against an example server; actions pinned by SHA (0.11 rule).", + "kind": "manual" + }, + { + "id": "dw-321378", + "text": "Tests `h3_serves_typed_routes_unchanged`, `h3_middleware_and_guards_apply`, `alt_svc_header_on_tcp_responses`, `http3_disabled_by_one_flag`, `shutdown_drains_tcp_and_udp`, `zero_rtt_off_by_default`, `h3_client_ip_from_quic_peer`; book part6/ch29-05-http3.md.", + "kind": "manual" + }, + { + "id": "dw-172733", + "text": "Gate: `cargo nextest run -p id_effect_axum --features http3`; `cargo nextest run -p id_effect_axum --all-features`; CI job `http3-conformance` green", + "kind": "manual" + }, + { + "id": "dw-28da5d", + "text": "Owns: `crates/id_effect_axum/src/server/{h3.rs,alt_svc.rs,lifecycle.rs}`, `crates/id_effect_axum/tests/http3.rs`, `crates/id_effect_axum/examples/092_http3.rs`, `.github/workflows/ci.yml` (the `http3-conformance` job only), `crates/id_effect/book/src/part6/ch29-05-http3.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "d6-h3-server", + "lockedBy": "d6-h3-server", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-678e4b.json b/.maestro/tasks/contracts/c-678e4b.json new file mode 100644 index 000000000..7fd2fa596 --- /dev/null +++ b/.maestro/tasks/contracts/c-678e4b.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-678e4b", + "taskId": "tsk-mvbh3xxf-emsxrb", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:22:20.628Z", + "lockedAt": "2026-10-16T21:22:20.628Z", + "intent": "r09-e3-1b-sqlite", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-e46f49", + "text": "Same conformance suite as pg runs green on SQLite in-memory and file DBs.", + "kind": "manual" + }, + { + "id": "dw-0fa362", + "text": "Gate: `cargo nextest run -p id_effect_sql --features sqlite`", + "kind": "manual" + }, + { + "id": "dw-587a13", + "text": "Owns: `crates/id_effect_sql/src/sqlite/**`, feature `sqlite`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-6882ee.json b/.maestro/tasks/contracts/c-6882ee.json new file mode 100644 index 000000000..954c4576a --- /dev/null +++ b/.maestro/tasks/contracts/c-6882ee.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-6882ee", + "taskId": "tsk-mvfns901-uz5ej3", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T19:40:12.952Z", + "lockedAt": "2026-10-19T19:40:12.952Z", + "intent": "r12-e12-4-rpc-check", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-752542", + "text": "id-effect rpc check --contract --against compares two contracts and classifies every change: breaking (procedure removed, kind changed, required input field added, input type narrowed, output field removed or narrowed, error variant added, access decision tightened) or compatible (procedure added, optional input field added, output field added); prints the diff with a reason per item.", + "kind": "manual" + }, + { + "id": "dw-8ede7a", + "text": "web.rpc#6: a breaking change passes only when the service version is bumped and the previous version is still served; exit 1 otherwise, exit 2 on unreadable input.", + "kind": "manual" + }, + { + "id": "dw-5dde31", + "text": "Tests `removed_procedure_is_breaking`, `optional_field_added_is_compatible`, `required_field_added_is_breaking`, `error_variant_added_is_breaking_for_clients`, `breaking_change_with_version_bump_passes`, `against_git_ref_reads_old_contract`; book part6/ch33-12-contract-check.md.", + "kind": "manual" + }, + { + "id": "dw-fe7949", + "text": "Gate: `cargo nextest run -p id_effect_tool --test rpc_check`; `cargo nextest run -p id_effect_tool`", + "kind": "manual" + }, + { + "id": "dw-569648", + "text": "Owns: `crates/id_effect_tool/src/rpc/check.rs`, `crates/id_effect_tool/src/cli.rs` (the check arm only, after r12-e12-4-gen-ts), `crates/id_effect_tool/tests/rpc_check.rs`, `crates/id_effect/book/src/part6/ch33-12-contract-check.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "g4-rpc-check", + "lockedBy": "g4-rpc-check", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-69fb70.json b/.maestro/tasks/contracts/c-69fb70.json new file mode 100644 index 000000000..b0325e864 --- /dev/null +++ b/.maestro/tasks/contracts/c-69fb70.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-69fb70", + "taskId": "tsk-mvf4c3s4-xn4847", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T10:35:43.406Z", + "lockedAt": "2026-10-19T10:35:43.406Z", + "intent": "r12-t0-skeletons", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-55b886", + "text": "ADR 0010 Amendment 8 (after Amendment 7, docs/adrs/0010-crate-tiers.md:220) names: id_effect_rpc (published, enters tier integration, moves to platform when the Relay depends on it; leaves the labs row); id_effect_rpc_macros (published proc-macro crate, tier integration); the id_effect_platform features tls, quic, http3, acme and the move of crates/id_effect_node/src/transport/certs.rs into id_effect_platform::tls with a one-minor deprecated re-export in node (rule 3, L9); examples/reference/relay (tier example, publish = false).", + "kind": "manual" + }, + { + "id": "dw-b68169", + "text": "`git mv labs/id_effect_rpc crates/id_effect_rpc` (history kept); labs/Cargo.toml members lose id_effect_rpc; path dependencies rewritten (../../crates/x → ../x); the crate builds and its existing tests (tests/{codegen_stub,openapi_emit,wire_round_trip}.rs) pass unchanged; publish = false and tier labs stay until r12-e12-1-promote.", + "kind": "manual" + }, + { + "id": "dw-0750c5", + "text": "crates/id_effect_rpc_macros skeleton: proc-macro = true, workspace version, README with a rust fence, #![deny(missing_docs)], one test; examples/reference/relay skeleton: Cargo.toml (package relay, publish = false, depends on id_effect_kit only), README, src/main.rs, one test.", + "kind": "manual" + }, + { + "id": "dw-2d4cf9", + "text": "Root Cargo.toml members gain crates/id_effect_rpc, crates/id_effect_rpc_macros, examples/reference/relay; scripts/validate-crate-packaging.sh LEVEL/ORDER: --print-order lists id_effect_rpc_macros, then id_effect_rpc after id_effect_axum, id_effect_platform, id_effect_node and id_effect_events, and both before id_effect_kit and id_effect_tool; crates.io availability of both names recorded in the leaf evidence (generic User-Agent, no personal data); .github/workflows/ci.yml -p lines include both crates and the labs job no longer lists id_effect_rpc.", + "kind": "manual" + }, + { + "id": "dw-6a2b16", + "text": "docs/MIGRATION-0.12.md created with one heading per planned break (node transport::certs move, axum TLS/HTTP/3 config fields, HttpClientConfig::http3, QUIC mesh ALPN idm/1, labs RpcGroup::register → #[rpc::service], OpenAPI 3.0 → 3.1 output, template server.http3).", + "kind": "manual" + }, + { + "id": "dw-dfe0c3", + "text": "Gate: `bash scripts/validate-crate-packaging.sh`; `bash scripts/validate-crate-packaging.sh --print-order`; `cargo nextest run -p id_effect_rpc -p id_effect_rpc_macros -p relay`; `cargo nextest run --manifest-path labs/Cargo.toml --workspace`", + "kind": "manual" + }, + { + "id": "dw-fa8192", + "text": "Owns: `docs/adrs/0010-crate-tiers.md`, root `Cargo.toml` (members), `labs/Cargo.toml`, `crates/id_effect_rpc/**` (move only; r12-e12-1-promote owns it next), `crates/id_effect_rpc_macros/**` (skeleton; r12-e12-1-macro next), `examples/reference/relay/**` (skeleton; r12-w7-relay next), `scripts/validate-crate-packaging.sh`, `.github/workflows/ci.yml` (-p lines, labs job), `docs/MIGRATION-0.12.md` (headings)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "a1-governance", + "lockedBy": "a1-governance", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-6bfbf2.json b/.maestro/tasks/contracts/c-6bfbf2.json new file mode 100644 index 000000000..ebcf26b90 --- /dev/null +++ b/.maestro/tasks/contracts/c-6bfbf2.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-6bfbf2", + "taskId": "tsk-mvb3399t-pysl4g", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T14:49:43.615Z", + "lockedAt": "2026-10-16T14:49:43.615Z", + "intent": "0.8 E2.3b — schema follow-ups", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-556071", + "text": "#[derive(Schema)] (crates/id_effect_proc_macro/src/derive_schema_parser.rs, built through Schema::make) attaches a SchemaAst via .with_ast(..) built from each field's HasSchema::schema().ast() (struct with field names, optional/nullable fields, enums/unions where the derive supports them), so json_schema_for::() is no longer {}; golden tests for a derived struct, a nested derived struct and an enum if supported; existing derive tests unchanged.", + "kind": "manual" + }, + { + "id": "dw-f0c6ca", + "text": "Real regular expressions for data.schema #8: a regex-backed validator (e.g. schema::validators::regex(pattern) -> Result<..>) using the regex crate as an optional or default dependency of id_effect (decide and justify; tier-conformance PASS), exporting the same pattern into JSON Schema; the existing caller-supplied-matcher pattern() stays.", + "kind": "manual" + }, + { + "id": "dw-676402", + "text": "Book schema chapter updated; cargo test/clippy -D warnings/doc for id_effect and id_effect_proc_macro green with default and all features.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-6c5676.json b/.maestro/tasks/contracts/c-6c5676.json new file mode 100644 index 000000000..dfc30d3af --- /dev/null +++ b/.maestro/tasks/contracts/c-6c5676.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-6c5676", + "taskId": "tsk-mvf9rlkh-oyyusj", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T13:07:50.557Z", + "lockedAt": "2026-10-19T13:07:50.557Z", + "intent": "r12-e12-1-macro", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f19d48", + "text": "#[rpc::service(version = N)] on a trait whose methods carry one of #[query] #[mutation] #[server_stream] #[client_stream] #[bidi] #[subscription] and one of #[public] / #[guard(G)] generates: the handler trait (each method an Effect, so DI, typed errors and TestExecutor apply), the Rust client generic over the transport (web.rpc#3), Service::contract() and the router mount; input, output and error types must be HasSchema (an on_unimplemented message names the derive).", + "kind": "manual" + }, + { + "id": "dw-179a70", + "text": "D11: id_effect_rpc::contract::assert_snapshot keeps rpc.contract.json current (UPDATE_GOLDEN=1 rewrites, as id_effect::testing::golden); two service versions can be served side by side (web.rpc#6).", + "kind": "manual" + }, + { + "id": "dw-5f8cfe", + "text": "trybuild compile-fail cases in crates/id_effect_rpc/tests/ui, each first error with a help: line (K4 style): procedure without access decision (ADR 0013 R1), two kinds on one method, non-HasSchema type, #[bidi] without a Duplex return, duplicate procedure names, missing version.", + "kind": "manual" + }, + { + "id": "dw-000f32", + "text": "Tests `generated_client_round_trip_every_kind`, `contract_snapshot_is_current`, `service_versions_served_side_by_side`, `guarded_procedure_runs_guard_before_handler`; book part6/ch33-04-service-macro.md.", + "kind": "manual" + }, + { + "id": "dw-32a289", + "text": "Gate: `cargo nextest run -p id_effect_rpc -p id_effect_rpc_macros --all-features`; `cargo test -p id_effect_rpc --test ui`; `bash scripts/tier-conformance.sh id_effect_rpc_macros`", + "kind": "manual" + }, + { + "id": "dw-26266b", + "text": "Owns: `crates/id_effect_rpc_macros/**`, `crates/id_effect_rpc/src/service.rs`, `crates/id_effect_rpc/tests/{ui/**,ui.rs,service_macro.rs}`, `crates/id_effect_rpc/rpc.contract.json`, `crates/id_effect/book/src/part6/ch33-04-service-macro.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "d3-macro", + "lockedBy": "d3-macro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-6cf47e.json b/.maestro/tasks/contracts/c-6cf47e.json new file mode 100644 index 000000000..180dff356 --- /dev/null +++ b/.maestro/tasks/contracts/c-6cf47e.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-6cf47e", + "taskId": "tsk-mvd9mltr-5gll5q", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T03:28:19.829Z", + "lockedAt": "2026-10-18T03:28:19.829Z", + "intent": "r101-auth", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-016618", + "text": "Closes H5, H6, H7, M15, M20 and L4 as specified in the Findings table of .maestro/specs/releases/0.10.1.md, within the patch-release rules there (additive API only; stricter defaults with a named dangerous/insecure opt-out). One regression test per finding, named after it (for example h1_per_ip_limit_sees_the_peer).", + "kind": "manual" + }, + { + "id": "dw-f10b15", + "text": "Gate: cargo test -p id_effect_auth --all-features (accounts tests need Postgres); cargo clippy -p id_effect_auth --all-features --all-targets -- -D warnings", + "kind": "manual" + }, + { + "id": "dw-c35a74", + "text": "Owns: crates/id_effect_auth/src/basic.rs, totp.rs, jwt.rs, jwks.rs, password.rs, new throttle.rs, account/** (login wiring only), lib.rs (re-exports only), crates/id_effect_auth/tests/**", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-6dd4b5.json b/.maestro/tasks/contracts/c-6dd4b5.json new file mode 100644 index 000000000..dcff88c25 --- /dev/null +++ b/.maestro/tasks/contracts/c-6dd4b5.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-6dd4b5", + "taskId": "tsk-mvcjv466-ezgto8", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T17:07:34.121Z", + "lockedAt": "2026-10-17T17:07:34.121Z", + "intent": "r10-reload-config", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-566b5f", + "text": "Profile files polled by mtime; a change re-parses and publishes through a `SubscriptionRef`; an invalid change keeps the previous value and reports the errors (#5; also the strict reading of sec.config#8, G1). Tests `profile_file_change_notifies_subscribers`, `invalid_change_keeps_previous` (under the test clock).", + "kind": "manual" + }, + { + "id": "dw-5367cf", + "text": "Gate: `cargo nextest run -p id_effect_config --all-features`", + "kind": "manual" + }, + { + "id": "dw-068d4c", + "text": "Owns: `crates/id_effect_config/src/{profile.rs,watch.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-6e0faf.json b/.maestro/tasks/contracts/c-6e0faf.json new file mode 100644 index 000000000..e05a7e439 --- /dev/null +++ b/.maestro/tasks/contracts/c-6e0faf.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-6e0faf", + "taskId": "tsk-mvbh3yxp-p9pp0e", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:46:08.061Z", + "lockedAt": "2026-10-16T21:46:08.061Z", + "intent": "r09-e5-5-audit", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3d4785", + "text": "`AuditLog` Layer appends `{actor, tenant, action, entity, before, after, at}` inside the caller's transaction; each record stores `sha256(prev_hash ‖ record)`; `verify_chain()` reports the first broken link. Tests `tampered_row_breaks_chain`, `audit_rolls_back_with_tx`.", + "kind": "manual" + }, + { + "id": "dw-93e735", + "text": "Gate: pg nextest `--features audit`", + "kind": "manual" + }, + { + "id": "dw-2e22be", + "text": "Owns: `crates/id_effect_sql/src/audit.rs` (feature `audit`), its migration", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-6fdef8.json b/.maestro/tasks/contracts/c-6fdef8.json new file mode 100644 index 000000000..cf45b169b --- /dev/null +++ b/.maestro/tasks/contracts/c-6fdef8.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 2, + "id": "c-6fdef8", + "taskId": "tsk-mvdfsdm7-rk163d", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:20:41.239Z", + "lockedAt": "2026-10-18T06:20:41.239Z", + "intent": "r11-m22-rebase", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-9cdbaf", + "text": "Step 1 (non-goals leave the score, every framework): METHOD.md §3.3 rewritten — a `non-goal` status is set only by a locked decision; a non-goal row gets weight 0 and leaves its group mean, U, E, RT and the row KPIs; a non-goal check leaves its row checklist and the row scores clamp(1, cap(L), round(100·m/(n−k))). scorecard.json marks row `web.graphql` (`non_goal: {decision: \"L15\"}`) and checks `fault.reload#2`, `#6`, `#7`, `dx.compile#3`, `dx.repl#3` (L8), `web.ui#3`, `#4` (L7), `data.backends#4`, `#5` (L6). `conc.safety#8` and `obs.tracing#8` stay scored. scorecard.schema.json and `scripts/scorecard.sh --validate` accept the field and reject a non-goal without a decision id.", + "kind": "manual" + }, + { + "id": "dw-35872c", + "text": "Step 2 (.NET scope): frameworks[dotnet].scope adds Orleans and the Durable Task Framework as first-party Microsoft; every .NET cell that alignment-2.0.json classifies `recommended` for Orleans/Durable Task becomes first-party; every fault, dist, conc and msg row is re-checked for checks Orleans/DTF meet that the snapshot did not credit, scored by the third-party rule (PLAN-OF-PLANS §7.0 step 1); results with per-cell evidence in docs/scorecard/alignment-2.2.json; METHOD §8 logs it as a scope change (snapshot date unchanged); the leaf evidence states .NET U/E/RT before and after.", + "kind": "manual" + }, + { + "id": "dw-67128c", + "text": "Step 3 (bump to 2.2): method_version \"2.2\"; every stored version 0.6.0–0.10.0 re-scored; docs/scorecard/results/*.json, REPORT-method-v2.md and index.html regenerated; METHOD §5.2 restated with the 2.2 table and the 2.1 table kept as history; new fixtures fixtures/non-goal-row.json and fixtures/non-goal-check.json with expected outputs, run by --self-test.", + "kind": "manual" + }, + { + "id": "dw-2a200c", + "text": "Targets: web.rpc and web.realtime 0.11.0 targets move to 0.12.0 (L15); bonus 0.11.0 targets conc.stm 89, sec.supply 100, test.qa 100 (0.11.0.md D1); web.graphql targets removed.", + "kind": "manual" + }, + { + "id": "dw-0b47e5", + "text": "PLAN-OF-PLANS: §1 north-star rows and every \"re-based\" §6 cell restated from `scripts/scorecard.sh --targets ` under 2.2; the §6 note \"E 73.3 at 0.11\" replaced; the dx.repl#3 effect (id_effect 75 → 71 on dx.repl, 0.11.0.md D3) recorded next to the numbers; the 0.11.0.md frontmatter `scorecard:` block and its \"Capacity and index effect\" table replaced by tool output.", + "kind": "manual" + }, + { + "id": "dw-72dac0", + "text": "Step 4: the owner acceptance of the re-based numbers is recorded in PLAN-OF-PLANS §11 with a date. Until it is recorded the leaf is done-pending-acceptance and 0.11.0.md keeps `provisional: true`; this leaf never records acceptance on the owner's behalf.", + "kind": "manual" + }, + { + "id": "dw-817b32", + "text": "Gate: `bash scripts/scorecard.sh --validate --self-test`; `bash scripts/scorecard.sh --method-compare && git diff --exit-code docs/scorecard/REPORT-method-v2.md`; `bash scripts/scorecard.sh --version 0.10.0`; `bash scripts/scorecard.sh --targets 0.11.0` prints the numbers written into 0.11.0.md", + "kind": "manual" + }, + { + "id": "dw-2e6ac9", + "text": "Owns: `docs/scorecard/{METHOD.md,scorecard.json,scorecard.schema.json,alignment-2.2.json,fixtures/**,results/*.json,REPORT-method-v2.md,index.html}`, `scripts/scorecard.sh`, `.maestro/specs/roadmap/PLAN-OF-PLANS.md` (§1, §6, §11), `.maestro/specs/releases/0.11.0.md` (frontmatter `scorecard:` block and the \"Capacity and index effect\" table only)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-7068f8.json b/.maestro/tasks/contracts/c-7068f8.json new file mode 100644 index 000000000..0fa91cdbb --- /dev/null +++ b/.maestro/tasks/contracts/c-7068f8.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-7068f8", + "taskId": "tsk-mvbh3y0u-n3qtug", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T23:01:54.630Z", + "lockedAt": "2026-10-16T23:01:54.630Z", + "intent": "r09-e3-3-docs", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-e25238", + "text": "Chapter + example.", + "kind": "manual" + }, + { + "id": "dw-c303d0", + "text": "Gate: book-snippets doctest", + "kind": "manual" + }, + { + "id": "dw-7e6532", + "text": "Owns: `book/src/part6/ch28-03-repositories.md`, `crates/id_effect_sql/examples/040_repository.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-767817.json b/.maestro/tasks/contracts/c-767817.json new file mode 100644 index 000000000..43032b40f --- /dev/null +++ b/.maestro/tasks/contracts/c-767817.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-767817", + "taskId": "tsk-mvbh3yns-8zm2bm", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T22:13:17.444Z", + "lockedAt": "2026-10-16T22:13:17.444Z", + "intent": "r09-e4-2-ui-routes", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-be5b5f", + "text": "`/openapi.json`, `/docs` (Scalar, D11), `/_routes` route table (method, path, handler name, schemas) behind a feature and an opt-in mount (web.server#8). Book `ch29-02-openapi.md`, example `085_openapi.rs`, integration test over a socket.", + "kind": "manual" + }, + { + "id": "dw-6dafde", + "text": "Gate: same + book-snippets; level must reach `documented` (0.8 miss cause)", + "kind": "manual" + }, + { + "id": "dw-00ad5a", + "text": "Owns: `crates/id_effect_axum/src/openapi/ui.rs`, `src/route/introspect.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-788775.json b/.maestro/tasks/contracts/c-788775.json new file mode 100644 index 000000000..f662bd716 --- /dev/null +++ b/.maestro/tasks/contracts/c-788775.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-788775", + "taskId": "tsk-mvcjv2xv-k2omgn", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:28:18.570Z", + "lockedAt": "2026-10-17T15:28:18.570Z", + "intent": "r10-co-schema-errors", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-b1d10f", + "text": "Derived schemas accumulate all field errors like combinator schemas (`decode_unknown_all`); trybuild/ui snapshots updated. Test `derived_schema_reports_every_invalid_field`.", + "kind": "manual" + }, + { + "id": "dw-105655", + "text": "Gate: `cargo nextest run -p id_effect_proc_macro -p id_effect`", + "kind": "manual" + }, + { + "id": "dw-08aa45", + "text": "Owns: `crates/id_effect_proc_macro/src/derive_schema_parser.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-7a66d1.json b/.maestro/tasks/contracts/c-7a66d1.json new file mode 100644 index 000000000..e621ea430 --- /dev/null +++ b/.maestro/tasks/contracts/c-7a66d1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-7a66d1", + "taskId": "tsk-mvcjv49j-j19atg", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T17:13:33.574Z", + "lockedAt": "2026-10-17T17:13:33.574Z", + "intent": "r10-reload-rolling", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-6c6e9f", + "text": "Kamal template sets the proxy healthcheck to `/ready` and a drain timeout ≥ the server's shutdown grace; test asserts the rendered values; chapter documents the rolling sequence, citing `on_sigterm_ready_turns_503_while_an_in_flight_request_completes` (`crates/id_effect_axum/tests/health.rs:216`) as the evidence (#4).", + "kind": "manual" + }, + { + "id": "dw-493121", + "text": "Gate: `cargo nextest run -p id_effect_tool`; `cargo test --doc -p book_snippets`", + "kind": "manual" + }, + { + "id": "dw-5e6a68", + "text": "Owns: `crates/id_effect_tool/templates/service/config/deploy.yml.template`, `crates/id_effect_tool/tests/bin.rs` (one test), `book/src/part6/ch34-00-dx-ship.md` (rolling-drain section)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-7b35a3.json b/.maestro/tasks/contracts/c-7b35a3.json new file mode 100644 index 000000000..9197e1f7a --- /dev/null +++ b/.maestro/tasks/contracts/c-7b35a3.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-7b35a3", + "taskId": "tsk-mvbh3z0z-819ef0", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:46:08.459Z", + "lockedAt": "2026-10-16T21:46:08.459Z", + "intent": "r09-e5-5-tenancy-doc", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-a7ef51", + "text": "Documented row-level tenant pattern: CapOverrideLayer tenant → Repository global filter → audit actor/tenant.", + "kind": "manual" + }, + { + "id": "dw-81b875", + "text": "Gate: book-snippets doctest", + "kind": "manual" + }, + { + "id": "dw-5c340d", + "text": "Owns: `book/src/part6/ch30-04-audit-tenancy.md`, example", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-7b91e1.json b/.maestro/tasks/contracts/c-7b91e1.json new file mode 100644 index 000000000..478e2d925 --- /dev/null +++ b/.maestro/tasks/contracts/c-7b91e1.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 2, + "id": "c-7b91e1", + "taskId": "tsk-mvbg796n-xpvbrc", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:56:47.644Z", + "lockedAt": "2026-10-16T20:56:47.644Z", + "intent": "PgSessionStore", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f7bb0b", + "text": "id_effect_auth feature `pg`: `PgSessionStore` implements `SessionStore` (save as upsert, load, remove, remove_user, purge_expired) over `id_effect_sql::SqlClient` with `SqlValue` params.", + "kind": "manual" + }, + { + "id": "dw-66d76a", + "text": "The table stores SHA-256 of the session id, never the raw id; ships a migration (`migrations/` in the crate, usable with id_effect_sql::migrate) creating `id_effect_sessions` with an index on user_id and on absolute_expires_at.", + "kind": "manual" + }, + { + "id": "dw-cec605", + "text": "Live Postgres test (ID_EFFECT_REQUIRE_PG=1) runs the same store behaviour suite as MemorySessionStore; book ch30-03 documents the store.", + "kind": "manual" + }, + { + "id": "dw-c3b89c", + "text": "Completes sec.authn #2 (persistent session store).", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-7c3672.json b/.maestro/tasks/contracts/c-7c3672.json index f7afa4c03..10a7e46f2 100644 --- a/.maestro/tasks/contracts/c-7c3672.json +++ b/.maestro/tasks/contracts/c-7c3672.json @@ -1,69 +1,67 @@ { - "schemaVersion": 2, - "id": "c-7c3672", - "taskId": "tsk-mv9xeqip-axc0oj", - "repoRoot": ".", - "status": "locked", - "createdAt": "2026-10-15T21:35:19.766Z", - "lockedAt": "2026-10-15T21:35:19.766Z", - "intent": "W1: scorecard.sh --diff/--compare-targets/--report/--html; publish REPORT-0.7.0 and index.html", - "scope": { - "filesExpected": [ - "**/*" - ], - "filesForbidden": [] - }, - "doneWhen": [ - { - "id": "dw-655593", - "text": "docs/adrs/0012-platform-vision.md (status Accepted) supersedes the ADR 0010 vision statement, adds the \"platform\" tier (integration gates + reference-app consumer), and amends Rule 4 so CI-tested reference apps count as consumers; ADR 0010 gets a \"Superseded in part by 0012\" header line; README scope paragraph (lines 3-5) no longer says \"not a batteries-included web framework\".", - "kind": "manual" - }, - { - "id": "dw-f1af8c", - "text": "docs/scorecard/ contains METHOD.md, scorecard.json (moved from .maestro/specs/roadmap/scorecard/), results/v0.6.0.json and results/v0.7.0.json; scripts/scorecard.sh (bash + jq, Requires header, exit 1 FAIL / 2 misuse) implements METHOD §5 and §5.1; `scripts/scorecard.sh --self-test` reproduces every number in METHOD §5.2 exactly (one-decimal string compare).", - "kind": "manual" - }, - { - "id": "dw-b7a521", - "text": "scorecard.sh subcommands work - `--version ` (indices + KPIs), `--targets ` (planned indices), `--diff ` (row deltas, exit 1 on any row ≤ −5 without `adr` note), `--compare-targets ` (exit 1 if E < target − 1.0), `--report ` (writes REPORT-.md), `--html` (writes docs/scorecard/index.html from the JSON).", - "kind": "manual" - }, - { - "id": "dw-d3c065", - "text": "scorecard.json validates against docs/scorecard/scorecard.schema.json (row ids unique, 10 competitor scores, scores in 1..100, every group/track referenced exists); invalid fixtures under docs/scorecard/fixtures/ make the validator exit 1.", - "kind": "manual" - }, - { - "id": "dw-ad478d", - "text": "moon task `scorecard` and a CI step in ci.yml run `scorecard.sh --self-test` and schema validation on every PR.", - "kind": "manual" - }, - { - "id": "dw-2c86cc", - "text": ".maestro/specs/roadmap/PLAN-OF-PLANS.md links to docs/scorecard/ paths (no stale .maestro scorecard paths remain - grep returns 0).", - "kind": "manual" - }, - { - "id": "dw-b7e983", - "text": "Stale task tsk-mv799sew-y9wf9g (W5, migrate downstream to 0.6) is abandoned with reason \"superseded by PLAN-OF-PLANS E0.5 (downstream migration to 0.7+)\"; mission pln-mv799j4f-1r1u6b is closed.", - "kind": "manual" - }, - { - "id": "dw-35ee82", - "text": "Method v2 table-stakes checklists exist for all 82 rows in scorecard.json (a checks list with 4-8 entries each); scorecard.sh --method 2 recomputes 0.6.0 and 0.7.0; REPORT-method-v2.md shows v1 vs v2 side by side for both versions.", - "kind": "manual" - } - ], - "amendments": [], - "createdBy": "claude-opus", - "lockedBy": "claude-opus", - "configSnapshot": { - "strict": false, - "overlapPolicy": "annotate", - "rebaseFallback": "best-effort", - "staleReclaimContractPolicy": "inherit" - }, - "missionId": "pln-mv9xenpd-d2lpkn", - "riskClass": "high" + "schemaVersion": 2, + "id": "c-7c3672", + "taskId": "tsk-mv9xeqip-axc0oj", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-15T21:35:19.766Z", + "lockedAt": "2026-10-15T21:35:19.766Z", + "intent": "W1: scorecard.sh --diff/--compare-targets/--report/--html; publish REPORT-0.7.0 and index.html", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-655593", + "text": "docs/adrs/0012-platform-vision.md (status Accepted) supersedes the ADR 0010 vision statement, adds the \"platform\" tier (integration gates + reference-app consumer), and amends Rule 4 so CI-tested reference apps count as consumers; ADR 0010 gets a \"Superseded in part by 0012\" header line; README scope paragraph (lines 3-5) no longer says \"not a batteries-included web framework\".", + "kind": "manual" + }, + { + "id": "dw-f1af8c", + "text": "docs/scorecard/ contains METHOD.md, scorecard.json (moved from .maestro/specs/roadmap/scorecard/), results/v0.6.0.json and results/v0.7.0.json; scripts/scorecard.sh (bash + jq, Requires header, exit 1 FAIL / 2 misuse) implements METHOD §5 and §5.1; `scripts/scorecard.sh --self-test` reproduces every number in METHOD §5.2 exactly (one-decimal string compare).", + "kind": "manual" + }, + { + "id": "dw-b7a521", + "text": "scorecard.sh subcommands work - `--version ` (indices + KPIs), `--targets ` (planned indices), `--diff ` (row deltas, exit 1 on any row ≤ −5 without `adr` note), `--compare-targets ` (exit 1 if E < target − 1.0), `--report ` (writes REPORT-.md), `--html` (writes docs/scorecard/index.html from the JSON).", + "kind": "manual" + }, + { + "id": "dw-d3c065", + "text": "scorecard.json validates against docs/scorecard/scorecard.schema.json (row ids unique, 10 competitor scores, scores in 1..100, every group/track referenced exists); invalid fixtures under docs/scorecard/fixtures/ make the validator exit 1.", + "kind": "manual" + }, + { + "id": "dw-ad478d", + "text": "moon task `scorecard` and a CI step in ci.yml run `scorecard.sh --self-test` and schema validation on every PR.", + "kind": "manual" + }, + { + "id": "dw-2c86cc", + "text": ".maestro/specs/roadmap/PLAN-OF-PLANS.md links to docs/scorecard/ paths (no stale .maestro scorecard paths remain - grep returns 0).", + "kind": "manual" + }, + { + "id": "dw-b7e983", + "text": "Stale task tsk-mv799sew-y9wf9g (W5, migrate downstream to 0.6) is abandoned with reason \"superseded by PLAN-OF-PLANS E0.5 (downstream migration to 0.7+)\"; mission pln-mv799j4f-1r1u6b is closed.", + "kind": "manual" + }, + { + "id": "dw-35ee82", + "text": "Method v2 table-stakes checklists exist for all 82 rows in scorecard.json (a checks list with 4-8 entries each); scorecard.sh --method 2 recomputes 0.6.0 and 0.7.0; REPORT-method-v2.md shows v1 vs v2 side by side for both versions.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "claude-opus", + "lockedBy": "claude-opus", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mv9xenpd-d2lpkn", + "riskClass": "high" } diff --git a/.maestro/tasks/contracts/c-7f89b4.json b/.maestro/tasks/contracts/c-7f89b4.json new file mode 100644 index 000000000..f83681a15 --- /dev/null +++ b/.maestro/tasks/contracts/c-7f89b4.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-7f89b4", + "taskId": "tsk-mvcjv3iq-y7wzar", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:41:54.476Z", + "lockedAt": "2026-10-17T15:41:54.476Z", + "intent": "r10-e6-2-fiber-registry", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-7c9c5f", + "text": "D8; `fiber_dump()` returns id, parent id, state, current span name, age for live fibers; entries vanish when the fiber ends. Tests `dump_lists_running_and_drops_finished`, `parent_links_follow_fork`. Bench gates and `kernel_alloc` green with the feature off and on.", + "kind": "manual" + }, + { + "id": "dw-c22aee", + "text": "Gate: `cargo nextest run -p id_effect --features fiber-registry`; `cargo nextest run -p id_effect --test kernel_alloc`; `scripts/bench-compare.sh` (no budget fail)", + "kind": "manual" + }, + { + "id": "dw-34dfaa", + "text": "Owns: `crates/id_effect/src/runtime/{fiber_context.rs,fiber_registry.rs}`, `crates/id_effect/Cargo.toml` (feature `fiber-registry`)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-84f283.json b/.maestro/tasks/contracts/c-84f283.json new file mode 100644 index 000000000..33d3447bb --- /dev/null +++ b/.maestro/tasks/contracts/c-84f283.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-84f283", + "taskId": "tsk-mvdh79dx-xb500e", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T07:00:15.424Z", + "lockedAt": "2026-10-18T07:00:15.424Z", + "intent": "r11-fuzz-ci", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-a27384", + "text": "A fuzz/ cargo-fuzz workspace (outside the main workspace, one pinned nightly date, 0.11.0.md D20) with at least 8 targets over public decoders whose input formats do not change in 0.11, chosen from: Schema decode from JSON (id_effect), typed-route path and query decode and form-body decode (id_effect_axum route/decode.rs), cron expression parse (id_effect scheduling), config profile TOML merge (id_effect_config), JWT header/claims decode (id_effect_auth jwt), JWKS document parse (id_effect_auth jwks), otpauth URI parse (id_effect_auth totp). The implementer confirms each target reaches only public API.", + "kind": "manual" + }, + { + "id": "dw-5390d6", + "text": "Seed corpora committed; .clusterfuzzlite/ (Dockerfile, build.sh, project.yaml); .github/workflows/cflite_pr.yml (PR mode, 5 minutes per target, fails on a new crash) and cflite_batch.yml (nightly batch with stored corpus), SHA-pinned with top-level permissions read; docs/security/fuzzing.md (how to run, how a crash becomes an issue and a regression test).", + "kind": "manual" + }, + { + "id": "dw-7740c2", + "text": "KPI: `ls fuzz/fuzz_targets/*.rs | wc -l` ≥ 8 at this leaf (≥ 10 after r11-e8-3-reliable-delivery adds mesh_frame and mesh_envelope); no open crash at exit.", + "kind": "manual" + }, + { + "id": "dw-d37e3f", + "text": "Gate: `cd fuzz && cargo +nightly- fuzz build`; `for t in $(cargo +nightly- fuzz list); do cargo +nightly- fuzz run $t -- -max_total_time=60; done` with no crash; `bash scripts/check-workflows.sh`", + "kind": "manual" + }, + { + "id": "dw-493192", + "text": "Owns: `fuzz/**`, `.clusterfuzzlite/**`, `.github/workflows/{cflite_pr.yml,cflite_batch.yml}`, `docs/security/fuzzing.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-851506.json b/.maestro/tasks/contracts/c-851506.json new file mode 100644 index 000000000..b12093233 --- /dev/null +++ b/.maestro/tasks/contracts/c-851506.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-851506", + "taskId": "tsk-mvbdsp4f-a1hau9", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:51:52.994Z", + "lockedAt": "2026-10-16T20:51:52.994Z", + "intent": "E3.2 id_effect_sql::migrate with advisory lock", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-5fc715", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-813d6e", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-3e6f1a", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-783d2a", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-f63f1d", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-1f7fac", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-76c9fc", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-8541de.json b/.maestro/tasks/contracts/c-8541de.json new file mode 100644 index 000000000..71ad2bdf2 --- /dev/null +++ b/.maestro/tasks/contracts/c-8541de.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-8541de", + "taskId": "tsk-mvbdsp4f-y1xx0b", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:31:59.204Z", + "lockedAt": "2026-10-16T20:31:59.204Z", + "intent": "E4.1 typed routes book chapter and example", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-08adda", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-1678b6", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-249bb0", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-f541f9", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-99f190", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-f4be57", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-b9ec6a", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-8595ef.json b/.maestro/tasks/contracts/c-8595ef.json new file mode 100644 index 000000000..fcbfc6922 --- /dev/null +++ b/.maestro/tasks/contracts/c-8595ef.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 2, + "id": "c-8595ef", + "taskId": "tsk-mvf4xf42-qjodki", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T10:52:16.361Z", + "lockedAt": "2026-10-19T10:52:16.361Z", + "intent": "r12-e12-2-axum-ws-sse", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-09bca3", + "text": "web.realtime#1: effect-native WebSocket endpoints (axum ws feature) on typed routes that declare public or guarded access (H8); the socket is a pull-based Stream plus a Sink inside a supervised fiber, so a dropped client interrupts its handler and runs finalizers.", + "kind": "manual" + }, + { + "id": "dw-090809", + "text": "web.realtime#2: an SSE helper with event ids and Last-Event-ID resume; web.realtime#8: heartbeats (ping every 20 s, two missed pongs close the socket) and an idle timeout, on the executor clock (TestClock in tests).", + "kind": "manual" + }, + { + "id": "dw-173bac", + "text": "web.realtime#6 (connect half) and ADR 0013 R9: authentication on connect through the existing Authenticate layer; the Origin header is checked on upgrade against a configured allowlist, an empty list refuses every browser origin, and dangerous_any_origin() records a posture opt-out; the dev profile allows http://localhost:* by configuration.", + "kind": "manual" + }, + { + "id": "dw-655cc3", + "text": "ADR 0013 R4: frame size 64 KiB (close 1009 above it), message rate per socket and sockets per client IP bounded by default (config).", + "kind": "manual" + }, + { + "id": "dw-4ae3a9", + "text": "id_effect_axum/Cargo.toml declares, once, the features ws, sse, tls, http3, webtransport and their optional dependencies (tokio-rustls, hyper-util http2, h3 =0.0.8, h3-quinn =0.0.10, h3-webtransport =0.1.2, quinn, id_effect_platform/tls and /quic) for r12-e12-3-tls-h2, r12-e12-3-h3-server and r12-e12-3-webtransport.", + "kind": "manual" + }, + { + "id": "dw-145a8f", + "text": "Tests `ws_round_trip_under_test_clock`, `sse_resumes_from_last_event_id`, `heartbeat_closes_dead_connection`, `dropped_socket_interrupts_handler`, `r9_ws_upgrade_from_foreign_origin_refused`, `r4_ws_frame_over_max_closes_1009`, `r4_ws_sockets_per_ip_capped`, `dangerous_any_origin_is_recorded`; examples 090_ws_echo.rs, 091_sse_ticker.rs; book part6/ch33-05-websocket-sse.md.", + "kind": "manual" + }, + { + "id": "dw-019f2b", + "text": "Gate: `cargo nextest run -p id_effect_axum --all-features`; `cargo run -p id_effect_axum --features ws --example 090_ws_echo -- --self-test`; `bash scripts/check-dangerous.sh`", + "kind": "manual" + }, + { + "id": "dw-4cf521", + "text": "Owns: `crates/id_effect_axum/Cargo.toml`, `crates/id_effect_axum/src/realtime/{mod.rs,ws.rs,sse.rs,origin.rs}`, `crates/id_effect_axum/tests/{ws.rs,sse.rs}`, `crates/id_effect_axum/examples/{090_ws_echo.rs,091_sse_ticker.rs}`, `crates/id_effect/book/src/part6/ch33-05-websocket-sse.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "b2-axum", + "lockedBy": "b2-axum", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-868c33.json b/.maestro/tasks/contracts/c-868c33.json new file mode 100644 index 000000000..254e7c875 --- /dev/null +++ b/.maestro/tasks/contracts/c-868c33.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 2, + "id": "c-868c33", + "taskId": "tsk-mvdgvhpd-9mxdxa", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:51:06.076Z", + "lockedAt": "2026-10-18T06:51:06.076Z", + "intent": "r11-e7-5-engine", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-17a5ee", + "text": "Design PR first: the Workflow trait, WorkflowContext and the Postgres schema are reviewed before r11-e7-5-activities-timers starts (the PR link is the first evidence item).", + "kind": "manual" + }, + { + "id": "dw-630b84", + "text": "Workflow trait (Schema-encoded input and output, name, a version string recorded on each instance); WorkflowContext::step(name, effect) journals each result and on resume returns the journaled result without re-running the step (fault.workflows#2); ctx.now() and ctx.random() are journaled (0.11.0.md D7); a replay whose step name or order differs from the history fails with a typed NonDeterministic error.", + "kind": "manual" + }, + { + "id": "dw-fef57b", + "text": "Postgres schema (migrations/0001): workflow_instances (id, name, status, input, output, version, lease_owner, lease_expires_at, fencing_token) and workflow_history (instance_id, seq, kind, payload, unique (instance_id, seq)); state survives a process restart (#1).", + "kind": "manual" + }, + { + "id": "dw-f210e2", + "text": "Multi-writer (D7): N engines run against one database; an instance advances only under its lease; every history append checks the fencing token, so a stale writer whose lease expired cannot append; an expired lease is taken over and the instance resumes from history on another engine.", + "kind": "manual" + }, + { + "id": "dw-2476e8", + "text": "Built on id_effect_sql SqlClient/SqlTransaction (0.11.0.md D6; no duroxide, no SQLite store); MemoryWorkflowStore for unit tests; start, status and result APIs.", + "kind": "manual" + }, + { + "id": "dw-90859f", + "text": "Tests `replay_returns_journaled_results_without_rerunning_steps`, `two_engines_never_advance_one_instance_concurrently`, `stale_writer_append_is_rejected_by_fencing`, `crashed_engine_instance_resumes_elsewhere`, `nondeterministic_replay_is_detected`.", + "kind": "manual" + }, + { + "id": "dw-a435dd", + "text": "Gate: `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p id_effect_workflow --all-features` (Postgres required)", + "kind": "manual" + }, + { + "id": "dw-ad5818", + "text": "Owns: `crates/id_effect_workflow/Cargo.toml`, `crates/id_effect_workflow/src/{lib,workflow,engine,history,lease,memory,pg,error}.rs`, `crates/id_effect_workflow/migrations/0001_*`, `crates/id_effect_workflow/tests/{engine_pg.rs,replay.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-88e200.json b/.maestro/tasks/contracts/c-88e200.json new file mode 100644 index 000000000..2775ba83e --- /dev/null +++ b/.maestro/tasks/contracts/c-88e200.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 2, + "id": "c-88e200", + "taskId": "tsk-mvbanfma-w9xz64", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T18:21:20.114Z", + "lockedAt": "2026-10-16T18:21:20.114Z", + "intent": "0.8 W3-F — tutorial and DX KPIs", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-c682e0", + "text": "Book page crates/id_effect/book/src/tutorial-zero-to-production.md (report the SUMMARY line; placed near the start of the book): install id-effect, `id-effect new` a service, run it, add a route with a typed error and a capability, add a test, add --db pg and a migration, run `id-effect doctor` and `--describe-layers`, build the Docker image, deploy config; every Rust step compiled via examples/book-snippets (or verified by a CI script that executes the tutorial steps).", + "kind": "manual" + }, + { + "id": "dw-c1c9b8", + "text": "scripts/dx-kpis.sh (bash, Requires header, exit 1 FAIL / 2 misuse, --self-test, --json) measures K1 (wall time from `id-effect new svc --template service --local-workspace` to GET /health = 200, cold build in a fresh target dir; budget 180 s for 0.8), K4 (gallery cases in crates/id_effect/tests/ui/gallery whose first error carries a fix note per docs/diagnostics.md — 17/20 expected, owner-accepted), K5 (compiled rust fences / all rust fences in the book, counting the book_snippets harness and the id_effect doctest includes; >= 80 %); writes docs/benchmarks/dx-kpis-0.8.json; a moon task dx-kpis (not in default CI path).", + "kind": "manual" + }, + { + "id": "dw-19e500", + "text": "Stale docs corrected: crates/id_effect/book/src/part6/ch34-00-dx-ship.md (no longer \"no id-effect new\", no deploy-kamal path), docs/platform/PHASE_E_CLI_PARITY.md (\"No app generator\" / id-effect-diagnose references); the orphaned crates/id_effect_cli/templates/admin-stub removed or documented as intentionally kept.", + "kind": "manual" + }, + { + "id": "dw-8a1b64", + "text": "cargo test --doc -p book_snippets green; moon run :book green; shellcheck and --self-test for dx-kpis.sh; measured K1/K4/K5 reported with machine info.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-8be221.json b/.maestro/tasks/contracts/c-8be221.json new file mode 100644 index 000000000..15b24c275 --- /dev/null +++ b/.maestro/tasks/contracts/c-8be221.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 2, + "id": "c-8be221", + "taskId": "tsk-mvb8iifv-1kp3tn", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T17:21:36.254Z", + "lockedAt": "2026-10-16T17:21:36.254Z", + "intent": "0.8 W3-D — diagnostics gallery", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-35428f", + "text": "crates/id_effect/tests/ui/gallery/: the 20 most likely mistakes when writing id_effect application code (missing capability, wrong provide, non-Send body, generic runner unannotated, missing retry factory, Layer output missing, effect! misuse, Never/Or error mismatches, block_on in async, etc.), each a trybuild fail case with a blessed .stderr on the pinned rustc (ui.rs BLESSED_RUSTC), registered in tests/ui.rs; the existing 9 fail + 4 pass cases still pass.", + "kind": "manual" + }, + { + "id": "dw-a77d5b", + "text": "K4 rule (owner decision 2026-10-16): a case counts when its first error carries a note (ours via #[diagnostic::on_unimplemented] note=, or rustc's help) that states the concrete fix. Sweep the 7 existing on_unimplemented attributes (capability/hlist.rs, kernel/bind.rs, kernel/macro_support.rs, layer.rs) and proc-macro error texts so every gallery case meets the rule; all 20 meet it.", + "kind": "manual" + }, + { + "id": "dw-bf4c1e", + "text": "docs/diagnostics.md (or a book appendix page; report the SUMMARY line) indexes each case: the mistake, the error, the fix.", + "kind": "manual" + }, + { + "id": "dw-c31417", + "text": "cargo test -p id_effect --test ui on the pinned rustc green; clippy -D warnings and docs green.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-8c758a.json b/.maestro/tasks/contracts/c-8c758a.json new file mode 100644 index 000000000..7b513a907 --- /dev/null +++ b/.maestro/tasks/contracts/c-8c758a.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-8c758a", + "taskId": "tsk-mvbdsp4f-wd3fai", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T19:49:46.913Z", + "lockedAt": "2026-10-16T19:49:46.913Z", + "intent": "E3.1a SqlValue and typed SqlRow (design review first)", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-9208c4", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-99371c", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-efbae7", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-1d8f83", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-c33ada", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-eab2e0", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-ca4207", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-8d23e8.json b/.maestro/tasks/contracts/c-8d23e8.json new file mode 100644 index 000000000..7e47b2c95 --- /dev/null +++ b/.maestro/tasks/contracts/c-8d23e8.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-8d23e8", + "taskId": "tsk-mveorm3d-m113ii", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:19:53.422Z", + "lockedAt": "2026-10-19T03:19:53.422Z", + "intent": "r11-sec-csrf-headers", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-023ab9", + "text": "M4 (layer part): the CSRF layer reads a form body only for form content types, up to a configurable CsrfConfig::form_limit (default 64 KiB instead of FORM_LIMIT 2 MiB at crates/id_effect_axum/src/server/security/csrf.rs:40) and under a read timeout (default 10 s) at :225, independent of the order of other layers.", + "kind": "manual" + }, + { + "id": "dw-d2250b", + "text": "L1 (BREACH): the token handed to pages and headers is masked per response (one-time pad XOR, base64url) and unmasked before the constant-time compare; MIGRATION-0.11 section \"masked CSRF token format\".", + "kind": "manual" + }, + { + "id": "dw-ac0408", + "text": "L5 (API): CsrfToken::rotate issues a new token and invalidates the old one; r11-sec-auth-state calls it at login.", + "kind": "manual" + }, + { + "id": "dw-122ebc", + "text": "L2 (headers): SecurityHeaders::recommended() (headers.rs:41-49) adds Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Resource-Policy: same-origin and a deny-list Permissions-Policy (camera, microphone, geolocation, payment, usb), each overridable.", + "kind": "manual" + }, + { + "id": "dw-b95d7e", + "text": "Regression tests `m4_csrf_form_read_respects_limit`, `m4_csrf_form_read_times_out`, `l1_csrf_token_masked_differently_per_response`, `l1_masked_token_validates`, `l5_rotate_issues_new_token_and_invalidates_old`, `l2_recommended_headers_include_coop_corp_permissions_policy`.", + "kind": "manual" + }, + { + "id": "dw-43edbc", + "text": "Gate: `cargo nextest run -p id_effect_axum --all-features`", + "kind": "manual" + }, + { + "id": "dw-e79746", + "text": "Owns: `crates/id_effect_axum/src/server/security/**`, `crates/id_effect_axum/tests/security_defaults.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sec-csrf-headers", + "lockedBy": "r11-sec-csrf-headers", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-8d39d3.json b/.maestro/tasks/contracts/c-8d39d3.json new file mode 100644 index 000000000..a89e77d0e --- /dev/null +++ b/.maestro/tasks/contracts/c-8d39d3.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 2, + "id": "c-8d39d3", + "taskId": "tsk-mvawkapy-nam47g", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T11:47:22.464Z", + "lockedAt": "2026-10-16T11:47:22.464Z", + "intent": "0.8 E0.2 security process", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-4a3a66", + "text": "SECURITY.md: private reporting via GitHub security advisories, response times, supported-versions table and support window, advisory (GHSA/RUSTSEC) publication process.", + "kind": "manual" + }, + { + "id": "dw-21e257", + "text": "deny.toml (licenses allow-list derived from cargo deny check licenses on the main workspace, bans, sources; carries the RUSTSEC-2023-0071 ignore from .cargo/audit.toml); cargo deny check exits 0; a deny job in .github/workflows/ci.yml listed in ci-pass.needs; moon task deny; cargo-deny in devenv.nix.", + "kind": "manual" + }, + { + "id": "dw-186596", + "text": "publish.yml: a CycloneDX SBOM (cargo cyclonedx) per release attached to the GitHub release, and actions/attest-build-provenance over the packaged .crate files and SBOMs with permissions id-token: write, attestations: write scoped to that job; cargo-cyclonedx in devenv.nix; actionlint clean on changed workflows.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvawbp5i-g5m6ot", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-8e6ced.json b/.maestro/tasks/contracts/c-8e6ced.json new file mode 100644 index 000000000..1cedbd46d --- /dev/null +++ b/.maestro/tasks/contracts/c-8e6ced.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-8e6ced", + "taskId": "tsk-mvcjv38n-qmey5i", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T17:07:33.829Z", + "lockedAt": "2026-10-17T17:07:33.829Z", + "intent": "r10-e4-3-tracing", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-5183ec", + "text": "`OtelConfig.sampler` (always/never/ratio/parent-based) and `OTEL_TRACES_SAMPLER(_ARG)` (#7); `TracedHttpClient` (feature `platform`) opens a client span and calls `inject_into_http_request` (web.client#6, obs.tracing#6); `id_effect_sql` emits a `db.query` span with `db.system` and a parameter-free statement, bridged by `OtelSpanHook` (#6); `axum.rs` uses `.instrument(span)` instead of `span.enter()` across `.await`; `metrics.rs` doc links build without `pg`. Tests with the in-memory exporter `client_span_is_child_and_sends_traceparent`, `db_query_creates_span`, `ratio_sampler_from_env`, `server_span_not_held_across_await`. Book section in `ch27-00-observability.md`.", + "kind": "manual" + }, + { + "id": "dw-28dc2a", + "text": "Gate: `cargo nextest run -p id_effect_opentelemetry -p id_effect_sql --all-features`; `cargo doc -p id_effect_sql --no-deps` with `RUSTDOCFLAGS=-D warnings`", + "kind": "manual" + }, + { + "id": "dw-73c991", + "text": "Owns: `crates/id_effect_opentelemetry/src/{config.rs,otlp.rs,client.rs,axum.rs,span_bridge.rs}`, `crates/id_effect_opentelemetry/Cargo.toml`, `crates/id_effect_sql/src/metrics.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-8ec8e1.json b/.maestro/tasks/contracts/c-8ec8e1.json new file mode 100644 index 000000000..fa87b0ff0 --- /dev/null +++ b/.maestro/tasks/contracts/c-8ec8e1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-8ec8e1", + "taskId": "tsk-mvcjv35b-3d8syc", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:40:03.075Z", + "lockedAt": "2026-10-17T15:40:03.075Z", + "intent": "r10-e4-3-client-defaults", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f337ae", + "text": "`RetryingHttpClient` decorator over a core `Schedule` per D10, installed by the provider layer (`:388-391`) and configured once (#4); `default_client` sets connect/total timeouts. Tests (wiremock) `retries_idempotent_get_then_succeeds`, `post_not_retried_by_default`, `retry_after_is_honoured`, `default_timeout_applies`.", + "kind": "manual" + }, + { + "id": "dw-6ac0b2", + "text": "Gate: `cargo nextest run -p id_effect_platform --all-features`", + "kind": "manual" + }, + { + "id": "dw-0de9ce", + "text": "Owns: `crates/id_effect_platform/src/http/{mod.rs,retry.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-8f8103.json b/.maestro/tasks/contracts/c-8f8103.json new file mode 100644 index 000000000..98c790b28 --- /dev/null +++ b/.maestro/tasks/contracts/c-8f8103.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-8f8103", + "taskId": "tsk-mvcjv3sy-7786hr", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T17:17:46.502Z", + "lockedAt": "2026-10-17T17:17:46.502Z", + "intent": "r10-e6-5b-guide", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-fa4b48", + "text": "Guide (#8) covering latency, seeded plans, partitions and kills (existing #3 #4); example; integration test.", + "kind": "manual" + }, + { + "id": "dw-c6b451", + "text": "Gate: `cargo test --doc -p book_snippets`; same nextest", + "kind": "manual" + }, + { + "id": "dw-ed36da", + "text": "Owns: `book/src/part4/ch15-09-fault-injection.md` + `SUMMARY.md` line, `crates/id_effect_test/examples/020_faults.rs`, `crates/id_effect_test/tests/faults.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-9135db.json b/.maestro/tasks/contracts/c-9135db.json new file mode 100644 index 000000000..27e06f324 --- /dev/null +++ b/.maestro/tasks/contracts/c-9135db.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 2, + "id": "c-9135db", + "taskId": "tsk-mvepzuqe-owgjul", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:54:14.774Z", + "lockedAt": "2026-10-19T03:54:14.774Z", + "intent": "r11-e8-3-k8s-discovery", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f29018", + "text": "dist.membership#4 (0.11.0.md D18): KubernetesDiscovery under feature kubernetes lists the ready addresses of a headless service through the in-cluster EndpointSlice API over rustls, with the service-account token and CA from /var/run/secrets/kubernetes.io/serviceaccount and the namespace from its file; no kube-rs; implements the Discovery trait (crates/id_effect_node/src/discovery.rs:14) beside StaticDiscovery (:21) and DnsDiscovery (:43) and composes with ChainedDiscovery (:69).", + "kind": "manual" + }, + { + "id": "dw-258507", + "text": "Tests against an in-process fake API server: `kubernetes_discovery_lists_ready_endpoints`, `kubernetes_discovery_skips_not_ready`, `kubernetes_discovery_uses_service_account_token`, `kubernetes_discovery_rejects_untrusted_api_cert`; book section in part7/ch38-00-distribution.md.", + "kind": "manual" + }, + { + "id": "dw-9d13e0", + "text": "Gate: `cargo nextest run -p id_effect_node --features kubernetes --test discovery_kubernetes`; `cargo nextest run -p id_effect_node --all-features`", + "kind": "manual" + }, + { + "id": "dw-2b7303", + "text": "Owns: `crates/id_effect_node/src/discovery.rs`, `crates/id_effect_node/tests/discovery_kubernetes.rs`, `crates/id_effect/book/src/part7/ch38-00-distribution.md` (section)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e8-3-k8s-discovery", + "lockedBy": "r11-e8-3-k8s-discovery", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-94ed0c.json b/.maestro/tasks/contracts/c-94ed0c.json new file mode 100644 index 000000000..66db0a2b2 --- /dev/null +++ b/.maestro/tasks/contracts/c-94ed0c.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 2, + "id": "c-94ed0c", + "taskId": "tsk-mvawkapy-h9kogq", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T11:47:22.680Z", + "lockedAt": "2026-10-16T11:47:22.680Z", + "intent": "0.8 E0.3 semver-checks and stability policy", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-d0e894", + "text": "A semver job in .github/workflows/ci.yml runs cargo semver-checks for the published crates against the 0.7.0 registry baseline; intentional 0.8 breaks are allowed only when listed in docs/MIGRATION-0.8.md (the job compares, or is report-only until the 0.8 tag and enforcing after it — documented in the job); job listed in ci-pass.needs; moon task semver; cargo-semver-checks in devenv.nix.", + "kind": "manual" + }, + { + "id": "dw-3e136f", + "text": "docs/STABILITY.md (linked from README and QUALITY_BAR): deprecation rule (one-minor #[deprecated] shim + migration entry), semver policy before/after 1.0, MSRV policy stated as \"at most N-2 stable\" consistent with rust-version = 1.94 and the msrv CI job.", + "kind": "manual" + }, + { + "id": "dw-3b262a", + "text": "docs/MIGRATION-0.8.md created (header + sections that wave leaves append to).", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvawbp5i-g5m6ot", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-9572b2.json b/.maestro/tasks/contracts/c-9572b2.json new file mode 100644 index 000000000..2e952ef3e --- /dev/null +++ b/.maestro/tasks/contracts/c-9572b2.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-9572b2", + "taskId": "tsk-mvcjv3fd-36woc8", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:41:54.618Z", + "lockedAt": "2026-10-17T15:41:54.618Z", + "intent": "r10-e4-4-storage", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-1280e8", + "text": "`ObjectStore` capability over object_store (put/get/head/delete/list); streaming upload/download as `Stream` without buffering the object (#5); S3 backend (#3); presigned URLs for S3 and HMAC-signed expiring URLs for the local backend (#7); local FS and in-memory backends. `cargo deny check` green for object_store's tree. Tests `stream_upload_never_buffers_whole_object`, `signed_url_expires`, `memory_and_local_conform`.", + "kind": "manual" + }, + { + "id": "dw-5f042a", + "text": "Gate: `cargo nextest run -p id_effect_storage --all-features`; `cargo deny check`", + "kind": "manual" + }, + { + "id": "dw-dff6f1", + "text": "Owns: `crates/id_effect_storage/src/{lib.rs,store.rs,local.rs,memory.rs,s3.rs,signed.rs}`, `crates/id_effect_storage/Cargo.toml`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-9594b9.json b/.maestro/tasks/contracts/c-9594b9.json new file mode 100644 index 000000000..3538c9876 --- /dev/null +++ b/.maestro/tasks/contracts/c-9594b9.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-9594b9", + "taskId": "tsk-mvbh3z49-hbvk3j", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:22:21.413Z", + "lockedAt": "2026-10-16T21:22:21.413Z", + "intent": "r09-e6-5-containers", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-e22be4", + "text": "Effect-native `Postgres/MySql/Redis/Keycloak/Vault` containers as scoped Layers (testcontainers), torn down on scope exit or interrupt (#2).", + "kind": "manual" + }, + { + "id": "dw-0ddb85", + "text": "Gate: `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p id_effect_test`", + "kind": "manual" + }, + { + "id": "dw-e43c4c", + "text": "Owns: `crates/id_effect_test/src/containers/**`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-960273.json b/.maestro/tasks/contracts/c-960273.json new file mode 100644 index 000000000..4dcf52060 --- /dev/null +++ b/.maestro/tasks/contracts/c-960273.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-960273", + "taskId": "tsk-mveuyqcl-h6cafd", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T06:13:22.252Z", + "lockedAt": "2026-10-19T06:13:22.252Z", + "intent": "r11-w3-ledger", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-051666", + "text": "The Ledger (built only through id_effect_kit) passes the prod posture check with zero accepted risks; every route is public or guarded and an access-report snapshot test lists the public routes; the console is off in prod and on in dev behind its guard; the SendGrid host is allowlisted in the SSRF policy; no RawSqlCapability is provided; `cargo dylint` with raw_sql_non_literal is clean on the Ledger.", + "kind": "manual" + }, + { + "id": "dw-68cde7", + "text": "Account closure runs as a durable workflow: an approval signal, a 30-day grace timer (under the test clock), a statement-export activity on the job queue and saga compensation; this makes the Ledger the reference-app consumer of id_effect_workflow, whose tier moves to platform (check P).", + "kind": "manual" + }, + { + "id": "dw-7c2875", + "text": "Tests `ledger_prod_posture_is_clean`, `ledger_access_report_snapshot`, `h8_ledger_unregistered_route_denied`, `m6_ledger_console_disabled_in_prod_profile`, `account_closure_workflow_survives_restart`; the reference-ledger CI job runs them with Postgres, Redis, Mailpit and the S3 container.", + "kind": "manual" + }, + { + "id": "dw-b2a21d", + "text": "Gate: `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p ledger` (Docker and Postgres required); `bash scripts/tier-conformance.sh` (all PASS)", + "kind": "manual" + }, + { + "id": "dw-e4d06b", + "text": "Owns: `examples/reference/ledger/**`, `.github/workflows/ci.yml` (reference-ledger job), the tier line in `crates/id_effect_workflow/Cargo.toml`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-w3-ledger", + "lockedBy": "r11-w3-ledger", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-97a670.json b/.maestro/tasks/contracts/c-97a670.json new file mode 100644 index 000000000..6c5869c39 --- /dev/null +++ b/.maestro/tasks/contracts/c-97a670.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-97a670", + "taskId": "tsk-mvcrl10t-m63g70", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T22:28:30.551Z", + "lockedAt": "2026-10-17T22:28:30.551Z", + "intent": "r10-w3-kpis", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-68eb93", + "text": "K2 ≤ 8 s, K6 recorded, K3 re-measured with the job (§8 definition), K1 on an idle machine (0.9 follow-up).", + "kind": "manual" + }, + { + "id": "dw-89eaa5", + "text": "Gate: `DX_KPIS_RELEASE=0.10 bash scripts/dx-kpis.sh`", + "kind": "manual" + }, + { + "id": "dw-b5180c", + "text": "Owns: `docs/benchmarks/dx-kpis-0.10.json`, `docs/benchmarks/k3/invoice.patch` (adds the job)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-98539c.json b/.maestro/tasks/contracts/c-98539c.json new file mode 100644 index 000000000..7a759bb62 --- /dev/null +++ b/.maestro/tasks/contracts/c-98539c.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-98539c", + "taskId": "tsk-mvepyvw7-ohtsdc", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:53:30.643Z", + "lockedAt": "2026-10-19T03:53:30.643Z", + "intent": "r11-sec-auth-state", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-8cc86f", + "text": "M18: PgTotpStepStore (features pg + totp) implements TotpStepStore (crates/id_effect_auth/src/totp.rs:236-240; MemoryTotpStepStore at :251 is the only implementation today) with one atomic compare-and-set statement; migration 0003 creates its table; using MemoryTotpStepStore under the prod profile is reported by the posture check as memory-only security state.", + "kind": "manual" + }, + { + "id": "dw-0a4d2a", + "text": "A Postgres store for the 0.10.1 LoginThrottle trait (H5), so throttling is shared across instances.", + "kind": "manual" + }, + { + "id": "dw-2ecca3", + "text": "L5: the session login path (session/manager.rs) rotates the session id and calls CsrfToken::rotate (from r11-sec-csrf-headers) on successful login.", + "kind": "manual" + }, + { + "id": "dw-fdd049", + "text": "Regression tests `m18_pg_totp_replay_rejected_across_instances`, `m18_pg_totp_concurrent_consume_one_wins`, `pg_login_throttle_shared_across_instances`, `l5_csrf_token_rotates_at_login`, `l5_session_id_rotates_at_login`.", + "kind": "manual" + }, + { + "id": "dw-2365bc", + "text": "Gate: `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p id_effect_auth --all-features` (Postgres required)", + "kind": "manual" + }, + { + "id": "dw-6ee569", + "text": "Owns: `crates/id_effect_auth/{src/totp.rs,src/session/manager.rs,migrations/0003_*,Cargo.toml,tests/auth_state.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sec-auth-state", + "lockedBy": "r11-sec-auth-state", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-9b461a.json b/.maestro/tasks/contracts/c-9b461a.json new file mode 100644 index 000000000..2fbd73c13 --- /dev/null +++ b/.maestro/tasks/contracts/c-9b461a.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-9b461a", + "taskId": "tsk-mvcpey7z-zm5f97", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:06:10.523Z", + "lockedAt": "2026-10-17T18:06:10.523Z", + "intent": "r10-e6-2-console-api", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-c62a60", + "text": "`Console::router(guard: Guard)` — no unguarded constructor; JSON under `/_console/api/`: `fibers` (#1), `supervisors` from `which_children` + `TreeNodeView` (#2), `metrics`, `health`, `log-level` GET/PUT changing the `EnvFilter` through a reload layer and the `EffectLogger` minimum level (#6). Tests `unauthorised_is_403`, `fiber_dump_lists_running_fiber`, `supervisor_tree_matches_which_children`, `log_level_change_takes_effect_without_restart`.", + "kind": "manual" + }, + { + "id": "dw-1a6409", + "text": "Gate: `cargo nextest run -p id_effect_console -p id_effect_opentelemetry --all-features`", + "kind": "manual" + }, + { + "id": "dw-2682b8", + "text": "Owns: `crates/id_effect_console/src/{lib.rs,api.rs,supervisors.rs,log_level.rs}`, `crates/id_effect_opentelemetry/src/subscriber.rs` (reload handle)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-9d2ff0.json b/.maestro/tasks/contracts/c-9d2ff0.json new file mode 100644 index 000000000..5c195232f --- /dev/null +++ b/.maestro/tasks/contracts/c-9d2ff0.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-9d2ff0", + "taskId": "tsk-mvbh3yap-l09oxk", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T22:13:18.860Z", + "lockedAt": "2026-10-16T22:13:18.860Z", + "intent": "r09-e3-4-redis", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-ea0212", + "text": "`RedisLayer` (pooled connection manager); typed KV/hash/JSON store API (D6); `CacheBackend` implemented by the in-process cache and by Redis so `Cache` code runs on either (#2); `RedisSessionStore: SessionStore`; rate-limit store for E4.1b; pub/sub as `Stream`. Tests against a Redis container. Book `ch28-04-redis.md`, example.", + "kind": "manual" + }, + { + "id": "dw-2ae36a", + "text": "Gate: `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p id_effect_redis`; tier-conformance", + "kind": "manual" + }, + { + "id": "dw-7fb113", + "text": "Owns: `crates/id_effect_redis/**`; `crates/id_effect/src/resource/cache.rs` (additive `CacheBackend` trait)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-9f4505.json b/.maestro/tasks/contracts/c-9f4505.json new file mode 100644 index 000000000..b095da8f9 --- /dev/null +++ b/.maestro/tasks/contracts/c-9f4505.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 2, + "id": "c-9f4505", + "taskId": "tsk-mvf4xf8t-hyoe72", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T10:52:16.481Z", + "lockedAt": "2026-10-19T10:52:16.481Z", + "intent": "r12-e12-3-quic-endpoint", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-050448", + "text": "D6: id_effect_platform features tls (certificates, Identity, rustls client/server configs, moved from crates/id_effect_node/src/transport/certs.rs:24-597 with history) and quic (QuicEndpoint with an AlpnRouter for h3 and idm/1); id_effect_node::transport::certs stays as a #[deprecated(note = \"use id_effect_platform::tls\")] re-export for one minor (L9); node features quic and tls-tcp depend on id_effect_platform with default-features = false; no dependency cycle (`cargo tree -i id_effect_node -p id_effect_platform` is empty).", + "kind": "manual" + }, + { + "id": "dw-86f865", + "text": "Endpoint defaults (ADR 0013 R4, R8): address validation (Retry) on, 0-RTT off, finite max concurrent bidi/uni streams, idle timeout, max UDP payload and receive window; each loosening is a dangerous_* opt-out recorded in the posture registry.", + "kind": "manual" + }, + { + "id": "dw-6b9e0f", + "text": "D7: when idm/1 and h3 share one endpoint, client certificates are requested as optional with no CA hints, a presented certificate is still verified against the cluster CA, and the idm/1 handler refuses a connection without a verified client chain before reading any frame; quic.mesh_port (config) gives the mesh its own endpoint with handshake-level mutual TLS as in 0.11.", + "kind": "manual" + }, + { + "id": "dw-ae5fb0", + "text": "The mesh QuicTransport runs on a QuicEndpoint (QuicTransport::on_endpoint) and requires ALPN idm/1; the 0.11 tests `mtls_rejects_peer_without_client_cert`, `mtls_rejects_cert_from_other_ca`, `pem_chain_and_key_load`, `rotated_cert_used_for_new_connections` pass on both shapes (dist.tls #2 #3 #4 #5 #7 kept).", + "kind": "manual" + }, + { + "id": "dw-bb8f3c", + "text": "id_effect_platform/Cargo.toml declares, once, features tls, quic, http3, acme and their deps (rustls 0.23.46+, quinn 0.11.12+ on aws-lc-rs, rcgen, h3 =0.0.8, h3-quinn =0.0.10, instant-acme 0.8) for r12-e12-3-h3-client and r12-e12-3-certs-acme; one quinn and one rustls in `cargo tree -d`.", + "kind": "manual" + }, + { + "id": "dw-2e6201", + "text": "Tests `alpn_routes_h3_and_idm`, `idm_without_client_cert_refused_on_shared_endpoint`, `idm_cert_from_other_ca_refused_on_shared_endpoint`, `unknown_alpn_refused`, `r4_quic_retry_on_by_default`, `r4_quic_limits_finite_by_default`, `mesh_port_splits_endpoint`, `deprecated_node_certs_path_still_compiles`; book part7/ch38-01-transport-security.md gains a \"Shared endpoint\" section.", + "kind": "manual" + }, + { + "id": "dw-a4b3e4", + "text": "Gate: `cargo nextest run -p id_effect_platform -p id_effect_node --all-features`; `cargo run -p id_effect_node --features quic --example 134_mtls_cluster`; `cargo deny check`; `cargo tree -d -p id_effect_node --all-features` lists no second quinn or rustls", + "kind": "manual" + }, + { + "id": "dw-c3bb40", + "text": "Owns: `crates/id_effect_platform/Cargo.toml`, `crates/id_effect_platform/src/tls/{mod.rs,certs.rs}`, `crates/id_effect_platform/src/quic/**`, `crates/id_effect_platform/tests/quic_endpoint.rs`, `crates/id_effect_node/Cargo.toml`, `crates/id_effect_node/src/transport/{certs.rs,quic.rs,mod.rs}`, `crates/id_effect_node/tests/{transport_tls.rs,shared_endpoint.rs}`, `crates/id_effect/book/src/part7/ch38-01-transport-security.md` (section)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "b3-quic", + "lockedBy": "b3-quic", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-a07b64.json b/.maestro/tasks/contracts/c-a07b64.json new file mode 100644 index 000000000..557a32b67 --- /dev/null +++ b/.maestro/tasks/contracts/c-a07b64.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-a07b64", + "taskId": "tsk-mvet31ys-gowf3l", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T05:20:45.689Z", + "lockedAt": "2026-10-19T05:20:45.689Z", + "intent": "r11-w3-kit-template", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-7b6474", + "text": "id_effect_kit features workflow (id_effect_workflow), mesh-mtls and kubernetes; full updated; the prelude exposes id_effect::stm.", + "kind": "manual" + }, + { + "id": "dw-64ec09", + "text": "ADR 0013 R10: id_effect_kit::posture::check(profile) returns a PostureReport with named probes — console guarded, docs closed, mesh transport encrypted, redaction policy non-empty, SSRF guard on, CSRF on, load shedding on, no memory-only security state, and no recorded dangerous opt-out that is not listed in config `security.accepted_risks` with a reason; under the prod profile startup fails on any failed probe; dev and test profiles report only.", + "kind": "manual" + }, + { + "id": "dw-7cd9bb", + "text": "The service, worker and cluster templates call the posture check at startup; the cluster template configures mTLS with PEM paths (dev certificates for local runs); the service template passes the prod posture check as generated.", + "kind": "manual" + }, + { + "id": "dw-a5db2d", + "text": "scripts/check-dangerous.sh proves every `pub fn dangerous_*` / `insecure_*` under crates/ calls id_effect::observability::posture::record_dangerous, with --self-test; added as one step to .github/workflows/lint.yml.", + "kind": "manual" + }, + { + "id": "dw-5a98c1", + "text": "Tests `posture_prod_fails_on_unaccepted_opt_out`, `posture_accepted_risk_needs_reason`, `posture_report_lists_probes`, `template_service_passes_prod_posture`.", + "kind": "manual" + }, + { + "id": "dw-3507b7", + "text": "Gate: `cargo nextest run -p id_effect_kit --all-features`; `bash scripts/template-smoke.sh`; `bash scripts/check-dangerous.sh --self-test && bash scripts/check-dangerous.sh`; `DX_KPIS_RELEASE=0.11 bash scripts/dx-kpis.sh` K2 ≤ 8 s", + "kind": "manual" + }, + { + "id": "dw-6f0771", + "text": "Owns: `crates/id_effect_kit/**` (incl. new `src/posture.rs`, `tests/posture.rs`), `crates/id_effect_tool/templates/**`, `scripts/check-dangerous.sh`, one step in `.github/workflows/lint.yml`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-w3-kit-template", + "lockedBy": "r11-w3-kit-template", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-a3cc1c.json b/.maestro/tasks/contracts/c-a3cc1c.json new file mode 100644 index 000000000..2c3084112 --- /dev/null +++ b/.maestro/tasks/contracts/c-a3cc1c.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-a3cc1c", + "taskId": "tsk-mvfc5h86-z8ef14", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T14:14:31.141Z", + "lockedAt": "2026-10-19T14:14:31.141Z", + "intent": "r12-e12-2-mesh-rpc", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-7f5516", + "text": "The same #[rpc::service] contract is callable node to node: an RPC endpoint process per service runs under a cluster-wide name (0.11 global registry), the client resolves it and calls over the mesh with postcard encoding; unary calls, server streams and two-way streams (credit-based) work.", + "kind": "manual" + }, + { + "id": "dw-62e8a8", + "text": "Spans: the caller fiber trace context becomes the parent of the handler span on the remote node (the 0.11 dist.remote#8 mechanism), and the rpc.request span carries the service, procedure and node.", + "kind": "manual" + }, + { + "id": "dw-5c8b24", + "text": "An endpoint process restart under its supervisor is transparent to callers (re-resolve on noproc, bounded retry for queries only).", + "kind": "manual" + }, + { + "id": "dw-4c9252", + "text": "No edits to crates/id_effect_node (the endpoint is a Process behaviour registered through public node APIs).", + "kind": "manual" + }, + { + "id": "dw-93f172", + "text": "Tests `mesh_unary_and_stream_round_trip`, `mesh_call_carries_trace_context`, `mesh_endpoint_restart_is_transparent_to_callers`, `mesh_mutation_not_retried_on_noproc`; book part7/ch38-02-rpc-over-the-mesh.md.", + "kind": "manual" + }, + { + "id": "dw-208197", + "text": "Gate: `cargo nextest run -p id_effect_rpc --features mesh`", + "kind": "manual" + }, + { + "id": "dw-2bf6e9", + "text": "Owns: `crates/id_effect_rpc/src/{transport/mesh.rs,client/mesh.rs}`, `crates/id_effect_rpc/tests/mesh_rpc.rs`, `crates/id_effect/book/src/part7/ch38-02-rpc-over-the-mesh.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "e2-mesh-rpc", + "lockedBy": "e2-mesh-rpc", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-a51e96.json b/.maestro/tasks/contracts/c-a51e96.json new file mode 100644 index 000000000..94848bd24 --- /dev/null +++ b/.maestro/tasks/contracts/c-a51e96.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-a51e96", + "taskId": "tsk-mvbh3yki-lxt7mh", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:22:21.047Z", + "lockedAt": "2026-10-16T21:22:21.047Z", + "intent": "r09-e4-2-openapi-doc", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3c7863", + "text": "`OpenApi::from_router(&router)` builds a 3.1 document whose component schemas are `to_json_schema` of the same Schemas the extractors decode with (#4); `securitySchemes` from declared auth (bearer JWT, cookie session, OIDC) (#7). Test validates the output against the OpenAPI 3.1 meta-schema and asserts schema identity with `json_schema_for::()`.", + "kind": "manual" + }, + { + "id": "dw-633d13", + "text": "Gate: `cargo nextest run -p id_effect_axum --features openapi`", + "kind": "manual" + }, + { + "id": "dw-e6b318", + "text": "Owns: `crates/id_effect_axum/src/openapi/**` (feature `openapi`)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-abad9b.json b/.maestro/tasks/contracts/c-abad9b.json new file mode 100644 index 000000000..0be8859b2 --- /dev/null +++ b/.maestro/tasks/contracts/c-abad9b.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-abad9b", + "taskId": "tsk-mvbdsp4f-5hrfbn", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:08:55.491Z", + "lockedAt": "2026-10-16T20:08:55.491Z", + "intent": "E5.3 route guards and authz testing", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-e06248", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-efbade", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-0a2e12", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-b6c458", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-059484", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-dcd274", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-7403e2", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-ac1d03.json b/.maestro/tasks/contracts/c-ac1d03.json new file mode 100644 index 000000000..8b8759ed8 --- /dev/null +++ b/.maestro/tasks/contracts/c-ac1d03.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-ac1d03", + "taskId": "tsk-mvb0jmfu-jljtq9", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T13:38:35.123Z", + "lockedAt": "2026-10-16T13:38:35.123Z", + "intent": "0.8 E2.2 — property testing and test helpers", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-957f29", + "text": "test.property absent 4/8 -> documented 7/8: a feature-gated id_effect::testing property module (feature proptest, on the proptest crate) with generators derived from Schema/HasSchema (#2), effect-aware properties that run framework effects under TestExecutor (#4), stateful/model-based testing (#5), shipped generators for framework domain types (Duration, Chunk, Exit/Cause, HList caps where sensible) (#8); keeps shrinking/replay from proptest; book chapter, runnable example, integration test.", + "kind": "manual" + }, + { + "id": "dw-9b0b7b", + "text": "test.unit 5/8 -> 7/8: first-party assertions for Exit/Cause (#3; the removed 0.6 assert_exit_* in a 0.8 form) and snapshot/golden testing support (#5; e.g. on insta behind a feature, or a small first-party golden helper).", + "kind": "manual" + }, + { + "id": "dw-212d2d", + "text": "MIGRATION-0.8/CHANGELOG text reported (feature returns); clippy -D warnings, docs, tests green for id_effect with and without the feature.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-b00f37.json b/.maestro/tasks/contracts/c-b00f37.json new file mode 100644 index 000000000..608c414b8 --- /dev/null +++ b/.maestro/tasks/contracts/c-b00f37.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-b00f37", + "taskId": "tsk-mvbdsp4f-cbg990", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:04:05.203Z", + "lockedAt": "2026-10-16T20:04:05.203Z", + "intent": "E4.1 typed extractors with Problem+JSON errors", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-4b358a", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-9aa519", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-1f7ade", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-f6faab", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-259ff2", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-9c0675", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-85a826", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-b3042e.json b/.maestro/tasks/contracts/c-b3042e.json new file mode 100644 index 000000000..0c890512b --- /dev/null +++ b/.maestro/tasks/contracts/c-b3042e.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-b3042e", + "taskId": "tsk-mvbh3ye3-0dqyir", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:22:21.172Z", + "lockedAt": "2026-10-16T21:22:21.172Z", + "intent": "r09-e4-1b-layers", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-b0609e", + "text": "First-party `CompressionLayer`, `RequestIdLayer` (propagates `x-request-id`, opens a tracing span with method/route/id), `TimeoutLayer` (→ 503 Problem), `BodyLimitLayer` (→ 413), `RateLimitLayer` over `id_effect_resilience::RateLimiter` (→ 429, store pluggable for Redis); `SecurityLayer::recommended` unchanged. One test per layer.", + "kind": "manual" + }, + { + "id": "dw-59997e", + "text": "Gate: `cargo nextest run -p id_effect_axum --all-features`", + "kind": "manual" + }, + { + "id": "dw-a01810", + "text": "Owns: `crates/id_effect_axum/src/middleware/{compression.rs,request_id.rs,limits.rs,rate_limit.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-b3ea78.json b/.maestro/tasks/contracts/c-b3ea78.json new file mode 100644 index 000000000..c541593b4 --- /dev/null +++ b/.maestro/tasks/contracts/c-b3ea78.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 2, + "id": "c-b3ea78", + "taskId": "tsk-mveox57m-tvw0xp", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:24:11.834Z", + "lockedAt": "2026-10-19T03:24:11.834Z", + "intent": "r11-e2-5-stm-collections", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f53fd4", + "text": "conc.stm#6: TSet, TArray, TPriorityQueue (take retries when empty), TPromise (await_ parks until completed; completes once), TReentrantLock (read and write locks, reentrant per fiber) in id_effect::stm, each composing with Stm::or_else and Stm::retry.", + "kind": "manual" + }, + { + "id": "dw-7478d2", + "text": "Tests `tset_add_remove_atomic_with_tref`, `tarray_update_is_transactional`, `tpriority_queue_orders_and_retries_when_empty`, `tpromise_await_parks_until_completed`, `treentrant_lock_writer_excludes_readers`, `stm_collections_contention`; examples 087-091; book part4/ch12-04-collections.md covers all nine structures (evidence for #6 at level documented, cap 89).", + "kind": "manual" + }, + { + "id": "dw-f668f0", + "text": "Gate: `cargo nextest run -p id_effect --test stm_collections`; `cargo test -p id_effect --doc stm`; `cargo test -p id_effect --examples`", + "kind": "manual" + }, + { + "id": "dw-d14b06", + "text": "Owns: `crates/id_effect/src/stm/{tset,tarray,tpriority_queue,tpromise,treentrant_lock}.rs`, `crates/id_effect/src/stm/mod.rs`, `crates/id_effect/examples/087-091_stm_*.rs`, `crates/id_effect/tests/stm_collections.rs`, `crates/id_effect/book/src/part4/ch12-04-collections.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e2-5-stm-collections", + "lockedBy": "r11-e2-5-stm-collections", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/tasks/contracts/c-b48bf0.json b/.maestro/tasks/contracts/c-b48bf0.json new file mode 100644 index 000000000..50a297a34 --- /dev/null +++ b/.maestro/tasks/contracts/c-b48bf0.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-b48bf0", + "taskId": "tsk-mvfl8a7k-641iow", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T18:28:59.564Z", + "lockedAt": "2026-10-19T18:28:59.564Z", + "intent": "r12-e12-4-gen-ts", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-8cad68", + "text": "id-effect rpc gen ts --contract --out writes plain TypeScript with no runtime dependency: types from the contract (structs, tagged unions, Option as optional or null, Vec, maps, brands as branded types, dates as ISO strings), error unions as discriminated unions on _tag, one client class per service and version (web.rpc#3).", + "kind": "manual" + }, + { + "id": "dw-06277d", + "text": "Runtime behaviour of the generated client: calls made in one microtask go out as one batch; transport choice WebTransport, then WebSocket, then SSE + POST; reconnect with exponential backoff and jitter and resume from the last cursor (web.realtime#7); AbortSignal cancels a call; the x-rpc header is always sent.", + "kind": "manual" + }, + { + "id": "dw-ede7dd", + "text": "cli.rs gains the rpc subcommand with gen ts only (Command is #[non_exhaustive], crates/id_effect_tool/src/cli.rs:29, so a new Rpc variant is not a break); r12-e12-4-rpc-check adds the check arm, so no stub ships (QUALITY_BAR check S).", + "kind": "manual" + }, + { + "id": "dw-447ac5", + "text": "D12: tests type-check with deno check and run deno test (pinned Deno >= 2.4 via the CI setup step pinned by SHA); no package.json, node_modules or npm lockfile is added.", + "kind": "manual" + }, + { + "id": "dw-c1a2d0", + "text": "Tests `gen_ts_golden_output` (crates/id_effect_tool/tests/golden/rpc/**), `generated_client_type_checks`, `generated_client_round_trip_against_test_server`, `generated_client_batches_same_microtask_calls`, `generated_client_resumes_after_reconnect`; book part6/ch33-11-typescript-client.md.", + "kind": "manual" + }, + { + "id": "dw-c9a845", + "text": "Gate: `cargo nextest run -p id_effect_tool --test rpc_gen_ts`; `deno check crates/id_effect_tool/tests/ts/*.ts`; `deno test crates/id_effect_tool/tests/ts/`", + "kind": "manual" + }, + { + "id": "dw-5741a4", + "text": "Owns: `crates/id_effect_tool/src/{rpc/mod.rs,rpc/gen_ts.rs,rpc/ts/**,cli.rs}`, `crates/id_effect_tool/Cargo.toml` (id_effect_rpc dependency for the contract model), `crates/id_effect_tool/tests/{rpc_gen_ts.rs,golden/rpc/**,ts/**}`, `crates/id_effect/book/src/part6/ch33-11-typescript-client.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "g1-gen-ts", + "lockedBy": "g1-gen-ts", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-b4e562.json b/.maestro/tasks/contracts/c-b4e562.json new file mode 100644 index 000000000..ccb2193f1 --- /dev/null +++ b/.maestro/tasks/contracts/c-b4e562.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 2, + "id": "c-b4e562", + "taskId": "tsk-mvg7dsl7-vj0q93", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-20T04:48:47.847Z", + "lockedAt": "2026-10-20T04:48:47.847Z", + "intent": "r12-t0-migration-guide", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-7bc956", + "text": "docs/MIGRATION-0.12.md has one section per cargo semver-checks break with its shim and a before/after snippet: id_effect_node::transport::certs → id_effect_platform::tls (deprecated re-export for one minor); axum HostConfig / ConnectionLimits TLS and HTTP/3 fields; id_effect_platform HttpClientConfig::http3; QUIC mesh ALPN idm/1 (0.11 nodes are refused); labs RpcGroup::register → #[rpc::service] (and the low-level RpcGroup with .public()/.guard()); OpenAPI 3.0 → 3.1 output of id_effect_rpc; the service template config key server.http3.enabled.", + "kind": "manual" + }, + { + "id": "dw-1e503b", + "text": "CHANGELOG.md 0.12.0 section with Added / Changed / Security / Scorecard subsections; the Scorecard section quotes REPORT-0.12.0 (E, RT, red rows, web.rpc, web.realtime, dist.tls); the Security section lists every call-surface default from the release spec Security table.", + "kind": "manual" + }, + { + "id": "dw-0194d9", + "text": "Gate: `cargo semver-checks` output ⊆ the MIGRATION-0.12 list (diff recorded in the leaf evidence)", + "kind": "manual" + }, + { + "id": "dw-b7ca40", + "text": "Owns: `docs/MIGRATION-0.12.md`, `CHANGELOG.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "l1-exit", + "lockedBy": "l1-exit", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/tasks/contracts/c-bb0c59.json b/.maestro/tasks/contracts/c-bb0c59.json new file mode 100644 index 000000000..c776ce481 --- /dev/null +++ b/.maestro/tasks/contracts/c-bb0c59.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-bb0c59", + "taskId": "tsk-mvdfwlxi-vkqc20", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:23:59.183Z", + "lockedAt": "2026-10-18T06:23:59.183Z", + "intent": "r11-e2-5-stm-core", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-ae2d4b", + "text": "id_effect::stm exposes Stm, TRef, TMap, TQueue, TSemaphore, Txn, Outcome, atomically and commit with unchanged semantics (moved from crates/id_effect_fp/src/stm/mod.rs, which depends only on id_effect::{Effect, from_async} at :38 and std); always compiled, no new dependencies (0.11.0.md D5); the global commit lock (:50) is unchanged.", + "kind": "manual" + }, + { + "id": "dw-b7abda", + "text": "id_effect_fp::stm::* re-exports the core items with #[deprecated(note = \"use id_effect::stm\")] for one minor (L9); the re-export at crates/id_effect_fp/src/lib.rs:77 is kept and deprecated; the core crate doc (crates/id_effect/src/lib.rs:30-31) no longer says STM lives in id_effect_fp.", + "kind": "manual" + }, + { + "id": "dw-8ed8c2", + "text": "Examples 077-086_stm_*.rs and tests/stm_contention.rs move to crates/id_effect and run there; book part4/ch12-00..03 point at id_effect::stm; MIGRATION-0.11 section \"STM path\".", + "kind": "manual" + }, + { + "id": "dw-791fc1", + "text": "The kernel is unchanged: crates/id_effect/tests/kernel_alloc.rs and the core bench gates pass.", + "kind": "manual" + }, + { + "id": "dw-5992ea", + "text": "Gate: `cargo nextest run -p id_effect -p id_effect_fp --all-features`; `cargo nextest run -p id_effect --test kernel_alloc`; `cargo test -p id_effect --examples`; `bash scripts/bench-compare.sh` (core gates)", + "kind": "manual" + }, + { + "id": "dw-5de298", + "text": "Owns: `crates/id_effect/src/stm/**`, `crates/id_effect/src/lib.rs`, `crates/id_effect/Cargo.toml`, `crates/id_effect/examples/077-086_stm_*.rs`, `crates/id_effect/tests/stm_contention.rs`, `crates/id_effect_fp/src/{stm/**,lib.rs}`, `crates/id_effect_fp/examples/077-086_stm_*.rs` (deleted), `crates/id_effect_fp/tests/stm_contention.rs` (deleted), `crates/id_effect/book/src/part4/ch12-0{0,1,2,3}-*.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-bb2dad.json b/.maestro/tasks/contracts/c-bb2dad.json new file mode 100644 index 000000000..018d616b1 --- /dev/null +++ b/.maestro/tasks/contracts/c-bb2dad.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-bb2dad", + "taskId": "tsk-mvcpeyei-jtr1wv", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:27:33.978Z", + "lockedAt": "2026-10-17T18:27:33.978Z", + "intent": "r10-e1-5-dev", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-866e78", + "text": "`id-effect dev [--services] [--watch PATH…]`: polls mtimes (D4), `cargo build`, restarts the child with `ID_EFFECT_PROFILE=dev` after SIGTERM + drain (#2, #3, fault.reload#1); a failed build keeps the old child and prints the errors; `--services` runs `docker compose up -d` once. Tests `edit_triggers_rebuild_and_restart`, `build_error_keeps_previous_child`.", + "kind": "manual" + }, + { + "id": "dw-8c287b", + "text": "Gate: `cargo nextest run -p id_effect_tool`", + "kind": "manual" + }, + { + "id": "dw-0fe81c", + "text": "Owns: `crates/id_effect_tool/src/dev.rs`, `src/cli.rs` (`Dev` variant), `crates/id_effect_tool/tests/dev.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-bb48d3.json b/.maestro/tasks/contracts/c-bb48d3.json new file mode 100644 index 000000000..a5a254e42 --- /dev/null +++ b/.maestro/tasks/contracts/c-bb48d3.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-bb48d3", + "taskId": "tsk-mvcjv31o-sevooz", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:40:33.264Z", + "lockedAt": "2026-10-17T15:40:33.264Z", + "intent": "r10-co-sql-errors", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-593ddd", + "text": "`SqlError` gains `#[non_exhaustive]` variants for connection and TLS failures and a typed SQLSTATE on plain `query`/`execute` (unique violation detectable without message parsing — used by E7.1 and E7.2); `PgAdvisoryLease::from_pool_config` honours explicit TLS settings. Tests `unique_violation_has_sqlstate_23505`, `tls_required_against_plain_server_is_tls_error`, `advisory_lease_uses_pool_tls`.", + "kind": "manual" + }, + { + "id": "dw-b75a6f", + "text": "Gate: ` -p id_effect_sql -p id_effect_node --all-features`", + "kind": "manual" + }, + { + "id": "dw-116078", + "text": "Owns: `crates/id_effect_sql/src/{error.rs,pg/error.rs,pg/client.rs}`, `crates/id_effect_node/src/singleton.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-bd9972.json b/.maestro/tasks/contracts/c-bd9972.json new file mode 100644 index 000000000..268e6b07c --- /dev/null +++ b/.maestro/tasks/contracts/c-bd9972.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-bd9972", + "taskId": "tsk-mvbh3y7e-zu7pb4", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:22:20.927Z", + "lockedAt": "2026-10-16T21:22:20.927Z", + "intent": "r09-e3-3-sea-query", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-26bf6a", + "text": "`select/insert/update/delete` builders from sea-query rendered per backend dialect; values bridged to `SqlValue` (D3); `query_as::(raw_sql)` (#6).", + "kind": "manual" + }, + { + "id": "dw-05014a", + "text": "Gate: `cargo nextest run -p id_effect_sql --all-features`", + "kind": "manual" + }, + { + "id": "dw-2c7894", + "text": "Owns: `crates/id_effect_sql/src/query/**`, feature `query`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-be3c11.json b/.maestro/tasks/contracts/c-be3c11.json new file mode 100644 index 000000000..9104dcc01 --- /dev/null +++ b/.maestro/tasks/contracts/c-be3c11.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-be3c11", + "taskId": "tsk-mvbh3z7s-2ol5gk", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T22:36:34.505Z", + "lockedAt": "2026-10-16T22:36:34.505Z", + "intent": "r09-e6-5-isolation-fixtures", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-8a9dbf", + "text": "`isolated_db()` per test: transaction rolled back at end, or template-database clone for tests that commit (#3); `Fixture`/factory trait with `FromRow` + Schema-arbitrary defaults (#4); re-export of the in-process HTTP client (#1). Book `part4` integration-testing chapter, example.", + "kind": "manual" + }, + { + "id": "dw-3b92ff", + "text": "Gate: same + tier-conformance id_effect_test", + "kind": "manual" + }, + { + "id": "dw-7e6a7a", + "text": "Owns: `crates/id_effect_test/src/{db.rs,fixtures.rs,http.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-bf7e36.json b/.maestro/tasks/contracts/c-bf7e36.json new file mode 100644 index 000000000..2b6d3ffb2 --- /dev/null +++ b/.maestro/tasks/contracts/c-bf7e36.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-bf7e36", + "taskId": "tsk-mvcpey14-2k0wis", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:34:51.859Z", + "lockedAt": "2026-10-17T18:34:51.859Z", + "intent": "r10-e7-3-deliver-later", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-49265e", + "text": "`deliver_later(mail)` / `deliver_later_in(&SqlTransaction, mail)` enqueue a `SendMail` job in `id_effect_jobs` with retries (#5). Test `deliver_later_sends_after_commit_only`. Chapter + example + integration test.", + "kind": "manual" + }, + { + "id": "dw-220067", + "text": "Gate: ` -p id_effect_mail --all-features`; `bash scripts/tier-conformance.sh id_effect_mail`", + "kind": "manual" + }, + { + "id": "dw-bde893", + "text": "Owns: `crates/id_effect_mail/src/later.rs` (feature `jobs`), `book/src/part6/ch31-03-mail.md`, `crates/id_effect_mail/examples/010_mail.rs`, `crates/id_effect_mail/tests/mail.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-c1a71d.json b/.maestro/tasks/contracts/c-c1a71d.json new file mode 100644 index 000000000..eceb06c5a --- /dev/null +++ b/.maestro/tasks/contracts/c-c1a71d.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-c1a71d", + "taskId": "tsk-mvf9rld7-x1htmf", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T13:07:50.304Z", + "lockedAt": "2026-10-19T13:07:50.304Z", + "intent": "r12-e12-2-rpc-ws-sse", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-b27ede", + "text": "web.rpc#5: every procedure kind over WebSocket frames multiplexed by call id (frame format from r12-rpc-design.md), subprotocols idrpc.json.v1 and idrpc.postcard.v1; subscriptions and server streams over SSE with mutations over POST next to it (the browser fallback).", + "kind": "manual" + }, + { + "id": "dw-3ef7e3", + "text": "Backpressure: streams are pull-based end to end, so a slow client slows its own stream and the server buffers at most the configured window per stream; per-connection stream cap and frame cap (ADR 0013 R4).", + "kind": "manual" + }, + { + "id": "dw-57a22b", + "text": "The Rust client chooses a transport (HTTP, WebSocket, SSE) from config; client/mod.rs holds the choice.", + "kind": "manual" + }, + { + "id": "dw-5c4c22", + "text": "Tests `every_kind_over_websocket`, `subscription_over_sse`, `mutation_over_post_next_to_sse`, `slow_ws_client_applies_backpressure`, `r4_ws_streams_per_connection_capped`, `ws_frame_decode_rejects_unknown_kind`; `transport::frame::decode` is public for the fuzz target; book part6/ch33-06-rpc-transports.md.", + "kind": "manual" + }, + { + "id": "dw-b2710d", + "text": "Gate: `cargo nextest run -p id_effect_rpc --features ws,sse`; `cargo nextest run -p id_effect_rpc --all-features`", + "kind": "manual" + }, + { + "id": "dw-77170d", + "text": "Owns: `crates/id_effect_rpc/src/transport/{mod.rs,ws.rs,sse.rs,frame.rs}`, `crates/id_effect_rpc/src/client/{mod.rs,ws.rs,sse.rs}`, `crates/id_effect_rpc/src/server/mod.rs` (transport mounts, after r12-e12-1-kinds), `crates/id_effect_rpc/tests/{transport_ws.rs,transport_sse.rs}`, `crates/id_effect/book/src/part6/ch33-06-rpc-transports.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "d1-rpc-ws-sse", + "lockedBy": "d1-rpc-ws-sse", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-c295a2.json b/.maestro/tasks/contracts/c-c295a2.json new file mode 100644 index 000000000..689f0b9f5 --- /dev/null +++ b/.maestro/tasks/contracts/c-c295a2.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-c295a2", + "taskId": "tsk-mvcjv3pj-tef3al", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T17:07:34.000Z", + "lockedAt": "2026-10-17T17:07:34.000Z", + "intent": "r10-e6-5b-faults", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-19452d", + "text": "`Fault::{delay, fail, timeout}` applied to `SqlClient` and `HttpClient` doubles; delays run on the virtual clock (`TestExecutor::advance`) (#2); `FaultPlan::seeded(seed)` gives the same schedule for the same seed (#7). Tests `same_seed_same_fault_schedule`, `delay_advances_virtual_clock_only`, `retry_policy_survives_injected_latency`.", + "kind": "manual" + }, + { + "id": "dw-1c8539", + "text": "Gate: `cargo nextest run -p id_effect_test -p id_effect_sql --features id_effect_test/fault`", + "kind": "manual" + }, + { + "id": "dw-db942f", + "text": "Owns: `crates/id_effect_test/src/fault/**` (feature `fault`), `crates/id_effect_test/Cargo.toml`, `crates/id_effect_sql/src/client.rs` (`TestSqlClient` delay only)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-c296e8.json b/.maestro/tasks/contracts/c-c296e8.json new file mode 100644 index 000000000..3fa2ed297 --- /dev/null +++ b/.maestro/tasks/contracts/c-c296e8.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-c296e8", + "taskId": "tsk-mvfusz8h-6pkxkz", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T22:56:46.587Z", + "lockedAt": "2026-10-19T22:56:46.587Z", + "intent": "r12-w8-kpis", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-91e6ad", + "text": "docs/security/kpis-0.12.json: open highs (0), fuzz targets (≥ 13 total, ≥ 5 new, open crashes 0), compile-fail security cases (0.11 count + 2), secure-stack delta (≤ 5 µs), the Security-table tests green; scripts/security-kpis.sh learns the 0.12 fields and keeps its self-test.", + "kind": "manual" + }, + { + "id": "dw-f1b652", + "text": "T12 bench results copied from docs/benchmarks/v0.12.json: unary delta, batch pair ratio, soak RSS ratio and heartbeat completeness, h3/h2 p99 ratio.", + "kind": "manual" + }, + { + "id": "dw-aeecf1", + "text": "docs/benchmarks/dx-kpis-0.12.json: K1–K6 with HTTP/3 on in the service template (K1 ≤ 180 s, K2 ≤ 8 s, K4 ≥ 17/20, K5 ≥ 80 %, K6 within budget) plus the cold-build delta of the http3 kit feature and of node quic pulling id_effect_platform (release spec Risk 9); measured on an idle machine.", + "kind": "manual" + }, + { + "id": "dw-c4b7f8", + "text": "Gate: `bash scripts/security-kpis.sh --self-test && bash scripts/security-kpis.sh 0.12.0`; `DX_KPIS_RELEASE=0.12 bash scripts/dx-kpis.sh`", + "kind": "manual" + }, + { + "id": "dw-20af69", + "text": "Owns: `docs/security/kpis-0.12.json`, `scripts/security-kpis.sh`, `docs/benchmarks/dx-kpis-0.12.json`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "j2-kpis", + "lockedBy": "j2-kpis", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/tasks/contracts/c-c2c7ad.json b/.maestro/tasks/contracts/c-c2c7ad.json new file mode 100644 index 000000000..2af26692f --- /dev/null +++ b/.maestro/tasks/contracts/c-c2c7ad.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 2, + "id": "c-c2c7ad", + "taskId": "tsk-mvb8iicl-xlm756", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T17:21:36.129Z", + "lockedAt": "2026-10-16T17:21:36.129Z", + "intent": "0.8 W3-A — id_effect_kit", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-6efdde", + "text": "docs/adrs/0010-crate-tiers.md Amendment 4 names id_effect_kit (integration tier; moves to platform once examples/reference exists) and id_effect_tool (integration tier, publishes the id-effect binary), per ADR 0010 Rule 5 and ADR 0012.", + "kind": "manual" + }, + { + "id": "dw-d9f510", + "text": "crates/id_effect_kit: version.workspace, readme, tier = \"integration\", #![deny(missing_docs)], forbid(unsafe_code); features web (id_effect_axum), sql-pg (id_effect_sql/pg), otel (id_effect_opentelemetry), cluster (id_effect_node), full; a prelude (id_effect core items used across examples, plus per-feature items: EffectExecutor, execute, serve_until_shutdown, SecurityLayer, health/readiness routers, metrics_router, SqlClientService, SqlError, with_transaction, OtelConfig, install_from_config, run_main, RunMainConfig) and whole-crate re-exports (kit::axum, kit::sql, ...); examples/010_prelude.rs runs; README with a rust block; tests satisfy tier-conformance density.", + "kind": "manual" + }, + { + "id": "dw-000ab0", + "text": "Registration: root Cargo.toml members, .moon/workspace.yml + crate moon.yml, scripts/validate-crate-packaging.sh LEVEL/ORDER for id_effect_kit (last level, after all its deps) and a reserved slot for id_effect_tool after it; the ci.yml semver job excludes both new crates until they have a published baseline.", + "kind": "manual" + }, + { + "id": "dw-671675", + "text": "bash scripts/tier-conformance.sh id_effect_kit PASS; bash scripts/validate-crate-packaging.sh PASS and --print-order lists id_effect_kit last (id_effect_tool added by W3-B); cargo hack-style check of each feature alone and full (cargo check -p id_effect_kit --no-default-features --features X for X in web, sql-pg, otel, cluster, full); clippy -D warnings, rustdoc -D warnings.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-c32380.json b/.maestro/tasks/contracts/c-c32380.json new file mode 100644 index 000000000..268fac0c9 --- /dev/null +++ b/.maestro/tasks/contracts/c-c32380.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-c32380", + "taskId": "tsk-mveq0q6b-7wurki", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:54:58.206Z", + "lockedAt": "2026-10-19T03:54:58.206Z", + "intent": "r11-e8-3-tcp-tls", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-5eb352", + "text": "The TCP transport (plaintext today, crates/id_effect_node/src/transport/tcp.rs:1-5) carries TLS 1.3 with mutual authentication under feature tls-tcp, reusing transport/certs.rs (PEM loading and reload).", + "kind": "manual" + }, + { + "id": "dw-d7dfea", + "text": "Plaintext TCP exists only with feature insecure-plaintext-tcp and an explicit TcpTransport::dangerous_plaintext(), which records a posture opt-out (ADR 0013 R8); without the feature the plaintext constructor does not exist (r11-e11-4-compile-fail adds the case). MIGRATION-0.11 section \"plaintext TCP feature\".", + "kind": "manual" + }, + { + "id": "dw-10ef3d", + "text": "Tests `tcp_tls_mutual_auth_round_trip`, `tcp_tls_rejects_untrusted_peer`, `plaintext_tcp_opt_out_is_recorded`.", + "kind": "manual" + }, + { + "id": "dw-d43758", + "text": "Gate: `cargo nextest run -p id_effect_node --features tls-tcp`; `cargo nextest run -p id_effect_node --all-features`; `cargo build -p id_effect_node --no-default-features`", + "kind": "manual" + }, + { + "id": "dw-81b468", + "text": "Owns: `crates/id_effect_node/src/transport/{tcp.rs,tls_tcp.rs,mod.rs}`, `crates/id_effect_node/tests/transport_tcp_tls.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e8-3-tcp-tls", + "lockedBy": "r11-e8-3-tcp-tls", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-c44cae.json b/.maestro/tasks/contracts/c-c44cae.json new file mode 100644 index 000000000..424607b68 --- /dev/null +++ b/.maestro/tasks/contracts/c-c44cae.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-c44cae", + "taskId": "tsk-mvfohzqk-xg47t7", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T20:00:18.766Z", + "lockedAt": "2026-10-19T20:00:18.766Z", + "intent": "r12-f-client-batching", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-2c8781", + "text": "F27: calls a `#[rpc::service]` generated client makes over `HttpTransport` in the same tick go out as one batch (≤ 50 calls / 1 MiB, same rules as `RpcClient::call`), each batched call carrying its service version `v`; a call alone in its tick is still a plain envelope.", + "kind": "manual" + }, + { + "id": "dw-a91122", + "text": "`RpcTransport` gains an effect-returning unary hook with a default (existing transports unchanged); `HttpTransport` overrides it so `call_unary` issues the request from the caller fiber and joins the batch window.", + "kind": "manual" + }, + { + "id": "dw-1447a4", + "text": "`client/http.rs` gains a JSON-value batched call carrying `v`; the batch wire is unchanged (WireCall already has `v`).", + "kind": "manual" + }, + { + "id": "dw-d8ee90", + "text": "Tests: `generated_client_batches_same_tick_calls` (two calls in one tick → one request, both answered, versions sent), `generated_client_lone_call_is_plain_envelope`; existing batching and service_macro tests stay green.", + "kind": "manual" + }, + { + "id": "dw-8499f8", + "text": "Gate: `cargo nextest run -p id_effect_rpc --all-features` and default; clippy -D warnings (all features, default, `--no-default-features --features client`); coverage of new code >= 96% regions.", + "kind": "manual" + }, + { + "id": "dw-46021d", + "text": "Owns: `crates/id_effect_rpc/src/{client/http.rs,service.rs}`, `crates/id_effect_rpc/tests/service_macro.rs` (new tests only), CHANGELOG own hunk.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "h2-client-batching", + "lockedBy": "h2-client-batching", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-c44ef8.json b/.maestro/tasks/contracts/c-c44ef8.json new file mode 100644 index 000000000..5dd5a8a85 --- /dev/null +++ b/.maestro/tasks/contracts/c-c44ef8.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-c44ef8", + "taskId": "tsk-mvf70fq3-r9ruuc", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T11:50:37.491Z", + "lockedAt": "2026-10-19T11:50:37.491Z", + "intent": "r12-e12-3-certs-acme", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-1ada82", + "text": "Reload: the CertReloader (moved from node in r12-e12-3-quic-endpoint) drives the QUIC endpoint and the TCP TLS listener from one Identity; new connections use the new certificate, existing ones are not cut; dist.tls#7 stays met for the mesh.", + "kind": "manual" + }, + { + "id": "dw-b842b7", + "text": "D8: platform feature acme issues and renews certificates with instant-acme over TLS-ALPN-01 (the acme-tls/1 ALPN answered by the rustls certificate resolver), renewal at two thirds of the lifetime with jitter, the account key stored with mode 0600, the directory URL from config (Lets Encrypt staging by default in non-prod profiles); a failed order keeps the current certificate and is logged and counted; DNS-01 is a non-goal.", + "kind": "manual" + }, + { + "id": "dw-cf4789", + "text": "Tests `reload_swaps_identity_on_endpoint_and_tcp`, `existing_connections_survive_reload`, `acme_issues_and_renews_against_pebble` (pinned Pebble image by digest, ID_EFFECT_REQUIRE_DOCKER=1), `acme_failure_keeps_current_cert`, `acme_account_key_mode_0600`; book part6/ch29-09-certificates-acme.md.", + "kind": "manual" + }, + { + "id": "dw-d089e9", + "text": "Gate: `cargo nextest run -p id_effect_platform --features tls,acme`; `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p id_effect_platform --features acme --test acme_pebble`", + "kind": "manual" + }, + { + "id": "dw-7d84a1", + "text": "Owns: `crates/id_effect_platform/src/tls/{reload.rs,acme.rs}`, `crates/id_effect_platform/tests/{tls_reload.rs,acme_pebble.rs}`, `crates/id_effect/book/src/part6/ch29-09-certificates-acme.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "c3-acme", + "lockedBy": "c3-acme", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-c62743.json b/.maestro/tasks/contracts/c-c62743.json new file mode 100644 index 000000000..72a974d86 --- /dev/null +++ b/.maestro/tasks/contracts/c-c62743.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-c62743", + "taskId": "tsk-mvfiv513-vvk5an", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T17:22:24.635Z", + "lockedAt": "2026-10-19T17:22:24.635Z", + "intent": "r12-e12-3-deploy-guide", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-1c73b2", + "text": "Book part6/ch29-07-http3-deployment.md documents both topologies: (a) a load balancer terminating HTTP/3 at the edge (needs nothing from the app; keep or disable server.http3.enabled for internal hops) and (b) direct-to-app with TCP and UDP 443 open, TLS-ALPN-01 ACME, the Alt-Svc behaviour, firewall rules, health checks over TCP, and what happens when UDP is blocked (clients stay on HTTP/2).", + "kind": "manual" + }, + { + "id": "dw-ee00f6", + "text": "Examples docs/deploy/http3/kamal.yml (UDP port publish) and docs/deploy/http3/helm-values.yaml (a UDP service port next to TCP, a NetworkPolicy for UDP 443) with a README; every Rust snippet in the chapter compiles through examples/book-snippets (K5).", + "kind": "manual" + }, + { + "id": "dw-be8ff6", + "text": "The mesh section links the shared-endpoint rule (D7) and quic.mesh_port, and states that the service template never shares a port with a mesh.", + "kind": "manual" + }, + { + "id": "dw-394e47", + "text": "Gate: `cargo test -p book-snippets`; `bash scripts/check-workflows.sh`; `yq e . docs/deploy/http3/kamal.yml docs/deploy/http3/helm-values.yaml > /dev/null`", + "kind": "manual" + }, + { + "id": "dw-f6d80d", + "text": "Owns: `crates/id_effect/book/src/part6/ch29-07-http3-deployment.md`, `docs/deploy/http3/{kamal.yml,helm-values.yaml,README.md}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "e4-deploy-guide", + "lockedBy": "e4-deploy-guide", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/tasks/contracts/c-c68bf3.json b/.maestro/tasks/contracts/c-c68bf3.json new file mode 100644 index 000000000..f7666c17b --- /dev/null +++ b/.maestro/tasks/contracts/c-c68bf3.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-c68bf3", + "taskId": "tsk-mvdi4g74-udxxs3", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T07:26:04.112Z", + "lockedAt": "2026-10-18T07:26:04.112Z", + "intent": "r11-e7-5-activities-timers", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-ba9a39", + "text": "fault.workflows#7: activities run as id_effect_jobs jobs (crates/id_effect_jobs/src/job.rs:26 Job, queue.rs:342 JobQueue) on a named queue, retried by a core Schedule, executed at least once by workers on any node, with idempotency key (instance id, seq) so a duplicate completion is ignored; the result is appended to the history.", + "kind": "manual" + }, + { + "id": "dw-e2aabc", + "text": "fault.workflows#4: ctx.sleep(d) and ctx.sleep_until(t) create durable timers fired through the job queue run_at (one job per timer, unique key); a timer survives an engine restart; waits of days complete under the test clock.", + "kind": "manual" + }, + { + "id": "dw-d6a61a", + "text": "fault.workflows#5: WorkflowClient::signal(instance, name, payload) stores the signal; ctx.wait_signal(name) receives signals in arrival order; a signal sent before the wait is buffered.", + "kind": "manual" + }, + { + "id": "dw-ab9f20", + "text": "Tests `activity_retried_until_success_then_journaled`, `activity_runs_at_least_once_after_worker_crash`, `activity_result_dedup_by_idempotency_key`, `durable_timer_survives_engine_restart`, `timer_of_days_under_test_clock`, `signal_before_wait_is_buffered`, `signals_delivered_in_order`.", + "kind": "manual" + }, + { + "id": "dw-664853", + "text": "Gate: `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p id_effect_workflow --all-features` (Postgres required)", + "kind": "manual" + }, + { + "id": "dw-4c4811", + "text": "Owns: `crates/id_effect_workflow/src/{activity,timer,signal,lib}.rs`, `crates/id_effect_workflow/Cargo.toml` (id_effect_jobs dependency), `crates/id_effect_workflow/migrations/0002_*`, `crates/id_effect_workflow/tests/{activities_pg,timers_pg,signals_pg}.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-c9b753.json b/.maestro/tasks/contracts/c-c9b753.json new file mode 100644 index 000000000..35ba2361b --- /dev/null +++ b/.maestro/tasks/contracts/c-c9b753.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-c9b753", + "taskId": "tsk-mvbh3xq5-gl0mv7", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:22:20.804Z", + "lockedAt": "2026-10-16T21:22:20.804Z", + "intent": "r09-e3-1b-conformance-routing", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-92e18a", + "text": "One generic test module instantiated per backend (#6 evidence); `RoutingSqlClient{primary, replicas}` sends `query` to replicas and `execute`/`begin` to primary; test `reads_go_to_replica_writes_to_primary`. Book section in ch28-01.", + "kind": "manual" + }, + { + "id": "dw-b87778", + "text": "Gate: all backend features", + "kind": "manual" + }, + { + "id": "dw-d3d744", + "text": "Owns: `crates/id_effect_sql/tests/conformance/**`, `src/routing.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-c9f395.json b/.maestro/tasks/contracts/c-c9f395.json new file mode 100644 index 000000000..6ef1e12d0 --- /dev/null +++ b/.maestro/tasks/contracts/c-c9f395.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-c9f395", + "taskId": "tsk-mvfuszbz-xpikgu", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T22:56:46.708Z", + "lockedAt": "2026-10-19T22:56:46.708Z", + "intent": "r12-f-ts-cursor", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-d3b43c", + "text": "F58: `RpcStream.cursor` (and every resume the runtime sends: WS/WT `resume`, SSE `Last-Event-ID`, subscription re-`call`) is the cursor of the last item the caller has CONSUMED from the iterator, not the last item received; buffered-but-unread items are re-delivered after a resume, never skipped.", + "kind": "manual" + }, + { + "id": "dw-f6f8c6", + "text": "Credits stay consistent with the design note: an item is credited when consumed; after a resume the server counts items up to the sent cursor as credited (handoff-resume-012.md).", + "kind": "manual" + }, + { + "id": "dw-37f591", + "text": "Deno test `resume_does_not_skip_buffered_unread_items` (server sends N items, client reads k < N, connection drops, client resumes → receives items k+1..N exactly once, in order) for a server stream and a subscription; goldens regenerated (`UPDATE_GOLDEN=1`).", + "kind": "manual" + }, + { + "id": "dw-9907b8", + "text": "Gate: `ID_EFFECT_REQUIRE_DENO=1 cargo nextest run -p id_effect_tool --test rpc_gen_ts`; `deno test` / `deno check` under crates/id_effect_tool/tests/ts; `id-effect rpc check --generated` for Ledger and Relay web clients regenerated if their checked-in output changes.", + "kind": "manual" + }, + { + "id": "dw-029880", + "text": "Owns: `crates/id_effect_tool/src/rpc/ts/**`, `crates/id_effect_tool/tests/{rpc_gen_ts.rs,golden/rpc/**,ts/**}`, regenerated client output under `examples/reference/{ledger,relay}/**` only where generated, CHANGELOG own hunk.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "j3-ts-cursor", + "lockedBy": "j3-ts-cursor", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-ca3b4a.json b/.maestro/tasks/contracts/c-ca3b4a.json new file mode 100644 index 000000000..2fa55b83e --- /dev/null +++ b/.maestro/tasks/contracts/c-ca3b4a.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-ca3b4a", + "taskId": "tsk-mvcrl0xj-ldfumo", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T19:38:13.787Z", + "lockedAt": "2026-10-17T19:38:13.787Z", + "intent": "r10-w3-ledger", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-a3c058", + "text": "See below. Tier flips to `platform` for jobs, messaging, mail, console, storage.", + "kind": "manual" + }, + { + "id": "dw-fb6daa", + "text": "Gate: ` -p ledger`; `bash scripts/tier-conformance.sh` (all PASS)", + "kind": "manual" + }, + { + "id": "dw-3a5db0", + "text": "Owns: `examples/reference/ledger/**`, `.github/workflows/ci.yml` (`reference-ledger` job services: Mailpit, Redis, S3 image), tier lines of the new crates", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-cb766a.json b/.maestro/tasks/contracts/c-cb766a.json new file mode 100644 index 000000000..362557e5b --- /dev/null +++ b/.maestro/tasks/contracts/c-cb766a.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 2, + "id": "c-cb766a", + "taskId": "tsk-mvawkapy-fwcf2f", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T11:47:23.670Z", + "lockedAt": "2026-10-16T11:47:23.670Z", + "intent": "0.8 E2.1h hygiene", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-0585dc", + "text": "FoldChannel::from_sink (streaming/channel.rs:306) and Sink::zip (streaming/sink.rs:121-126) no longer panic: a FoldSink newtype (or Result-returning API) makes a non-fold sink a compile error or a typed error; the two should_panic tests in tests/stream_channel.rs are replaced; rg \"\\.expect\\(\" crates/id_effect/src/streaming returns nothing; MIGRATION-0.8 entry.", + "kind": "manual" + }, + { + "id": "dw-abdcf7", + "text": "Queue::offer/take (coordination/queue.rs:239-313) take a synchronous fast path when there is room/an item and fall back to the async path only when full/empty; take avoids the Vec allocation.", + "kind": "manual" + }, + { + "id": "dw-167af3", + "text": "retry_loop (scheduling/schedule.rs:501-525) no longer forces a fiber per run for count-only schedules (no clock read unless the schedule needs time), or the profile shows another dominant cost and that is fixed.", + "kind": "manual" + }, + { + "id": "dw-762e56", + "text": "bench-compare against docs/benchmarks/v0.7.json: coord/queue_bounded_10k and coord/channel_spsc_10k <= 1.25x v0.6, phase0/bench_schedule_retry_overhead <= 2x v0.6; the three ids added as budget gates in docs/benchmarks/gates.toml; bench-compare.sh --self-test passes; findings in docs/benchmarks/REPORT-0.8.md (draft section).", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvawbp5i-g5m6ot", + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-cd7ec4.json b/.maestro/tasks/contracts/c-cd7ec4.json new file mode 100644 index 000000000..92aba2574 --- /dev/null +++ b/.maestro/tasks/contracts/c-cd7ec4.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-cd7ec4", + "taskId": "tsk-mvcjv4cx-u59do2", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T15:43:18.054Z", + "lockedAt": "2026-10-17T15:43:18.054Z", + "intent": "r10-sqlt-mysql-query-ci", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-06a37d", + "text": "MySQL `DATETIME`/`TIMESTAMP`/`DATE`/`TIME` decode from the binary protocol into jiff; the sea-query bridge uses `with-jiff` only; `cargo tree -p id_effect_kit --all-features -e features -i sqlx` shows no `feature \"time\"` and `cargo tree -p id_effect_sql --all-features -e normal -i time` finds no match (CI step fails otherwise); MIGRATION-0.10 section \"Data: sqlx's `time` feature is off again\".", + "kind": "manual" + }, + { + "id": "dw-e1ad07", + "text": "Gate: ` -p id_effect_sql --all-features` (MySQL conformance); the CI step", + "kind": "manual" + }, + { + "id": "dw-9b4cc3", + "text": "Owns: `crates/id_effect_sql/src/mysql/value.rs`, `src/query/time_conv.rs`, `crates/id_effect_sql/Cargo.toml` (sqlx `time` `:78`, `time` dep `:68`, `query` feature `:36`, sea-query `with-time` `:93`), `.github/workflows/ci.yml` (one check step), `docs/MIGRATION-0.10.md` (section draft)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-cf44a1.json b/.maestro/tasks/contracts/c-cf44a1.json new file mode 100644 index 000000000..6ffc21c2e --- /dev/null +++ b/.maestro/tasks/contracts/c-cf44a1.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-cf44a1", + "taskId": "tsk-mvf9rlnr-ts175i", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T13:07:50.681Z", + "lockedAt": "2026-10-19T13:07:50.681Z", + "intent": "r12-e12-1-postcard", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-344160", + "text": "D13: Codec negotiation per request (Content-Type / Accept: application/json default, application/vnd.id-effect.rpc+postcard) and per connection (WebSocket subprotocol names idrpc.json.v1 / idrpc.postcard.v1 reserved for r12-e12-2-rpc-ws-sse); an unknown codec answers 415 Problem.", + "kind": "manual" + }, + { + "id": "dw-dad56e", + "text": "The postcard path decodes with serde and then runs the Schema validators of the input type, so refinements and brands still apply; encoded size is capped before decode.", + "kind": "manual" + }, + { + "id": "dw-8e4410", + "text": "Tests `postcard_round_trip_every_kind`, `postcard_payload_still_runs_schema_refinements`, `unknown_codec_is_415`, `json_is_default`, `postcard_oversized_frame_rejected_before_decode`; `codec::postcard::decode_frame` is public for the fuzz target; book part6/ch33-03-encodings.md.", + "kind": "manual" + }, + { + "id": "dw-2e8c51", + "text": "Gate: `cargo nextest run -p id_effect_rpc --features postcard`; `cargo nextest run -p id_effect_rpc --all-features`", + "kind": "manual" + }, + { + "id": "dw-2bca06", + "text": "Owns: `crates/id_effect_rpc/src/codec/{mod.rs,postcard.rs}`, `crates/id_effect_rpc/tests/codec.rs`, `crates/id_effect/book/src/part6/ch33-03-encodings.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "d4-postcard", + "lockedBy": "d4-postcard", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-cfe3a5.json b/.maestro/tasks/contracts/c-cfe3a5.json new file mode 100644 index 000000000..1f989785d --- /dev/null +++ b/.maestro/tasks/contracts/c-cfe3a5.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-cfe3a5", + "taskId": "tsk-mveqc5k9-6weba4", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T04:03:51.102Z", + "lockedAt": "2026-10-19T04:03:51.102Z", + "intent": "r11-e7-5-sagas-promote", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-314941", + "text": "fault.workflows#3 on the published crate: ctx.saga() registers compensations as journaled activities; on failure they run in reverse order, at least once, and survive a restart (the id_effect_fsm saga at crates/id_effect_fsm/src/saga.rs:66 stays the in-memory variant).", + "kind": "manual" + }, + { + "id": "dw-9135b0", + "text": "Level labs → documented: book part6/ch32-00-workflow-cluster.md rewritten for id_effect_workflow (labs banner removed), part5/ch19-04-workflow-bridge.md updated; runnable examples 010_order_saga, 020_durable_timer, 030_signal_approval (DATABASE_URL); README with a rust fence; integration test `saga_compensates_in_reverse_after_restart`.", + "kind": "manual" + }, + { + "id": "dw-17e39f", + "text": "labs/id_effect_workflow deleted with its duroxide, duroxide-pg and rusqlite dependencies and the NetworkJournalStub (labs/id_effect_workflow/src/journal.rs:131, QUALITY_BAR check S); labs/Cargo.toml member removed.", + "kind": "manual" + }, + { + "id": "dw-c6f387", + "text": "The leaf evidence maps fault.workflows #1 #2 #3 #4 #5 #7 to paths and tests at HEAD (6/8 = 75 at documented).", + "kind": "manual" + }, + { + "id": "dw-ccb036", + "text": "Gate: `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p id_effect_workflow --all-features` (Postgres required); `cargo test --manifest-path labs/Cargo.toml --workspace`; `bash scripts/tier-conformance.sh id_effect_workflow`", + "kind": "manual" + }, + { + "id": "dw-140d67", + "text": "Owns: `crates/id_effect_workflow/{src/saga.rs,src/lib.rs,README.md,examples/**,tests/saga_pg.rs}`, `crates/id_effect/book/src/part6/ch32-00-workflow-cluster.md`, `crates/id_effect/book/src/part5/ch19-04-workflow-bridge.md`, `labs/id_effect_workflow/**` (deleted), `labs/Cargo.toml`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e7-5-sagas-promote", + "lockedBy": "r11-e7-5-sagas-promote", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-d0ac4c.json b/.maestro/tasks/contracts/c-d0ac4c.json new file mode 100644 index 000000000..f726d3ab9 --- /dev/null +++ b/.maestro/tasks/contracts/c-d0ac4c.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 2, + "id": "c-d0ac4c", + "taskId": "tsk-mvfo16u4-pdz4fg", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T19:47:05.419Z", + "lockedAt": "2026-10-19T19:47:05.419Z", + "intent": "r12-w6-kit-template", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-90fe21", + "text": "id_effect_kit features: rpc (id_effect_rpc + macros, HTTP transport, implies web), rpc-ws (WebSocket + SSE transports, subscriptions; with events also the events-backed subscriptions), http3 (axum http3 + tls; with http-client also the platform HTTP/3 client), webtransport (http3 + rpc-ws + WebTransport); cluster additionally turns on id_effect_rpc?/channels and ?/mesh; all four in full; prelude exposes rpc::service and the procedure attributes.", + "kind": "manual" + }, + { + "id": "dw-b01d7d", + "text": "Posture probes (kit posture::check): every procedure declares public or guarded, WebSocket/WebTransport origins configured, 0-RTT off, QUIC Retry on, HTTP/3 enabled only with TLS, no id-effect dev certificate in prod; tests listed below.", + "kind": "manual" + }, + { + "id": "dw-74d3a4", + "text": "service template: a sample #[rpc::service] next to the REST routes, HTTP/3 on by default (server.http3.enabled = true, D4) on the same port as TLS TCP, TCP always on; prod config requires certificate paths or ACME (or the flag off behind a load balancer); README and the zero-to-production tutorial explain the flag.", + "kind": "manual" + }, + { + "id": "dw-c92238", + "text": "id-effect dev creates a localhost dev CA and a 13-day ECDSA P-256 certificate (WebTransport serverCertificateHashes compatible), rotates it before expiry, prints trust instructions, and never runs in the prod profile.", + "kind": "manual" + }, + { + "id": "dw-940030", + "text": "scripts/template-smoke.sh generates a service and its TypeScript client (id-effect rpc gen ts), runs the service and a Deno round trip over HTTP/3 and over TCP; scripts/check-dangerous.sh lists every new dangerous_* API.", + "kind": "manual" + }, + { + "id": "dw-6f49c7", + "text": "Tests `template_service_serves_rpc_over_h3_and_tcp`, `template_http3_disabled_by_one_flag`, `posture_prod_refuses_dev_certificate`, `posture_reports_rpc_probes`, `posture_fails_on_undeclared_procedure`, `dev_cert_valid_for_webtransport_hashes`.", + "kind": "manual" + }, + { + "id": "dw-fb3f2b", + "text": "Gate: `cargo nextest run -p id_effect_kit -p id_effect_tool --all-features`; `bash scripts/template-smoke.sh`; `bash scripts/check-dangerous.sh`; `cargo test -p book-snippets` (tutorial)", + "kind": "manual" + }, + { + "id": "dw-2cd70b", + "text": "Owns: `crates/id_effect_kit/**`, `crates/id_effect_tool/templates/**`, `crates/id_effect_tool/src/dev.rs`, `crates/id_effect_tool/Cargo.toml` (rcgen for the dev certificate, after r12-e12-4-gen-ts), `crates/id_effect_tool/tests/dev_cert.rs`, `scripts/template-smoke.sh`, `scripts/check-dangerous.sh`, `crates/id_effect/book/src/tutorial-zero-to-production.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "h1-kit-template", + "lockedBy": "h1-kit-template", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-d16fdf.json b/.maestro/tasks/contracts/c-d16fdf.json new file mode 100644 index 000000000..9ebb277c9 --- /dev/null +++ b/.maestro/tasks/contracts/c-d16fdf.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-d16fdf", + "taskId": "tsk-mvcrl146-fp6v3u", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T19:38:13.909Z", + "lockedAt": "2026-10-17T19:38:13.909Z", + "intent": "r10-w3-level-audit", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-cefb30", + "text": "For each of the 14 rows + sec.authn: every planned check id maps to an existing path at HEAD and the row has book + example + integration test; gaps become tasks.", + "kind": "manual" + }, + { + "id": "dw-b7b551", + "text": "Gate: `python3 -I .tmp/plan10/gaps.py v` compared against the evidence map", + "kind": "manual" + }, + { + "id": "dw-368489", + "text": "Owns: none (read-only)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-d1f9fb.json b/.maestro/tasks/contracts/c-d1f9fb.json new file mode 100644 index 000000000..ff0c41918 --- /dev/null +++ b/.maestro/tasks/contracts/c-d1f9fb.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-d1f9fb", + "taskId": "tsk-mvbdsp4f-c6kk1f", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T19:53:30.158Z", + "lockedAt": "2026-10-16T19:53:30.158Z", + "intent": "E5.2a argon2 passwords and server-side sessions", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-bcaeff", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-d44130", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-305524", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-47a6fa", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-8b8696", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-e34ee3", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-906ad7", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-d2023b.json b/.maestro/tasks/contracts/c-d2023b.json new file mode 100644 index 000000000..509a5c25c --- /dev/null +++ b/.maestro/tasks/contracts/c-d2023b.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-d2023b", + "taskId": "tsk-mvffsow8-ogb7fz", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T15:56:36.455Z", + "lockedAt": "2026-10-19T15:56:36.455Z", + "intent": "r12-f-h3-platform", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-59108f", + "text": "F22 (D9 tuning task): `QuicLimits` (or a sibling transport setting) gains a congestion-control choice (`Cubic`, `Bbr`); the `h3` route defaults to BBR, the mesh `idm/1` route keeps Cubic; the choice is documented as quinn-experimental for BBR and is configurable per route. Test `h3_route_uses_bbr_mesh_uses_cubic`.", + "kind": "manual" + }, + { + "id": "dw-cea452", + "text": "F23: the QUIC endpoint sets UDP send/receive socket buffers (default 4 MiB, best effort, logs the granted size at debug); configurable; test asserts the requested size is applied or the shortfall logged.", + "kind": "manual" + }, + { + "id": "dw-bea5a1", + "text": "F10: the HTTP/3 client trusts the platform verifier (`rustls-platform-verifier =0.7.1`, already in Cargo.lock) when no explicit roots are configured, keeping `HttpClientConfig::root_certificates` as the override; SSRF and body-cap tests stay green.", + "kind": "manual" + }, + { + "id": "dw-267903", + "text": "F24: `HostTls::Provided` can carry an `Identity` (or exposes one from its resolver) so a host with provided TLS serves HTTP/3 too; when it cannot, the existing log line stays. Test `provided_tls_serves_h3`.", + "kind": "manual" + }, + { + "id": "dw-6e10aa", + "text": "Gate: `cargo nextest run -p id_effect_platform --all-features`, `cargo nextest run -p id_effect_axum --features http3,tls`, clippy -D warnings, coverage of new code >= 96% regions, `cargo tree -d` one quinn / rustls.", + "kind": "manual" + }, + { + "id": "dw-4de393", + "text": "Owns: `crates/id_effect_platform/{Cargo.toml,src/quic/**,src/http/h3.rs,tests/http3_client.rs}`, `crates/id_effect_axum/src/server/{bootstrap.rs,tls.rs,lifecycle.rs}` (Provided identity only), CHANGELOG/MIGRATION-0.12 own hunks.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "f1-h3-platform", + "lockedBy": "f1-h3-platform", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-d26a99.json b/.maestro/tasks/contracts/c-d26a99.json new file mode 100644 index 000000000..4c648607e --- /dev/null +++ b/.maestro/tasks/contracts/c-d26a99.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-d26a99", + "taskId": "tsk-mvb9dhfl-6cdwnk", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T17:45:38.735Z", + "lockedAt": "2026-10-16T17:45:38.735Z", + "intent": "0.8 W3-E4 — snippet dependencies", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-0d050d", + "text": "examples/book-snippets/Cargo.toml gains the crates the remaining dependency-blocked fences need: tokio (macros, rt, rt-multi-thread, signal), clap (derive), proptest, reqwest (json, no default TLS if it compiles), tower (util) + id_effect_axum feature tower, id_effect_logger, opentelemetry + opentelemetry_sdk (versions matching id_effect_opentelemetry), cli_minimal (path).", + "kind": "manual" + }, + { + "id": "dw-a707b4", + "text": "The hidden tokio stand-in in part1/ch01-01 and the hidden clap stand-in in cookbook/ch40-03 are replaced by the real crates; fences ignored only for \"needs dep\" reasons compile (no_run where they would block or need a network/collector); remaining ignores keep a reason (labs crates, type-definition copies, quick-reference list).", + "kind": "manual" + }, + { + "id": "dw-4f4154", + "text": "cargo test --doc -p book_snippets green; moon run :book green; report the new compiled/total count.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/tasks/contracts/c-d54700.json b/.maestro/tasks/contracts/c-d54700.json new file mode 100644 index 000000000..431660ea9 --- /dev/null +++ b/.maestro/tasks/contracts/c-d54700.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-d54700", + "taskId": "tsk-mvdhyh5g-5yq01o", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T07:21:25.991Z", + "lockedAt": "2026-10-18T07:21:25.991Z", + "intent": "r11-sc-sast-scorecard", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-d412d1", + "text": "S4 (SAST, 0.11.0.md D14): .github/workflows/codeql.yml runs CodeQL for Rust and GitHub Actions on push, pull request and weekly, uploads SARIF, SHA-pinned, top-level permissions read; no open high-severity alert at exit.", + "kind": "manual" + }, + { + "id": "dw-029502", + "text": ".github/workflows/scorecard.yml runs ossf/scorecard-action weekly and on push to main with publish_results: true; the README shows the Scorecard badge.", + "kind": "manual" + }, + { + "id": "dw-9de37b", + "text": "docs/security/openssf.md lists every Scorecard check with its expected score, the evidence, and the owner actions it needs (Branch-Protection, Code-Review, Signed-Releases, Token-Permissions; 0.11.0.md D15), adding up to ≥ 7.5; it also holds the drafted OpenSSF Best Practices (bestpractices.dev) Passing answers with evidence links. The owner submits the questionnaire (D16); the badge goes in the README once Passing.", + "kind": "manual" + }, + { + "id": "dw-eecce6", + "text": "Gate: `bash scripts/check-workflows.sh`; `actionlint .github/workflows/codeql.yml .github/workflows/scorecard.yml`; the Scorecard score from the workflow run (or `scorecard --repo=github.com/Industrial/id_effect`) recorded at exit, ≥ 7.5", + "kind": "manual" + }, + { + "id": "dw-af71ed", + "text": "Owns: `.github/workflows/{codeql.yml,scorecard.yml}`, `README.md` (badges line), `docs/security/openssf.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-d71720.json b/.maestro/tasks/contracts/c-d71720.json new file mode 100644 index 000000000..80639d8ea --- /dev/null +++ b/.maestro/tasks/contracts/c-d71720.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-d71720", + "taskId": "tsk-mvbh3xu3-3dzjef", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T22:13:18.490Z", + "lockedAt": "2026-10-16T22:13:18.490Z", + "intent": "r09-e3-1b-mysql", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-1e52f5", + "text": "Conformance suite green on MySQL 8 (testcontainers).", + "kind": "manual" + }, + { + "id": "dw-e25967", + "text": "Gate: `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p id_effect_sql --features mysql`", + "kind": "manual" + }, + { + "id": "dw-5c469c", + "text": "Owns: `crates/id_effect_sql/src/mysql/**`, feature `mysql`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-d73d72.json b/.maestro/tasks/contracts/c-d73d72.json new file mode 100644 index 000000000..9adee165c --- /dev/null +++ b/.maestro/tasks/contracts/c-d73d72.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 2, + "id": "c-d73d72", + "taskId": "tsk-mvb0jm8t-w9qmja", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T13:38:34.862Z", + "lockedAt": "2026-10-16T13:38:34.862Z", + "intent": "0.8 E2.1s — stream operators", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-cbadf2", + "text": "msg.streams 5/8 -> at least 7/8: zip / zip_with / combine_latest (#4), time-based throttle and debounce (#5) on the fiber clock (TestExecutor virtual-time tests), group_by key into substreams/partitions (#6) with bounded buffering and backpressure; all interruption-safe (finalizers run when the consumer stops early).", + "kind": "manual" + }, + { + "id": "dw-5f2bb2", + "text": "Book streams chapter updated with each operator; a runnable example; clippy -D warnings, docs, tests green for id_effect.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-d76698.json b/.maestro/tasks/contracts/c-d76698.json new file mode 100644 index 000000000..37d782b50 --- /dev/null +++ b/.maestro/tasks/contracts/c-d76698.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-d76698", + "taskId": "tsk-mvfdi1tt-t8vr7z", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T14:52:16.227Z", + "lockedAt": "2026-10-19T14:52:16.227Z", + "intent": "r12-e12-4-openapi-asyncapi", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-aaca2b", + "text": "web.rpc#7: id_effect_rpc::openapi emits OpenAPI 3.1 from Service::contract() for queries and mutations (GET and POST forms, the x-rpc header, idempotency key, error unions as oneOf with a _tag discriminator, the batch endpoint); it replaces the labs 3.0 emitter (crates/id_effect_rpc/src/openapi.rs).", + "kind": "manual" + }, + { + "id": "dw-e09cb0", + "text": "id_effect_rpc::asyncapi emits AsyncAPI 3.0 for server streams, client streams, two-way streams, subscriptions and channels, with WebSocket and SSE bindings and the cursor field documented.", + "kind": "manual" + }, + { + "id": "dw-533276", + "text": "Both documents validate against their meta-schemas (jsonschema, no remote resolution); /rpc/openapi.json and /rpc/asyncapi.json are closed in the prod profile unless guarded (ADR 0013 R7).", + "kind": "manual" + }, + { + "id": "dw-e7b4cd", + "text": "Tests `openapi_31_validates_against_meta_schema`, `openapi_error_union_is_one_of_with_discriminator`, `asyncapi_30_validates`, `asyncapi_lists_every_stream_and_channel`, `r7_rpc_docs_closed_in_prod`; book part6/ch33-10-contracts.md.", + "kind": "manual" + }, + { + "id": "dw-ff8f48", + "text": "Gate: `cargo nextest run -p id_effect_rpc --all-features --test openapi_emit --test asyncapi_emit`", + "kind": "manual" + }, + { + "id": "dw-347ba0", + "text": "Owns: `crates/id_effect_rpc/src/{openapi.rs,asyncapi.rs}`, `crates/id_effect_rpc/tests/{openapi_emit.rs,asyncapi_emit.rs}`, `crates/id_effect_rpc/tests/fixtures/asyncapi-3.0.schema.json`, `crates/id_effect/book/src/part6/ch33-10-contracts.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "e3-openapi", + "lockedBy": "e3-openapi", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-d82f5c.json b/.maestro/tasks/contracts/c-d82f5c.json new file mode 100644 index 000000000..c63da96c9 --- /dev/null +++ b/.maestro/tasks/contracts/c-d82f5c.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-d82f5c", + "taskId": "tsk-mvbdsp4f-2m3dmu", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:09:02.589Z", + "lockedAt": "2026-10-16T21:09:02.589Z", + "intent": "E3.2 id-effect migrate new/run/status/revert/check", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-a2fe47", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-93f51b", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-9e4a18", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-941b6f", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-62384a", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-6bf9ba", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-7c94e6", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-d90e27.json b/.maestro/tasks/contracts/c-d90e27.json new file mode 100644 index 000000000..cb69c2ddf --- /dev/null +++ b/.maestro/tasks/contracts/c-d90e27.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-d90e27", + "taskId": "tsk-mvcpeyky-peqniz", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:43:41.556Z", + "lockedAt": "2026-10-17T18:43:41.556Z", + "intent": "r10-e1-3-generate-job", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-9e086b", + "text": "`id-effect generate job [--queue Q] [--cron EXPR]` writes the `Job` impl, its registration and a test into a service project; template smoke generates one into a fresh `service` and runs `cargo test`.", + "kind": "manual" + }, + { + "id": "dw-8bcc07", + "text": "Gate: `cargo nextest run -p id_effect_tool`; `bash scripts/template-smoke.sh`", + "kind": "manual" + }, + { + "id": "dw-7300a9", + "text": "Owns: `crates/id_effect_tool/src/generate/**`, `crates/id_effect_tool/templates/generate/job/**`, `src/cli.rs` (`Generate` variant, rebased after E1.5), `scripts/template-smoke.sh`, `book/src/part6/ch34-03-generators.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-d959a2.json b/.maestro/tasks/contracts/c-d959a2.json new file mode 100644 index 000000000..57a699ace --- /dev/null +++ b/.maestro/tasks/contracts/c-d959a2.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-d959a2", + "taskId": "tsk-mvcjv3zi-qxn0mq", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T17:07:34.285Z", + "lockedAt": "2026-10-17T17:07:34.285Z", + "intent": "r10-e7-4-promote", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-800e2e", + "text": "Moved to `crates/`; `id_effect_graph` and `es-entity` dependencies and the `es_entity/` module removed; `SqlJournal` ported to `SqlClient`/`SqlValue` (pg + sqlite); `append_expected(stream, expected_version, events)`; command/query handlers and projections return `Effect` (no `run_sync` in public paths); live tests fail under `ID_EFFECT_REQUIRE_PG`. Tests `expected_version_conflict`, `cqrs_dispatch_is_an_effect`.", + "kind": "manual" + }, + { + "id": "dw-f8ba4f", + "text": "Gate: ` -p id_effect_events --all-features`", + "kind": "manual" + }, + { + "id": "dw-d6677f", + "text": "Owns: `labs/id_effect_events/**` → `crates/id_effect_events/**`, `labs/Cargo.toml`, root `Cargo.toml` member line", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-dd89a6.json b/.maestro/tasks/contracts/c-dd89a6.json new file mode 100644 index 000000000..1c8ab528d --- /dev/null +++ b/.maestro/tasks/contracts/c-dd89a6.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-dd89a6", + "taskId": "tsk-mvd9mlqi-lnd416", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T03:28:19.715Z", + "lockedAt": "2026-10-18T03:28:19.715Z", + "intent": "r101-mesh", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-9efa54", + "text": "Closes H3, H4 and M9 as specified in the Findings table of .maestro/specs/releases/0.10.1.md, within the patch-release rules there (additive API only; stricter defaults with a named dangerous/insecure opt-out). One regression test per finding, named after it (for example h1_per_ip_limit_sees_the_peer).", + "kind": "manual" + }, + { + "id": "dw-efea42", + "text": "Gate: cargo test -p id_effect_node --all-features; cargo clippy -p id_effect_node --all-features --all-targets -- -D warnings", + "kind": "manual" + }, + { + "id": "dw-2976c6", + "text": "Owns: crates/id_effect_node/src/transport/**, crates/id_effect_node/src/handshake.rs, crates/id_effect_node/src/session.rs, crates/id_effect_node/src/config*.rs, crates/id_effect_node/tests/**, crates/id_effect_node/Cargo.toml (rustls features only)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-de4276.json b/.maestro/tasks/contracts/c-de4276.json new file mode 100644 index 000000000..28ec248fa --- /dev/null +++ b/.maestro/tasks/contracts/c-de4276.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-de4276", + "taskId": "tsk-mvdfoc36-i9tzlz", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:17:32.599Z", + "lockedAt": "2026-10-18T06:17:32.599Z", + "intent": "r11-sec-logging", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-c8eebb", + "text": "M11: a pipeline with no explicit policy applies RedactionPolicy::recommended() — case-insensitive fields password, passwd, secret, token, access_token, refresh_token, id_token, api_key, apikey, authorization, cookie, set-cookie, session, csrf, private_key, client_secret and globs *_token, *_secret, *password*; RedactionPolicy::new() (crates/id_effect_logger/src/redact.rs:59-67, redacts nothing) is deprecated for RedactionPolicy::empty(); RedactionPolicy::dangerous_none() records a posture opt-out. MIGRATION-0.11 section.", + "kind": "manual" + }, + { + "id": "dw-9ec617", + "text": "M12: the tracing-line formatter (pipeline.rs:168-181) escapes CR, LF, other control characters and ANSI escapes in messages and values, escapes spaces and = in keys and quotes values containing spaces; the structured backend (pipeline.rs:294-309) escapes keys.", + "kind": "manual" + }, + { + "id": "dw-3c0aba", + "text": "Regression tests `m11_default_pipeline_redacts_common_secret_fields`, `m11_empty_policy_is_explicit`, `m12_newline_in_message_cannot_forge_a_line`, `m12_annotation_key_cannot_inject_fields`, `m12_control_and_ansi_sequences_are_escaped`.", + "kind": "manual" + }, + { + "id": "dw-65d956", + "text": "Gate: `cargo nextest run -p id_effect_logger --all-features`", + "kind": "manual" + }, + { + "id": "dw-08cd86", + "text": "Owns: `crates/id_effect_logger/src/{redact.rs,pipeline.rs,lib.rs}`, `crates/id_effect_logger/tests/{pipeline.rs,security.rs}`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-de5028.json b/.maestro/tasks/contracts/c-de5028.json new file mode 100644 index 000000000..46e605cf5 --- /dev/null +++ b/.maestro/tasks/contracts/c-de5028.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-de5028", + "taskId": "tsk-mvbdsp4f-e7yo8j", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T20:51:52.663Z", + "lockedAt": "2026-10-16T20:51:52.663Z", + "intent": "E3.1a derive(FromRow)", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-81ae45", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-50e230", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-de1e13", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-78eb61", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-c09ca4", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-e0d59c", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-9a16ed", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-df7c71.json b/.maestro/tasks/contracts/c-df7c71.json new file mode 100644 index 000000000..26df34b3e --- /dev/null +++ b/.maestro/tasks/contracts/c-df7c71.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 2, + "id": "c-df7c71", + "taskId": "tsk-mvawkapy-xdespa", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T11:47:22.822Z", + "lockedAt": "2026-10-16T11:47:22.822Z", + "intent": "0.8 E0.4 governance", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-3c4753", + "text": "GOVERNANCE.md: current maintainers (truthfully: one), decision process, ADR process, path to co-maintainer with merge rights, how releases are cut against PLAN-OF-PLANS; .github/CODEOWNERS.", + "kind": "manual" + }, + { + "id": "dw-7878a1", + "text": ".github/FUNDING.yml only if the owner confirms an existing GitHub Sponsors or OpenCollective account (otherwise omitted and noted).", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvawbp5i-g5m6ot", + "riskClass": "low" +} diff --git a/.maestro/tasks/contracts/c-e0acee.json b/.maestro/tasks/contracts/c-e0acee.json new file mode 100644 index 000000000..0a19ef662 --- /dev/null +++ b/.maestro/tasks/contracts/c-e0acee.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-e0acee", + "taskId": "tsk-mvf70f4h-isuu0c", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T11:50:37.106Z", + "lockedAt": "2026-10-19T11:50:37.106Z", + "intent": "r12-e12-3-tls-h2", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-fea978", + "text": "D10: axum feature tls serves TLS 1.3 (1.2 allowed) on TCP with ALPN h2 and http/1.1 from the id_effect_platform::tls Identity; new connections use a reloaded certificate without a restart; hyper-util auto builder gains http2.", + "kind": "manual" + }, + { + "id": "dw-84a46b", + "text": "The 0.11 ConnectionLimits (crates/id_effect_axum/src/server/connection.rs:65) apply to TLS connections and HTTP/2: header read timeout, connection cap, requests per connection, plus a finite HTTP/2 max concurrent streams and a TLS handshake timeout; each loosening is a recorded dangerous_* opt-out.", + "kind": "manual" + }, + { + "id": "dw-31b042", + "text": "HostConfig gains a tls section (cert/key paths or an Identity provider); plain HTTP stays the default without it; prod posture warns when HTTP/3 is enabled without TLS (no Alt-Svc is sent over plain HTTP).", + "kind": "manual" + }, + { + "id": "dw-162475", + "text": "Tests `tls_h2_round_trip`, `tls_http1_round_trip`, `tls_reloaded_cert_used_for_new_connections`, `h2_concurrent_streams_capped`, `tls_handshake_timeout_closes_slow_client`; book part6/ch29-04-tls-http2.md.", + "kind": "manual" + }, + { + "id": "dw-664e08", + "text": "Gate: `cargo nextest run -p id_effect_axum --features tls`; `cargo nextest run -p id_effect_axum --all-features`; `bash scripts/check-dangerous.sh`", + "kind": "manual" + }, + { + "id": "dw-ef5554", + "text": "Owns: `crates/id_effect_axum/src/server/{connection.rs,tls.rs,bootstrap.rs,mod.rs}`, `crates/id_effect_axum/tests/tls_h2.rs`, `crates/id_effect/book/src/part6/ch29-04-tls-http2.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "c1-tls-h2", + "lockedBy": "c1-tls-h2", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-e1164a.json b/.maestro/tasks/contracts/c-e1164a.json new file mode 100644 index 000000000..13ac3233c --- /dev/null +++ b/.maestro/tasks/contracts/c-e1164a.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-e1164a", + "taskId": "tsk-mvfl8acb-0806yq", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T18:28:59.683Z", + "lockedAt": "2026-10-19T18:28:59.683Z", + "intent": "r12-e12-3-webtransport", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-9040d3", + "text": "axum feature webtransport: WebTransport sessions (h3-webtransport =0.1.2) on the HTTP/3 adapter; the extended CONNECT is a declared typed route (public or guarded) and its Origin header is checked against the same allowlist as WebSocket (ADR 0013 R9).", + "kind": "manual" + }, + { + "id": "dw-6ffbfa", + "text": "id_effect_rpc feature webtransport: two-way streams and subscriptions over WebTransport bidirectional streams (one QUIC stream per call), resume after session loss with the r12-e12-2-bidi-resume cursor protocol; stream credits and session limits finite.", + "kind": "manual" + }, + { + "id": "dw-3db9a6", + "text": "D12 browser tests: Chromium driven from Rust over CDP (chromiumoxide or headless_chrome, choice recorded), using serverCertificateHashes with the 13-day ECDSA dev certificate; the client prefers WebTransport, then WebSocket. The browser test runs only with ID_EFFECT_REQUIRE_BROWSER=1 (fails, never skips, when set) and in the Relay CI job.", + "kind": "manual" + }, + { + "id": "dw-4070ab", + "text": "Tests `webtransport_bidi_round_trip`, `webtransport_subscription_round_trip`, `webtransport_resume_after_session_loss`, `r9_webtransport_connect_from_foreign_origin_refused`, `browser_prefers_webtransport_then_websocket`; book part6/ch29-06-webtransport.md.", + "kind": "manual" + }, + { + "id": "dw-b8bb8f", + "text": "Gate: `cargo nextest run -p id_effect_rpc -p id_effect_axum --features webtransport`; `ID_EFFECT_REQUIRE_BROWSER=1 cargo nextest run -p id_effect_rpc --features webtransport --test browser`", + "kind": "manual" + }, + { + "id": "dw-8444e5", + "text": "Owns: `crates/id_effect_axum/src/server/webtransport.rs`, `crates/id_effect_rpc/src/transport/webtransport.rs`, `crates/id_effect_rpc/tests/{transport_webtransport.rs,browser.rs}`, `crates/id_effect/book/src/part6/ch29-06-webtransport.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "g2-webtransport", + "lockedBy": "g2-webtransport", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-e1b585.json b/.maestro/tasks/contracts/c-e1b585.json new file mode 100644 index 000000000..d999d78de --- /dev/null +++ b/.maestro/tasks/contracts/c-e1b585.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-e1b585", + "taskId": "tsk-mvcpexxx-4ohdhl", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T18:15:45.419Z", + "lockedAt": "2026-10-17T18:15:45.419Z", + "intent": "r10-e7-3-templates-preview", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-0aa4e2", + "text": "minijinja HTML + text templates with layouts and partials (#2); `MailPreviews` router at `/_mail/previews` lists and renders registered previews, re-reading templates from disk in the dev profile (#3); Mailpit container (`axllent/mailpit` pinned, SMTP 1025, API 8025). Tests `layout_wraps_html_and_text`, `preview_index_lists_registered_mails`, container test `smtp_delivery_reaches_mailpit`.", + "kind": "manual" + }, + { + "id": "dw-2ba355", + "text": "Gate: ` -p id_effect_mail -p id_effect_test --all-features`", + "kind": "manual" + }, + { + "id": "dw-8e2cca", + "text": "Owns: `crates/id_effect_mail/src/{template.rs,preview.rs}`, `crates/id_effect_test/src/containers/mailpit.rs` (+ one `mod` line)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-e5a1cd.json b/.maestro/tasks/contracts/c-e5a1cd.json new file mode 100644 index 000000000..490c333cb --- /dev/null +++ b/.maestro/tasks/contracts/c-e5a1cd.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-e5a1cd", + "taskId": "tsk-mvfr89bs-sr3cs4", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T21:16:33.675Z", + "lockedAt": "2026-10-19T21:16:33.675Z", + "intent": "r12-w7-ledger", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-f8d4e8", + "text": "The Ledger exposes its accounts and entries API as a #[rpc::service] (queries and mutations with idempotency keys, one server stream for statements) that calls the same handlers as its REST routes; REST and OpenAPI stay (ADR 0014: REST + OpenAPI remains the public API style).", + "kind": "manual" + }, + { + "id": "dw-7b5446", + "text": "Its tests use the generated TypeScript client under Deno for a round trip, and the Rust client for the rest; rpc.contract.json is checked in; id-effect rpc check runs in the reference-ledger CI job against the last tag.", + "kind": "manual" + }, + { + "id": "dw-064f63", + "text": "Prod posture still clean with zero accepted risks; every procedure declared public or guarded (access-report snapshot updated).", + "kind": "manual" + }, + { + "id": "dw-f21f6e", + "text": "Tests `ledger_rpc_and_rest_agree`, `ledger_ts_client_round_trip`, `ledger_rpc_procedures_all_declared`, `ledger_rpc_mutation_idempotent`, `ledger_prod_posture_is_clean`.", + "kind": "manual" + }, + { + "id": "dw-7a597e", + "text": "Gate: `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p ledger` (Docker and Postgres; never the ambient DATABASE_URL); CI job `reference-ledger` green", + "kind": "manual" + }, + { + "id": "dw-b5af15", + "text": "Owns: `examples/reference/ledger/**`, `.github/workflows/ci.yml` (steps of the `reference-ledger` job only)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "i2-ledger", + "lockedBy": "i2-ledger", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-e8bbec.json b/.maestro/tasks/contracts/c-e8bbec.json new file mode 100644 index 000000000..46f8c43d2 --- /dev/null +++ b/.maestro/tasks/contracts/c-e8bbec.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-e8bbec", + "taskId": "tsk-mveoxp2g-hrduy2", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:24:37.752Z", + "lockedAt": "2026-10-19T03:24:37.752Z", + "intent": "r11-sc-dco-docs", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-15c9c6", + "text": "S6: .github/workflows/dco.yml fails a pull request when any commit lacks a Signed-off-by line (`s6_dco_check_rejects_unsigned_off_commit` as a fixture run); CONTRIBUTING.md (DCO section at :12-20) documents signing commits (SSH or GPG, or gitsign) and the signed-tag rule; enabling \"require signed commits\" on protected branches is an owner action (0.11.0.md D15) recorded with its date.", + "kind": "manual" + }, + { + "id": "dw-b08580", + "text": "S7 and sec.supply#7: SECURITY.md supported versions list 0.11.x and 0.10.x (90-day window), replacing 0.9.x at :35, and a \"Security releases\" section lists the 0.10.1 GHSA advisories (ids and links) as public security-release history.", + "kind": "manual" + }, + { + "id": "dw-3f1362", + "text": "THREAT-MODEL.md: STRIDE per trust boundary (HTTP edge, authentication, authorization, mesh, console/operator surfaces, data/SQL/Redis, outbound HTTP, supply chain); each threat names its mitigation, the ADR 0013 rule (R1–R10) and the test or gate; L6 (rsa 0.9 RUSTSEC-2023-0071 via sqlx-mysql and openidconnect, ignored with a reachability reason at deny.toml:18) is an accepted risk with the reachability argument and a re-check each release.", + "kind": "manual" + }, + { + "id": "dw-4de78d", + "text": "Gate: `bash scripts/check-workflows.sh`; `actionlint .github/workflows/dco.yml`; for each of R1..R10, `grep -c \"R\" THREAT-MODEL.md` ≥ 1", + "kind": "manual" + }, + { + "id": "dw-c26d27", + "text": "Owns: `.github/workflows/dco.yml`, `CONTRIBUTING.md`, `SECURITY.md`, `THREAT-MODEL.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sc-dco-docs", + "lockedBy": "r11-sc-dco-docs", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "low" +} diff --git a/.maestro/tasks/contracts/c-e952b5.json b/.maestro/tasks/contracts/c-e952b5.json new file mode 100644 index 000000000..6446b31ef --- /dev/null +++ b/.maestro/tasks/contracts/c-e952b5.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-e952b5", + "taskId": "tsk-mvdfbkr7-8wkwg5", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T06:07:37.440Z", + "lockedAt": "2026-10-18T06:07:37.440Z", + "intent": "r11-t0-skeletons", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-cd36c7", + "text": "ADR 0010 Amendment 7 (after Amendment 6, docs/adrs/0010-crate-tiers.md:182) names: id_effect_workflow (published, tier integration; replaces labs/id_effect_workflow, which r11-e7-5-sagas-promote deletes); STM moving from id_effect_fp (maintenance) into id_effect core with a one-minor deprecated re-export in id_effect_fp (L9, 0.11.0.md D5); the unpublished test crate tests/security_compile_fail (publish = false).", + "kind": "manual" + }, + { + "id": "dw-f69948", + "text": "Root Cargo.toml members gain crates/id_effect_workflow and tests/security_compile_fail; scripts/validate-crate-packaging.sh LEVEL/ORDER: `--print-order` lists id_effect_workflow after id_effect_sql and id_effect_jobs and before id_effect_kit; the security test crate is never published.", + "kind": "manual" + }, + { + "id": "dw-9e7680", + "text": "crates/id_effect_workflow skeleton: Cargo.toml on the workspace version, README with a `rust` fence, `#![deny(missing_docs)]`, one example, one test, `[package.metadata.id_effect] tier = \"integration\"`; crates.io availability of `id_effect_workflow` recorded in the leaf evidence (generic User-Agent, no personal data).", + "kind": "manual" + }, + { + "id": "dw-d266e1", + "text": "docs/MIGRATION-0.11.md created with one heading per planned break (H8 routing, Sql/RawSql, process Next/Info/SendError, RedactionPolicy::new, Secret and its equality, STM path, SSRF default, plaintext TCP feature, CSRF masked token format, mesh protocol version); each owning leaf fills its section.", + "kind": "manual" + }, + { + "id": "dw-e76ae5", + "text": "Gate: `bash scripts/tier-conformance.sh id_effect_workflow`; `bash scripts/validate-crate-packaging.sh`; `cargo nextest run -p id_effect_workflow -p security_compile_fail`", + "kind": "manual" + }, + { + "id": "dw-e8ab71", + "text": "Owns: `docs/adrs/0010-crate-tiers.md`, root `Cargo.toml` (members), `crates/id_effect_workflow/**` (skeleton only; r11-e7-5-engine owns it next), `tests/security_compile_fail/**` (skeleton only; r11-e11-4-compile-fail owns it in wave 3), `scripts/validate-crate-packaging.sh`, `docs/MIGRATION-0.11.md` (headings)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-ead934.json b/.maestro/tasks/contracts/c-ead934.json new file mode 100644 index 000000000..95e4e54a3 --- /dev/null +++ b/.maestro/tasks/contracts/c-ead934.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-ead934", + "taskId": "tsk-mvb9wqu3-mo1a0u", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T18:00:37.198Z", + "lockedAt": "2026-10-16T18:00:37.198Z", + "intent": "0.8 W3-C — doctor and layer graph", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-23b7eb", + "text": "`id-effect doctor [--path DIR] [--json]` in crates/id_effect_tool/src/doctor/** reports pass/warn/fail per check and exits 1 on any fail: rustc >= the project rust-version (and the id_effect MSRV); every id_effect* crate in Cargo.lock / cargo metadata resolves to one version (no duplicate id_effect); keys listed in .env.example are set; DATABASE_URL (when present) is reachable over TCP; docker/compose present when the project has a Dockerfile/compose.yaml; the service port is free. Unit tests per check, and a fixture project with mixed id_effect versions that doctor fails.", + "kind": "manual" + }, + { + "id": "dw-c69c8f", + "text": "Layer graph (dx.diagnostics #7): crates/id_effect_kit/src/graph.rs lets an app register named layers (name + Layer, using the public TypeIds trait for In/Out capability lists) and renders the dependency edges (consumer In matched to producer Out) as text and DOT, flagging capabilities nobody provides; the service template exposes it with `--describe-layers` and `id-effect doctor --graph` runs that and prints it. Tests: a graph with a missing provider and a DOT golden.", + "kind": "manual" + }, + { + "id": "dw-847580", + "text": "Book: a doctor/graph section (report the SUMMARY line); clippy -D warnings, rustdoc -D warnings, tests for id_effect_tool and id_effect_kit; tier-conformance PASS for both; template-smoke still green for the service template.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-eb0e89.json b/.maestro/tasks/contracts/c-eb0e89.json new file mode 100644 index 000000000..f6307ae20 --- /dev/null +++ b/.maestro/tasks/contracts/c-eb0e89.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-eb0e89", + "taskId": "tsk-mvblcbcl-ojq8t0", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T23:20:44.197Z", + "lockedAt": "2026-10-16T23:20:44.197Z", + "intent": "r09-w3-kit-template", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-dc7a24", + "text": "Kit features `sql-sqlite`, `sql-mysql`, `migrate`, `openapi`, `auth`, `authz`, `test` (+`redis`); `full` updated; service template replaces the `include_str!` migration (`db.rs.template:10,26`) with `migrate::run`.", + "kind": "manual" + }, + { + "id": "dw-da2584", + "text": "Gate: kit tests; CI template matrix (`ci.yml` template job)", + "kind": "manual" + }, + { + "id": "dw-a66179", + "text": "Owns: `crates/id_effect_kit/Cargo.toml` + `src/prelude.rs`, `crates/id_effect_tool/templates/service/**`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-ef88f6.json b/.maestro/tasks/contracts/c-ef88f6.json new file mode 100644 index 000000000..0028c9291 --- /dev/null +++ b/.maestro/tasks/contracts/c-ef88f6.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-ef88f6", + "taskId": "tsk-mvep8nuq-do4lci", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:33:08.518Z", + "lockedAt": "2026-10-19T03:33:08.518Z", + "intent": "r11-e8-3-reliable-delivery", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-1c598f", + "text": "At-least-once mode (0.11.0.md D19), opt-in per send (Addr::send_reliable) and per mesh (DeliveryMode::AtLeastOnce); at-most-once stays the default. Per-peer sequence numbers, ack frames, a bounded retransmit buffer (count and bytes, configurable) with backoff, and a receiver dedup window keyed by (incarnation, seq), so reconnects do not duplicate; messages pending when the peer restarts with a new incarnation fail with DeliveryFailed to the sender's monitor (documented).", + "kind": "manual" + }, + { + "id": "dw-e3f4bd", + "text": "PROTOCOL_VERSION bumps (crates/id_effect_node/src/wire.rs:16); older nodes are refused with VersionMismatch; MIGRATION-0.11 and CHANGELOG say so.", + "kind": "manual" + }, + { + "id": "dw-3d78bc", + "text": "dist.remote#8: the sending fiber's trace context is carried on remote cast, call and send (today an empty TraceContext::new() at mesh.rs:369, :384, :424) and becomes the parent span on the receiver.", + "kind": "manual" + }, + { + "id": "dw-88d997", + "text": "Fuzz targets mesh_frame and mesh_envelope added to fuzz/ (counted in the E11.4 KPI).", + "kind": "manual" + }, + { + "id": "dw-c2ba0a", + "text": "Tests `reliable_send_survives_connection_drop`, `duplicates_suppressed_after_reconnect`, `retransmit_buffer_bound_faults_oldest`, `at_most_once_remains_default`, `remote_call_carries_trace_context`, `version_mismatch_refused`.", + "kind": "manual" + }, + { + "id": "dw-a02314", + "text": "Gate: `cargo nextest run -p id_effect_node --all-features`; in fuzz/: `cargo +nightly- fuzz build mesh_frame mesh_envelope` and each run `-max_total_time=60` with no crash", + "kind": "manual" + }, + { + "id": "dw-3172eb", + "text": "Owns: `crates/id_effect_node/src/{envelope.rs,wire.rs,mesh.rs,session.rs,delivery.rs,lib.rs}`, `crates/id_effect_node/tests/reliable_delivery.rs`, `fuzz/fuzz_targets/{mesh_frame.rs,mesh_envelope.rs}` and their `fuzz/Cargo.toml` entries", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e8-3-reliable-delivery", + "lockedBy": "r11-e8-3-reliable-delivery", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-efb330.json b/.maestro/tasks/contracts/c-efb330.json new file mode 100644 index 000000000..6ad0a20df --- /dev/null +++ b/.maestro/tasks/contracts/c-efb330.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 2, + "id": "c-efb330", + "taskId": "tsk-mveox92p-bcbotq", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:24:14.707Z", + "lockedAt": "2026-10-19T03:24:14.707Z", + "intent": "r11-sec-h8-routing", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-e9e996", + "text": "H8 (0.11.0.md D8): typed routes carry an access marker; Route::public() or Route::guard(g: impl RouteGuard) is required before Router::route (crates/id_effect_axum/src/route/mod.rs:580) accepts the Endpoint; an undecided endpoint is a compile error. RouteGuard is an id_effect_axum trait implemented by id_effect_authz Guard (crates/id_effect_authz/src/guard.rs), so axum does not depend on authz.", + "kind": "manual" + }, + { + "id": "dw-3ad723", + "text": "RouteInfo (route/registry.rs:96, #[non_exhaustive]) gains access: Public | Guarded(policies); Router::route_unchecked is the #[deprecated] one-minor shim (L9) and records a posture opt-out; Router::into_axum() installs a deny-by-default layer answering 404 Problem Details for any request whose matched path is not a registered public or guarded route (covers plain axum routes merged later) unless allow_unregistered_routes_dangerous(); Router::access_report() lists public routes and is logged at startup; OpenAPI marks security per route.", + "kind": "manual" + }, + { + "id": "dw-b4ab79", + "text": "Anonymous principals (crates/id_effect_auth/src/middleware.rs:10 continues as Anonymous; guard.rs:13-16) reach only public routes; the auth middleware docs say so.", + "kind": "manual" + }, + { + "id": "dw-703a6c", + "text": "Every typed-route call site in the workspace is migrated in this leaf so `cargo build --workspace --all-targets` stays green: id_effect_axum examples and tests, id_effect_auth account and oidc routes, id_effect_console, examples/**, crates/id_effect_tool/templates/**, and book route snippets (K5).", + "kind": "manual" + }, + { + "id": "dw-48a2dd", + "text": "Regression tests `h8_route_without_access_decision_does_not_compile` (trybuild), `h8_route_unchecked_is_deprecated_and_recorded`, `h8_unregistered_axum_route_is_denied`, `h8_anonymous_reaches_only_public_routes`, `h8_access_report_lists_public_routes`. MIGRATION-0.11 section \"H8 routing\".", + "kind": "manual" + }, + { + "id": "dw-ceda7d", + "text": "Gate: `cargo nextest run -p id_effect_axum -p id_effect_authz -p id_effect_auth -p id_effect_console --all-features`; `cargo nextest run -p id_effect_axum --test ui`; `cargo build --workspace --all-targets`; `cargo test -p book-snippets` (K5 snippets compile)", + "kind": "manual" + }, + { + "id": "dw-a71842", + "text": "Owns: `crates/id_effect_axum/src/{route/mod.rs,route/registry.rs,route/access.rs,lib.rs}`, `crates/id_effect_axum/Cargo.toml`, `crates/id_effect_axum/tests/{route_access,ui,route_typed,route_registry,route_example,openapi}.rs`, `crates/id_effect_axum/tests/ui/**`, `crates/id_effect_axum/examples/**`, `crates/id_effect_authz/src/guard.rs`, `crates/id_effect_auth/src/{middleware.rs,account/**,oidc/**}` (route call sites), `crates/id_effect_console/src/**` (route call sites), route call sites only in `examples/**`, `crates/id_effect_tool/templates/**` and `crates/id_effect/book/src/**`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sec-h8-routing", + "lockedBy": "r11-sec-h8-routing", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-f1bc8a.json b/.maestro/tasks/contracts/c-f1bc8a.json new file mode 100644 index 000000000..57aa0c634 --- /dev/null +++ b/.maestro/tasks/contracts/c-f1bc8a.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 2, + "id": "c-f1bc8a", + "taskId": "tsk-mveoxua6-li0n6j", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:24:41.372Z", + "lockedAt": "2026-10-19T03:24:41.372Z", + "intent": "r11-e8-3-quic-mtls", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-c3ed94", + "text": "dist.tls#4: QUIC requires and verifies client certificates by default (replacing with_no_client_auth at crates/id_effect_node/src/transport/quic.rs:250, :254, :265); a peer without a client certificate, or with one from another CA, is refused.", + "kind": "manual" + }, + { + "id": "dw-68da5e", + "text": "dist.tls#5: NodeCertificate::from_pem_files(chain, key) with PKCS#8, PKCS#1 and SEC1 keys and a CA bundle loader (today the only constructor is self_signed, quic.rs:50); dist.tls#7: a CertReloader polls the files (no notify crate, as 0.10 D4) and new connections use the new certificate without a restart; dist.tls#2: MeshConfig defaults to an encrypted transport (0.10.1 already refuses QUIC without roots or a pinned key).", + "kind": "manual" + }, + { + "id": "dw-6fe6cb", + "text": "Every dangerous_* opt-out (accept any server cert, no client auth) calls id_effect::observability::posture::record_dangerous. id_effect_node/Cargo.toml declares, once, the features this release needs: quic, tls-tcp, insecure-plaintext-tcp, kubernetes and their deps (r11-e8-3-tcp-tls and r11-e8-3-k8s-discovery use them in wave 2 without editing Cargo.toml).", + "kind": "manual" + }, + { + "id": "dw-67a4f8", + "text": "Level tested → documented: book part7/ch38-01-transport-security.md, example 134_mtls_cluster.rs, integration test tests/transport_tls.rs. Tests `mtls_rejects_peer_without_client_cert`, `mtls_rejects_cert_from_other_ca`, `pem_chain_and_key_load`, `rotated_cert_used_for_new_connections`, `dangerous_accept_any_server_cert_is_recorded_in_posture`.", + "kind": "manual" + }, + { + "id": "dw-e15a6f", + "text": "Gate: `cargo nextest run -p id_effect_node --all-features`; `cargo run -p id_effect_node --features quic --example 134_mtls_cluster`; `cargo deny check`", + "kind": "manual" + }, + { + "id": "dw-9f7ed2", + "text": "Owns: `crates/id_effect_node/src/transport/{quic.rs,certs.rs,mod.rs}`, `crates/id_effect_node/Cargo.toml`, `crates/id_effect_node/tests/transport_tls.rs`, `crates/id_effect_node/examples/134_mtls_cluster.rs`, `crates/id_effect/book/src/part7/ch38-01-transport-security.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e8-3-quic-mtls", + "lockedBy": "r11-e8-3-quic-mtls", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-f2a10d.json b/.maestro/tasks/contracts/c-f2a10d.json new file mode 100644 index 000000000..973798eed --- /dev/null +++ b/.maestro/tasks/contracts/c-f2a10d.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "id": "c-f2a10d", + "taskId": "tsk-mvbdsp4f-0yq2ci", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:21:34.097Z", + "lockedAt": "2026-10-16T21:21:34.097Z", + "intent": "E3.2 migrations book chapter and example", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-9d5531", + "text": "T0 (governance, PLAN-OF-PLANS §7.1 + ADR 0010 Rule 5): ADR 0010 Amendment 5 names every new published crate of this release (id_effect_auth, id_effect_authz, id_effect_test, and id_effect_redis if E3.4 ships) with its tier; scripts/validate-crate-packaging.sh and .github/workflows/publish.yml publish them in dependency order; docs/MIGRATION-0.9.md lists every semver break with its deprecation shim (L9).", + "kind": "manual" + }, + { + "id": "dw-19cdd2", + "text": "Every in-scope epic (table below) has its leaves shipped with evidence naming the scorecard check ids it meets (rows[].v2.evidence paths that exist at the tag); every row whose target needs ↑documented has a book chapter, a runnable example and an integration test (METHOD §7.2).", + "kind": "manual" + }, + { + "id": "dw-9d6d89", + "text": "Ledger KPI (PLAN-OF-PLANS §6): examples/reference/ledger is a workspace member tested in CI; its job runs id-effect migrate run and id-effect migrate check, CRUD over typed routes, password + session login, JWT bearer access, OIDC + PKCE login against a containerised IdP, and RBAC/ownership denials, all green; bash scripts/tier-conformance.sh passes for every platform-tier crate (check P).", + "kind": "manual" + }, + { + "id": "dw-921365", + "text": "Blind re-score (METHOD §6) recorded as 0.9.0 in docs/scorecard/scorecard.json; bash scripts/scorecard.sh --compare-targets 0.9.0 passes (measured E ≥ planned E − 1.0) or every missed row has a written carry-over in REPORT-0.9.0.md; data.jsonschema (0.8 carry-over) reaches ≥ 75.", + "kind": "manual" + }, + { + "id": "dw-55dcb9", + "text": "bash scripts/scorecard.sh --diff 0.8.0 0.9.0 passes (no drop of 5 or more without an ADR note); red rows (< 40) ≤ 21.", + "kind": "manual" + }, + { + "id": "dw-4ff5c2", + "text": "DX KPIs by scripts/dx-kpis.sh: K1 ≤ 180 s, K4 ≥ 17/20, K5 ≥ 80 %, and K3 (lines to add the invoice resource to Ledger) measured and stored in the 0.9 dx results file.", + "kind": "manual" + }, + { + "id": "dw-ff9835", + "text": "QUALITY_BAR gates green (coverage ≥ 96 % on core/runtime/integration/platform crates, new crates included); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.9.md; bench budget gates green; CHANGELOG has a Scorecard section with the index deltas.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "missionId": "pln-mvbdsaam-th3g19", + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-f4b245.json b/.maestro/tasks/contracts/c-f4b245.json new file mode 100644 index 000000000..45a34ff1e --- /dev/null +++ b/.maestro/tasks/contracts/c-f4b245.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-f4b245", + "taskId": "tsk-mvfypvdw-vae18k", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-20T00:46:18.366Z", + "lockedAt": "2026-10-20T00:46:18.366Z", + "intent": "r12-f-rpc-spans", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-08f2be", + "text": "web.rpc#8: every call over WebSocket, WebTransport and SSE opens one `rpc.request` span (same fields as HTTP: rpc.system, rpc.service, rpc.method, correlation id, transport) parented on the connection span; the HTTP path gets a test. Tests `http_call_emits_rpc_request_span`, `ws_call_emits_rpc_request_span`, `sse_call_emits_rpc_request_span`, `webtransport_call_emits_rpc_request_span` (capturing subscriber, asserts one span per call with the tag).", + "kind": "manual" + }, + { + "id": "dw-9f8181", + "text": "Relay CSP: determine why Chromium refused WebTransport with \"violates the document's Content Security Policy\" in one loaded run (level audit, F59). If the Relay page CSP lacks the WebTransport origin in `connect-src` (https:// to the same host:port, which CSP treats as a different scheme from the page or a different port), fix the Relay page CSP (or SecurityLayer defaults if the default is wrong for WebTransport); add a test that the served CSP admits the WebTransport URL; rerun `ID_EFFECT_REQUIRE_BROWSER=1 cargo nextest run -p relay` 5 times. If it is a load artefact only, document the evidence.", + "kind": "manual" + }, + { + "id": "dw-d9b27e", + "text": "No change to security defaults beyond a correct CSP entry for the app's own WebTransport endpoint.", + "kind": "manual" + }, + { + "id": "dw-ddd90a", + "text": "Gate: `cargo nextest run -p id_effect_rpc --all-features`, `-p relay` with ID_EFFECT_REQUIRE_DOCKER/DENO/BROWSER=1; clippy -D warnings; unary bench delta stays ≤ 3,000 ns (spans must not regress D5: rerun `rpc/unary_json` vs `rpc/axum_json_handler`).", + "kind": "manual" + }, + { + "id": "dw-bdd37c", + "text": "Owns: `crates/id_effect_rpc/src/transport/{ws,webtransport,sse}.rs` (span hunks), `crates/id_effect_rpc/src/span.rs`, `crates/id_effect_rpc/tests/spans.rs`, `examples/reference/relay/**` (CSP and the stale cursor comment at web/tests/relay.test.ts:92), CHANGELOG own hunk.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "k1-rpc-spans", + "lockedBy": "k1-rpc-spans", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-f6083b.json b/.maestro/tasks/contracts/c-f6083b.json new file mode 100644 index 000000000..f42a509cd --- /dev/null +++ b/.maestro/tasks/contracts/c-f6083b.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-f6083b", + "taskId": "tsk-mveouc1i-byube1", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:21:58.682Z", + "lockedAt": "2026-10-19T03:21:58.682Z", + "intent": "r11-sec-limits", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-e04aff", + "text": "M5: a LoadShed layer caps in-flight requests (default derived from available parallelism, configurable in HostConfig) and answers 503 Problem Details with Retry-After when full, without queueing; server_layer installs it by default (no such layer exists in crates/id_effect_axum/src/middleware/ today); disabling it is dangerous_no_load_shedding() (posture).", + "kind": "manual" + }, + { + "id": "dw-67d0f2", + "text": "M8: MemoryRateLimitStore eviction is O(1) amortized (LRU list or clock ring) and the store is sharded (no full scan as at middleware/rate_limit.rs:138-145; DEFAULT_MAX_KEYS at :96 kept).", + "kind": "manual" + }, + { + "id": "dw-45dda4", + "text": "Regression tests `m5_load_shed_rejects_over_capacity_with_503`, `m5_default_server_stack_has_concurrency_cap`, `m8_eviction_does_not_scan_all_keys` (visit counter under a test hook), `m8_eviction_evicts_least_recently_used`, `m8_sharded_store_under_contention`.", + "kind": "manual" + }, + { + "id": "dw-62a84e", + "text": "Gate: `cargo nextest run -p id_effect_axum --all-features`; `bash scripts/bench-run.sh --only id_effect_axum:gate_request` (no budget regression)", + "kind": "manual" + }, + { + "id": "dw-d4d664", + "text": "Owns: `crates/id_effect_axum/src/middleware/{rate_limit.rs,load_shed.rs,mod.rs}`, `crates/id_effect_axum/src/server/{bootstrap.rs,lifecycle.rs}`, `crates/id_effect_axum/tests/limits.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-sec-limits", + "lockedBy": "r11-sec-limits", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-f62814.json b/.maestro/tasks/contracts/c-f62814.json new file mode 100644 index 000000000..570146203 --- /dev/null +++ b/.maestro/tasks/contracts/c-f62814.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-f62814", + "taskId": "tsk-mvdh8or6-37dwa9", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T07:01:21.740Z", + "lockedAt": "2026-10-18T07:01:21.740Z", + "intent": "r11-e8-3-global-registry", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-942742", + "text": "dist.placement#5: GlobalRegistry with register(name, pid), whereis(name), unregister(name), resolvable from every node (today registry.rs:150 registers behaviours, not processes); an entry is removed when its process exits or its node goes down; registering a taken name fails with AlreadyRegistered.", + "kind": "manual" + }, + { + "id": "dw-5b34b4", + "text": "After a partition heals, conflicting registrations resolve deterministically to one owner (documented rule, e.g. lowest (incarnation, node id)); the losing process receives an exit signal with reason NameConflict; an Addr can be obtained by global name.", + "kind": "manual" + }, + { + "id": "dw-5705ea", + "text": "Tests `global_name_resolves_from_every_node`, `name_released_on_process_exit`, `name_released_on_node_down`, `conflicting_registration_after_heal_keeps_one`, `register_twice_fails_with_already_registered`; example 135_global_names.rs; book part7/ch39-01-global-registry.md.", + "kind": "manual" + }, + { + "id": "dw-4109cf", + "text": "Gate: `cargo nextest run -p id_effect_node --all-features --test global_registry`; `cargo nextest run -p id_effect_node --all-features`", + "kind": "manual" + }, + { + "id": "dw-844e19", + "text": "Owns: `crates/id_effect_node/src/{global_registry.rs,lib.rs,envelope.rs,mesh.rs}`, `crates/id_effect_node/tests/global_registry.rs`, `crates/id_effect_node/examples/135_global_names.rs`, `crates/id_effect/book/src/part7/ch39-01-global-registry.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-f7c029.json b/.maestro/tasks/contracts/c-f7c029.json new file mode 100644 index 000000000..c351e3fb4 --- /dev/null +++ b/.maestro/tasks/contracts/c-f7c029.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-f7c029", + "taskId": "tsk-mveovhgv-tk9wbj", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:22:52.616Z", + "lockedAt": "2026-10-19T03:22:52.616Z", + "intent": "r11-e8-1-timers-hibernate", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-ce04a0", + "text": "fault.actors#6: ProcessCtx::send_after(msg, d) -> TimerRef, Addr::send_after for callers outside the process, cancel_timer(TimerRef) -> bool (true means the message will never be delivered); timers run on the executor clock (TestExecutor can advance them) and die with the process; ProcessCtx::set_idle_timeout(d) delivers Info::Timeout once per idle period.", + "kind": "manual" + }, + { + "id": "dw-e8b138", + "text": "fault.actors#8: Next::Hibernate(state) releases the mailbox buffers and calls an optional Process::hibernate hook, and the process resumes on the next message. Next (process.rs:41) and Info (msg.rs:40) become #[non_exhaustive] (MIGRATION-0.11 section).", + "kind": "manual" + }, + { + "id": "dw-3df4bf", + "text": "Tests `send_after_delivers_under_test_clock`, `cancelled_timer_never_delivers`, `timers_die_with_process`, `idle_timeout_fires_once_per_idle_period`, `hibernate_releases_buffer_and_resumes`; runnable examples for send_after and hibernate; book part7/ch37-01-timers-selective-receive.md covers #6, #7 and #8 (row level documented).", + "kind": "manual" + }, + { + "id": "dw-9b1358", + "text": "Gate: `cargo nextest run -p id_effect_process --all-features`; `cargo test -p id_effect_process --examples`", + "kind": "manual" + }, + { + "id": "dw-418380", + "text": "Owns: `crates/id_effect_process/src/{process.rs,cell.rs,timer.rs,dynamic.rs,supervisor.rs,msg.rs,lib.rs}`, `crates/id_effect_process/tests/timers.rs`, `crates/id_effect_process/examples/` (new send_after and hibernate examples), `crates/id_effect/book/src/part7/ch37-01-timers-selective-receive.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e8-1-timers-hibernate", + "lockedBy": "r11-e8-1-timers-hibernate", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-f7e98d.json b/.maestro/tasks/contracts/c-f7e98d.json new file mode 100644 index 000000000..7fdee2909 --- /dev/null +++ b/.maestro/tasks/contracts/c-f7e98d.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "id": "c-f7e98d", + "taskId": "tsk-mvepmv97-fe5jxj", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-19T03:44:09.843Z", + "lockedAt": "2026-10-19T03:44:09.843Z", + "intent": "r11-e8-2-statem", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-9f2dba", + "text": "Additive Statem behaviour in id_effect_fsm (the Copy-bound Fsm trait at crates/id_effect_fsm/src/definition.rs:44 is unchanged): fault.fsm#3 states may carry data (no Copy bound) and a separate Data value travels with the machine; handle_event(event, state, data, ctx) returns Transition::{Next(state, data, actions), Keep(data, actions), Stop(reason)}.", + "kind": "manual" + }, + { + "id": "dw-c02d22", + "text": "fault.fsm#4 actions StateTimeout(d, msg) (cancelled on any state change), EventTimeout(d, msg) (cancelled by any event), GenericTimeout(name, d, msg), built on r11-e8-1-timers-hibernate; fault.fsm#5 Postpone: a postponed event is re-delivered after the next state change, in arrival order; also Reply and NextEvent actions; an optional state-enter callback runs on every state change (strengthens #6); runs as a supervised process (StatemProcess::start / start_linked).", + "kind": "manual" + }, + { + "id": "dw-ab2dd3", + "text": "Tests `statem_state_carries_data`, `postponed_event_redelivered_after_state_change`, `state_timeout_cancelled_on_transition`, `event_timeout_cancelled_by_any_event`, `enter_action_runs_on_every_state_change`, `statem_under_supervisor_restarts`; example statem_door_lock.rs; book part5/ch19-05-statem.md (row level documented, 8/8 at cap 89).", + "kind": "manual" + }, + { + "id": "dw-3122ae", + "text": "Gate: `cargo nextest run -p id_effect_fsm --all-features`; `cargo test -p id_effect_fsm --examples`", + "kind": "manual" + }, + { + "id": "dw-4b0895", + "text": "Owns: `crates/id_effect_fsm/src/{statem.rs,lib.rs,error.rs}`, `crates/id_effect_fsm/tests/statem.rs`, `crates/id_effect_fsm/examples/statem_door_lock.rs`, `crates/id_effect/book/src/part5/ch19-05-statem.md`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "r11-e8-2-statem", + "lockedBy": "r11-e8-2-statem", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-f8ad18.json b/.maestro/tasks/contracts/c-f8ad18.json new file mode 100644 index 000000000..e3f4e169c --- /dev/null +++ b/.maestro/tasks/contracts/c-f8ad18.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-f8ad18", + "taskId": "tsk-mvd9mln9-g1f798", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-18T03:28:19.597Z", + "lockedAt": "2026-10-18T03:28:19.597Z", + "intent": "r101-http", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-c60fc4", + "text": "Closes H1, H2 and M1 as specified in the Findings table of .maestro/specs/releases/0.10.1.md, within the patch-release rules there (additive API only; stricter defaults with a named dangerous/insecure opt-out). One regression test per finding, named after it (for example h1_per_ip_limit_sees_the_peer).", + "kind": "manual" + }, + { + "id": "dw-13a0ef", + "text": "Gate: cargo test -p id_effect_axum --all-features; cargo test -p ledger (examples/reference/ledger, needs Postgres); cargo clippy -p id_effect_axum --all-features --all-targets -- -D warnings", + "kind": "manual" + }, + { + "id": "dw-145842", + "text": "Owns: crates/id_effect_axum/src/server/**, crates/id_effect_axum/src/middleware/rate_limit.rs, crates/id_effect_axum/src/client_ip.rs, crates/id_effect_axum/src/lib.rs (re-export only), crates/id_effect_axum/Cargo.toml (hyper-util features only), crates/id_effect_axum/tests/**, examples/reference/ledger/src/app.rs, examples/reference/ledger/config/**", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "high" +} diff --git a/.maestro/tasks/contracts/c-f91953.json b/.maestro/tasks/contracts/c-f91953.json new file mode 100644 index 000000000..09ca1b399 --- /dev/null +++ b/.maestro/tasks/contracts/c-f91953.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "id": "c-f91953", + "taskId": "tsk-mvb0jmj8-is6qdv", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T13:38:35.251Z", + "lockedAt": "2026-10-16T13:38:35.251Z", + "intent": "0.8 E2.4 + E1.8", + "scope": { + "filesExpected": ["**/*"], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-368280", + "text": "test.determinism 6/8 -> 7/8: seeded exploration of task interleavings in TestExecutor (#7): a seed selects the order in which ready fibers run, a helper runs a test under N seeds and reports the failing seed, and re-running with that seed reproduces it; a test that finds a planted race; book section.", + "kind": "manual" + }, + { + "id": "dw-51d2ba", + "text": "core.ergonomics 5/8 -> 6/8: official idiom/style guide for application code (#8) in the book (when to use effect!, Layer vs provide, error modelling, retry factories, resource scopes, testing), every snippet compiled (mdbook test or doctest harness used by the book).", + "kind": "manual" + }, + { + "id": "dw-0585d7", + "text": "clippy -D warnings, docs, tests green for id_effect.", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-fafa10.json b/.maestro/tasks/contracts/c-fafa10.json new file mode 100644 index 000000000..ab6037c75 --- /dev/null +++ b/.maestro/tasks/contracts/c-fafa10.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-fafa10", + "taskId": "tsk-mvbh3yhd-mlyvgg", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-16T21:46:06.885Z", + "lockedAt": "2026-10-16T21:46:06.885Z", + "intent": "r09-e4-1b-static-cache", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-a080c8", + "text": "`serve_dir` with ETag / Last-Modified / 304 (web.server#7); `HttpCacheLayer` sets Cache-Control and answers `If-None-Match` for GET responses (data.cache#7). Tests `conditional_get_returns_304`, `etag_changes_with_body`. Book `ch29-03-middleware.md`, example `090_middleware.rs`.", + "kind": "manual" + }, + { + "id": "dw-898fcb", + "text": "Gate: same + book-snippets", + "kind": "manual" + }, + { + "id": "dw-cbbeec", + "text": "Owns: `crates/id_effect_axum/src/server/static_files.rs`, `src/middleware/http_cache.rs`", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/c-fd27d1.json b/.maestro/tasks/contracts/c-fd27d1.json new file mode 100644 index 000000000..897ef6b0d --- /dev/null +++ b/.maestro/tasks/contracts/c-fd27d1.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "c-fd27d1", + "taskId": "tsk-mvblcbms-o8fsc1", + "repoRoot": ".", + "status": "locked", + "createdAt": "2026-10-17T00:58:35.328Z", + "lockedAt": "2026-10-17T00:58:35.328Z", + "intent": "r09-w3-level-audit", + "scope": { + "filesExpected": [ + "**/*" + ], + "filesForbidden": [] + }, + "doneWhen": [ + { + "id": "dw-8740a1", + "text": "For each of the 15 rows: every planned check id maps to an existing path at HEAD and the row has book + example + integration test; list gaps as tasks.", + "kind": "manual" + }, + { + "id": "dw-a683ad", + "text": "Gate: `python3 -I .tmp/plan09/gaps.py v` compared against the evidence map", + "kind": "manual" + }, + { + "id": "dw-1e9c01", + "text": "Owns: none (read-only)", + "kind": "manual" + } + ], + "amendments": [], + "createdBy": "maestro", + "lockedBy": "maestro", + "configSnapshot": { + "strict": false, + "overlapPolicy": "annotate", + "rebaseFallback": "best-effort", + "staleReclaimContractPolicy": "inherit" + }, + "riskClass": "medium" +} diff --git a/.maestro/tasks/contracts/index.jsonl b/.maestro/tasks/contracts/index.jsonl index 4c9569437..ed1c1c262 100644 --- a/.maestro/tasks/contracts/index.jsonl +++ b/.maestro/tasks/contracts/index.jsonl @@ -166,3 +166,187 @@ {"id":"c-bceacb","taskId":"tsk-mv8ohjj3-2vklr8","status":"locked","at":"2026-10-15T17:03:45.059Z"} {"id":"c-e2085d","taskId":"tsk-mv8ohjj3-83nf3k","status":"locked","at":"2026-10-15T17:50:30.983Z"} {"id":"c-949793","taskId":"tsk-mv8ohjj3-btygb9","status":"locked","at":"2026-10-15T18:33:58.157Z"} +{"id":"c-9b9698","taskId":"tsk-mv9xeqip-pyftp2","status":"locked","at":"2026-10-15T19:39:58.341Z"} +{"id":"c-5bceac","taskId":"tsk-mv9xeqip-b9jlzp","status":"locked","at":"2026-10-15T19:39:58.468Z"} +{"id":"c-208d1a","taskId":"tsk-mv9xeqip-lny39q","status":"locked","at":"2026-10-15T19:39:58.596Z"} +{"id":"c-19c080","taskId":"tsk-mv9zvfav-x5368h","status":"locked","at":"2026-10-15T20:31:53.291Z"} +{"id":"c-7c3672","taskId":"tsk-mv9xeqip-axc0oj","status":"locked","at":"2026-10-15T21:35:19.766Z"} +{"id":"c-521413","taskId":"tsk-mv9xeqip-pixe4f","status":"locked","at":"2026-10-15T21:35:19.936Z"} +{"id":"c-53f8c8","taskId":"tsk-mvaoq06i-s0i7vr","status":"locked","at":"2026-10-16T08:07:30.864Z"} +{"id":"c-2c1844","taskId":"tsk-mvaw7lqp-9fes2a","status":"locked","at":"2026-10-16T11:37:09.169Z"} +{"id":"c-8d39d3","taskId":"tsk-mvawkapy-nam47g","status":"locked","at":"2026-10-16T11:47:22.464Z"} +{"id":"c-94ed0c","taskId":"tsk-mvawkapy-h9kogq","status":"locked","at":"2026-10-16T11:47:22.680Z"} +{"id":"c-df7c71","taskId":"tsk-mvawkapy-xdespa","status":"locked","at":"2026-10-16T11:47:22.822Z"} +{"id":"c-cb766a","taskId":"tsk-mvawkapy-fwcf2f","status":"locked","at":"2026-10-16T11:47:23.670Z"} +{"id":"c-322722","taskId":"tsk-mvb0jm49-3ytgwg","status":"locked","at":"2026-10-16T13:38:34.732Z"} +{"id":"c-d73d72","taskId":"tsk-mvb0jm8t-w9qmja","status":"locked","at":"2026-10-16T13:38:34.862Z"} +{"id":"c-2a2a87","taskId":"tsk-mvb0jmcb-ajdki6","status":"locked","at":"2026-10-16T13:38:34.996Z"} +{"id":"c-ac1d03","taskId":"tsk-mvb0jmfu-jljtq9","status":"locked","at":"2026-10-16T13:38:35.123Z"} +{"id":"c-f91953","taskId":"tsk-mvb0jmj8-is6qdv","status":"locked","at":"2026-10-16T13:38:35.251Z"} +{"id":"c-6bfbf2","taskId":"tsk-mvb3399t-pysl4g","status":"locked","at":"2026-10-16T14:49:43.615Z"} +{"id":"c-c2c7ad","taskId":"tsk-mvb8iicl-xlm756","status":"locked","at":"2026-10-16T17:21:36.129Z"} +{"id":"c-8be221","taskId":"tsk-mvb8iifv-1kp3tn","status":"locked","at":"2026-10-16T17:21:36.254Z"} +{"id":"c-12b6e9","taskId":"tsk-mvb8iij2-kzsw5d","status":"locked","at":"2026-10-16T17:21:36.375Z"} +{"id":"c-1e07fc","taskId":"tsk-mvb8sxid-wpkrtg","status":"locked","at":"2026-10-16T17:29:39.347Z"} +{"id":"c-d26a99","taskId":"tsk-mvb9dhfl-6cdwnk","status":"locked","at":"2026-10-16T17:45:38.735Z"} +{"id":"c-ead934","taskId":"tsk-mvb9wqu3-mo1a0u","status":"locked","at":"2026-10-16T18:00:37.198Z"} +{"id":"c-88e200","taskId":"tsk-mvbanfma-w9xz64","status":"locked","at":"2026-10-16T18:21:20.114Z"} +{"id":"c-48c6ea","taskId":"tsk-mvbbmmz0-vebdrd","status":"locked","at":"2026-10-16T18:48:42.656Z"} +{"id":"c-46ad51","taskId":"tsk-mvawkapy-lzvg52","status":"locked","at":"2026-10-16T18:57:30.578Z"} +{"id":"c-64de72","taskId":"tsk-mvbdsp4f-xmojqz","status":"locked","at":"2026-10-16T19:49:46.762Z"} +{"id":"c-8c758a","taskId":"tsk-mvbdsp4f-wd3fai","status":"locked","at":"2026-10-16T19:49:46.913Z"} +{"id":"c-334ee9","taskId":"tsk-mvbdsp4f-ex9rcm","status":"locked","at":"2026-10-16T19:49:47.039Z"} +{"id":"c-3628a6","taskId":"tsk-mvbdsp4f-p6l6nv","status":"locked","at":"2026-10-16T19:49:47.164Z"} +{"id":"c-413616","taskId":"tsk-mvbdsp4f-bcinb5","status":"locked","at":"2026-10-16T19:53:30.031Z"} +{"id":"c-d1f9fb","taskId":"tsk-mvbdsp4f-c6kk1f","status":"locked","at":"2026-10-16T19:53:30.158Z"} +{"id":"c-4a73f5","taskId":"tsk-mvbdsp4f-souo6g","status":"locked","at":"2026-10-16T20:03:16.658Z"} +{"id":"c-b00f37","taskId":"tsk-mvbdsp4f-cbg990","status":"locked","at":"2026-10-16T20:04:05.203Z"} +{"id":"c-abad9b","taskId":"tsk-mvbdsp4f-5hrfbn","status":"locked","at":"2026-10-16T20:08:55.491Z"} +{"id":"c-1a0e33","taskId":"tsk-mvbdsp4f-i8nl5p","status":"locked","at":"2026-10-16T20:31:58.880Z"} +{"id":"c-8541de","taskId":"tsk-mvbdsp4f-y1xx0b","status":"locked","at":"2026-10-16T20:31:59.204Z"} +{"id":"c-3254b7","taskId":"tsk-mvbdsp4f-ua6x6x","status":"locked","at":"2026-10-16T20:39:31.238Z"} +{"id":"c-de5028","taskId":"tsk-mvbdsp4f-e7yo8j","status":"locked","at":"2026-10-16T20:51:52.663Z"} +{"id":"c-24f7a7","taskId":"tsk-mvbdsp4f-spcif4","status":"locked","at":"2026-10-16T20:51:52.834Z"} +{"id":"c-851506","taskId":"tsk-mvbdsp4f-a1hau9","status":"locked","at":"2026-10-16T20:51:52.994Z"} +{"id":"c-7b91e1","taskId":"tsk-mvbg796n-xpvbrc","status":"locked","at":"2026-10-16T20:56:47.644Z"} +{"id":"c-d82f5c","taskId":"tsk-mvbdsp4f-2m3dmu","status":"locked","at":"2026-10-16T21:09:02.589Z"} +{"id":"c-f2a10d","taskId":"tsk-mvbdsp4f-0yq2ci","status":"locked","at":"2026-10-16T21:21:34.097Z"} +{"id":"c-678e4b","taskId":"tsk-mvbh3xxf-emsxrb","status":"locked","at":"2026-10-16T21:22:20.628Z"} +{"id":"c-c9b753","taskId":"tsk-mvbh3xq5-gl0mv7","status":"locked","at":"2026-10-16T21:22:20.804Z"} +{"id":"c-bd9972","taskId":"tsk-mvbh3y7e-zu7pb4","status":"locked","at":"2026-10-16T21:22:20.927Z"} +{"id":"c-a51e96","taskId":"tsk-mvbh3yki-lxt7mh","status":"locked","at":"2026-10-16T21:22:21.047Z"} +{"id":"c-b3042e","taskId":"tsk-mvbh3ye3-0dqyir","status":"locked","at":"2026-10-16T21:22:21.172Z"} +{"id":"c-2a710f","taskId":"tsk-mvbh3yr2-bsovx7","status":"locked","at":"2026-10-16T21:22:21.291Z"} +{"id":"c-9594b9","taskId":"tsk-mvbh3z49-hbvk3j","status":"locked","at":"2026-10-16T21:22:21.413Z"} +{"id":"c-fafa10","taskId":"tsk-mvbh3yhd-mlyvgg","status":"locked","at":"2026-10-16T21:46:06.885Z"} +{"id":"c-003692","taskId":"tsk-mvbh3yue-83n9vp","status":"locked","at":"2026-10-16T21:46:07.533Z"} +{"id":"c-6e0faf","taskId":"tsk-mvbh3yxp-p9pp0e","status":"locked","at":"2026-10-16T21:46:08.061Z"} +{"id":"c-7b35a3","taskId":"tsk-mvbh3z0z-819ef0","status":"locked","at":"2026-10-16T21:46:08.459Z"} +{"id":"c-767817","taskId":"tsk-mvbh3yns-8zm2bm","status":"locked","at":"2026-10-16T22:13:17.444Z"} +{"id":"c-3bc0d9","taskId":"tsk-mvbh3y45-3zte0e","status":"locked","at":"2026-10-16T22:13:17.969Z"} +{"id":"c-d71720","taskId":"tsk-mvbh3xu3-3dzjef","status":"locked","at":"2026-10-16T22:13:18.490Z"} +{"id":"c-9d2ff0","taskId":"tsk-mvbh3yap-l09oxk","status":"locked","at":"2026-10-16T22:13:18.860Z"} +{"id":"c-be3c11","taskId":"tsk-mvbh3z7s-2ol5gk","status":"locked","at":"2026-10-16T22:36:34.505Z"} +{"id":"c-7068f8","taskId":"tsk-mvbh3y0u-n3qtug","status":"locked","at":"2026-10-16T23:01:54.630Z"} +{"id":"c-0aa267","taskId":"tsk-mvbl55h5-z4i872","status":"locked","at":"2026-10-16T23:15:06.300Z"} +{"id":"c-eb0e89","taskId":"tsk-mvblcbcl-ojq8t0","status":"locked","at":"2026-10-16T23:20:44.197Z"} +{"id":"c-02ad50","taskId":"tsk-mvbm09jg-c92j9a","status":"locked","at":"2026-10-16T23:39:19.663Z"} +{"id":"c-246560","taskId":"tsk-mvblcbgd-lktftw","status":"locked","at":"2026-10-16T23:39:20.074Z"} +{"id":"c-2dbc7a","taskId":"tsk-mvblcbjl-ijn35n","status":"locked","at":"2026-10-17T00:58:35.210Z"} +{"id":"c-fd27d1","taskId":"tsk-mvblcbms-o8fsc1","status":"locked","at":"2026-10-17T00:58:35.328Z"} +{"id":"c-19cd27","taskId":"tsk-mvbp968a-2m4p9i","status":"locked","at":"2026-10-17T01:10:16.716Z"} +{"id":"c-1c636c","taskId":"tsk-mvcjv4jp-snazrp","status":"locked","at":"2026-10-17T15:28:18.324Z"} +{"id":"c-10ff0e","taskId":"tsk-mvcjv4gc-nokr7e","status":"locked","at":"2026-10-17T15:28:18.449Z"} +{"id":"c-788775","taskId":"tsk-mvcjv2xv-k2omgn","status":"locked","at":"2026-10-17T15:28:18.570Z"} +{"id":"c-436b66","taskId":"tsk-mvcjv3m4-28cr81","status":"locked","at":"2026-10-17T15:28:18.695Z"} +{"id":"c-8ec8e1","taskId":"tsk-mvcjv35b-3d8syc","status":"locked","at":"2026-10-17T15:40:03.075Z"} +{"id":"c-bb48d3","taskId":"tsk-mvcjv31o-sevooz","status":"locked","at":"2026-10-17T15:40:33.264Z"} +{"id":"c-66b5da","taskId":"tsk-mvcjv3wa-40y1j9","status":"locked","at":"2026-10-17T15:41:54.327Z"} +{"id":"c-7f89b4","taskId":"tsk-mvcjv3iq-y7wzar","status":"locked","at":"2026-10-17T15:41:54.476Z"} +{"id":"c-9572b2","taskId":"tsk-mvcjv3fd-36woc8","status":"locked","at":"2026-10-17T15:41:54.618Z"} +{"id":"c-cd7ec4","taskId":"tsk-mvcjv4cx-u59do2","status":"locked","at":"2026-10-17T15:43:18.054Z"} +{"id":"c-8e6ced","taskId":"tsk-mvcjv38n-qmey5i","status":"locked","at":"2026-10-17T17:07:33.829Z"} +{"id":"c-c295a2","taskId":"tsk-mvcjv3pj-tef3al","status":"locked","at":"2026-10-17T17:07:34.000Z"} +{"id":"c-6dd4b5","taskId":"tsk-mvcjv466-ezgto8","status":"locked","at":"2026-10-17T17:07:34.121Z"} +{"id":"c-d959a2","taskId":"tsk-mvcjv3zi-qxn0mq","status":"locked","at":"2026-10-17T17:07:34.285Z"} +{"id":"c-7a66d1","taskId":"tsk-mvcjv49j-j19atg","status":"locked","at":"2026-10-17T17:13:33.574Z"} +{"id":"c-8f8103","taskId":"tsk-mvcjv3sy-7786hr","status":"locked","at":"2026-10-17T17:17:46.502Z"} +{"id":"c-24b4dd","taskId":"tsk-mvcjv42t-2myvtg","status":"locked","at":"2026-10-17T17:36:25.209Z"} +{"id":"c-3e3e19","taskId":"tsk-mvcjv3bz-jrd70e","status":"locked","at":"2026-10-17T17:57:38.522Z"} +{"id":"c-4930bc","taskId":"tsk-mvcpexut-0na9la","status":"locked","at":"2026-10-17T18:02:39.004Z"} +{"id":"c-66740d","taskId":"tsk-mvcpexl4-f87btw","status":"locked","at":"2026-10-17T18:04:41.386Z"} +{"id":"c-9b461a","taskId":"tsk-mvcpey7z-zm5f97","status":"locked","at":"2026-10-17T18:06:10.523Z"} +{"id":"c-380a8e","taskId":"tsk-mvcpexet-7d5r2f","status":"locked","at":"2026-10-17T18:14:19.227Z"} +{"id":"c-e1b585","taskId":"tsk-mvcpexxx-4ohdhl","status":"locked","at":"2026-10-17T18:15:45.419Z"} +{"id":"c-5a67b2","taskId":"tsk-mvcpexoh-4d52ro","status":"locked","at":"2026-10-17T18:17:35.685Z"} +{"id":"c-3f3d66","taskId":"tsk-mvcpeyb9-t3b4bu","status":"locked","at":"2026-10-17T18:18:37.969Z"} +{"id":"c-bb2dad","taskId":"tsk-mvcpeyei-jtr1wv","status":"locked","at":"2026-10-17T18:27:33.978Z"} +{"id":"c-65b606","taskId":"tsk-mvcpexrp-ffsx4n","status":"locked","at":"2026-10-17T18:33:47.323Z"} +{"id":"c-4efb50","taskId":"tsk-mvcpexhy-k9wn2c","status":"locked","at":"2026-10-17T18:33:47.449Z"} +{"id":"c-bf7e36","taskId":"tsk-mvcpey14-2k0wis","status":"locked","at":"2026-10-17T18:34:51.859Z"} +{"id":"c-2dfbca","taskId":"tsk-mvcpeyhp-jmyzba","status":"locked","at":"2026-10-17T18:36:56.581Z"} +{"id":"c-d90e27","taskId":"tsk-mvcpeyky-peqniz","status":"locked","at":"2026-10-17T18:43:41.556Z"} +{"id":"c-58c9b3","taskId":"tsk-mvcpey4b-b89h8y","status":"locked","at":"2026-10-17T19:00:59.831Z"} +{"id":"c-5dd9e6","taskId":"tsk-mvcrl0u6-q95oms","status":"locked","at":"2026-10-17T19:03:11.869Z"} +{"id":"c-545f94","taskId":"tsk-mvcjv4mz-pvmh4b","status":"locked","at":"2026-10-17T19:17:11.614Z"} +{"id":"c-ca3b4a","taskId":"tsk-mvcrl0xj-ldfumo","status":"locked","at":"2026-10-17T19:38:13.787Z"} +{"id":"c-d16fdf","taskId":"tsk-mvcrl146-fp6v3u","status":"locked","at":"2026-10-17T19:38:13.909Z"} +{"id":"c-97a670","taskId":"tsk-mvcrl10t-m63g70","status":"locked","at":"2026-10-17T22:28:30.551Z"} +{"id":"c-f8ad18","taskId":"tsk-mvd9mln9-g1f798","status":"locked","at":"2026-10-18T03:28:19.597Z"} +{"id":"c-dd89a6","taskId":"tsk-mvd9mlqi-lnd416","status":"locked","at":"2026-10-18T03:28:19.715Z"} +{"id":"c-6cf47e","taskId":"tsk-mvd9mltr-5gll5q","status":"locked","at":"2026-10-18T03:28:19.829Z"} +{"id":"c-2618bf","taskId":"tsk-mvd9mlx1-w6ietq","status":"locked","at":"2026-10-18T03:28:19.947Z"} +{"id":"c-101f8f","taskId":"tsk-mvdf5hvl-qkcn8e","status":"locked","at":"2026-10-18T06:02:53.657Z"} +{"id":"c-e952b5","taskId":"tsk-mvdfbkr7-8wkwg5","status":"locked","at":"2026-10-18T06:07:37.440Z"} +{"id":"c-de4276","taskId":"tsk-mvdfoc36-i9tzlz","status":"locked","at":"2026-10-18T06:17:32.599Z"} +{"id":"c-6fdef8","taskId":"tsk-mvdfsdm7-rk163d","status":"locked","at":"2026-10-18T06:20:41.239Z"} +{"id":"c-1ba8bb","taskId":"tsk-mvdftqvl-77wo59","status":"locked","at":"2026-10-18T06:21:45.103Z"} +{"id":"c-bb0c59","taskId":"tsk-mvdfwlxi-vkqc20","status":"locked","at":"2026-10-18T06:23:59.183Z"} +{"id":"c-616b6d","taskId":"tsk-mvdgoa0t-18vdc1","status":"locked","at":"2026-10-18T06:45:30.008Z"} +{"id":"c-2136ca","taskId":"tsk-mvdgtk7t-ly5lul","status":"locked","at":"2026-10-18T06:49:36.067Z"} +{"id":"c-868c33","taskId":"tsk-mvdgvhpd-9mxdxa","status":"locked","at":"2026-10-18T06:51:06.076Z"} +{"id":"c-84f283","taskId":"tsk-mvdh79dx-xb500e","status":"locked","at":"2026-10-18T07:00:15.424Z"} +{"id":"c-f62814","taskId":"tsk-mvdh8or6-37dwa9","status":"locked","at":"2026-10-18T07:01:21.740Z"} +{"id":"c-d54700","taskId":"tsk-mvdhyh5g-5yq01o","status":"locked","at":"2026-10-18T07:21:25.991Z"} +{"id":"c-5e5f1b","taskId":"tsk-mvdhzjr6-6xpm3i","status":"locked","at":"2026-10-18T07:22:15.120Z"} +{"id":"c-c68bf3","taskId":"tsk-mvdi4g74-udxxs3","status":"locked","at":"2026-10-18T07:26:04.112Z"} +{"id":"c-8d23e8","taskId":"tsk-mveorm3d-m113ii","status":"locked","at":"2026-10-19T03:19:53.422Z"} +{"id":"c-f6083b","taskId":"tsk-mveouc1i-byube1","status":"locked","at":"2026-10-19T03:21:58.682Z"} +{"id":"c-0ace4c","taskId":"tsk-mveovcwy-q3fo5g","status":"locked","at":"2026-10-19T03:22:47.461Z"} +{"id":"c-f7c029","taskId":"tsk-mveovhgv-tk9wbj","status":"locked","at":"2026-10-19T03:22:52.616Z"} +{"id":"c-56fc6b","taskId":"tsk-mveovo0t-2qcudv","status":"locked","at":"2026-10-19T03:23:00.050Z"} +{"id":"c-b3ea78","taskId":"tsk-mveox57m-tvw0xp","status":"locked","at":"2026-10-19T03:24:11.834Z"} +{"id":"c-efb330","taskId":"tsk-mveox92p-bcbotq","status":"locked","at":"2026-10-19T03:24:14.707Z"} +{"id":"c-18d82e","taskId":"tsk-mveoxlor-3b9mxh","status":"locked","at":"2026-10-19T03:24:33.553Z"} +{"id":"c-e8bbec","taskId":"tsk-mveoxp2g-hrduy2","status":"locked","at":"2026-10-19T03:24:37.752Z"} +{"id":"c-f1bc8a","taskId":"tsk-mveoxua6-li0n6j","status":"locked","at":"2026-10-19T03:24:41.372Z"} +{"id":"c-3565d2","taskId":"tsk-mveoxvjj-lwme2p","status":"locked","at":"2026-10-19T03:24:42.967Z"} +{"id":"c-ef88f6","taskId":"tsk-mvep8nuq-do4lci","status":"locked","at":"2026-10-19T03:33:08.518Z"} +{"id":"c-f7e98d","taskId":"tsk-mvepmv97-fe5jxj","status":"locked","at":"2026-10-19T03:44:09.843Z"} +{"id":"c-98539c","taskId":"tsk-mvepyvw7-ohtsdc","status":"locked","at":"2026-10-19T03:53:30.643Z"} +{"id":"c-9135db","taskId":"tsk-mvepzuqe-owgjul","status":"locked","at":"2026-10-19T03:54:14.774Z"} +{"id":"c-c32380","taskId":"tsk-mveq0q6b-7wurki","status":"locked","at":"2026-10-19T03:54:58.206Z"} +{"id":"c-cfe3a5","taskId":"tsk-mveqc5k9-6weba4","status":"locked","at":"2026-10-19T04:03:51.102Z"} +{"id":"c-59c955","taskId":"tsk-mverrnjm-v3bnoo","status":"locked","at":"2026-10-19T04:43:53.023Z"} +{"id":"c-16e5d7","taskId":"tsk-mvervb9q-tizqjp","status":"locked","at":"2026-10-19T04:46:45.087Z"} +{"id":"c-a07b64","taskId":"tsk-mvet31ys-gowf3l","status":"locked","at":"2026-10-19T05:20:45.689Z"} +{"id":"c-3e4a1c","taskId":"tsk-mveutmw9-l3uhuv","status":"locked","at":"2026-10-19T06:09:27.526Z"} +{"id":"c-960273","taskId":"tsk-mveuyqcl-h6cafd","status":"locked","at":"2026-10-19T06:13:22.252Z"} +{"id":"c-44b421","taskId":"tsk-mvey8ngt-tghgvt","status":"locked","at":"2026-10-19T07:45:03.560Z"} +{"id":"c-2123cb","taskId":"tsk-mvey915n-8cs2b5","status":"locked","at":"2026-10-19T07:45:20.957Z"} +{"id":"c-2e2040","taskId":"tsk-mvf4c3oa-dc40fo","status":"locked","at":"2026-10-19T10:35:43.284Z"} +{"id":"c-69fb70","taskId":"tsk-mvf4c3s4-xn4847","status":"locked","at":"2026-10-19T10:35:43.406Z"} +{"id":"c-4d02a6","taskId":"tsk-mvf4xez4-00j1za","status":"locked","at":"2026-10-19T10:52:16.238Z"} +{"id":"c-8595ef","taskId":"tsk-mvf4xf42-qjodki","status":"locked","at":"2026-10-19T10:52:16.361Z"} +{"id":"c-9f4505","taskId":"tsk-mvf4xf8t-hyoe72","status":"locked","at":"2026-10-19T10:52:16.481Z"} +{"id":"c-e0acee","taskId":"tsk-mvf70f4h-isuu0c","status":"locked","at":"2026-10-19T11:50:37.106Z"} +{"id":"c-0fb2aa","taskId":"tsk-mvf70fjh-fmn5my","status":"locked","at":"2026-10-19T11:50:37.239Z"} +{"id":"c-c44ef8","taskId":"tsk-mvf70fq3-r9ruuc","status":"locked","at":"2026-10-19T11:50:37.491Z"} +{"id":"c-1fb1ff","taskId":"tsk-mvf7jpll-z8910m","status":"locked","at":"2026-10-19T12:05:49.757Z"} +{"id":"c-c1a71d","taskId":"tsk-mvf9rld7-x1htmf","status":"locked","at":"2026-10-19T13:07:50.304Z"} +{"id":"c-65c93f","taskId":"tsk-mvf9rlgq-6v0ddu","status":"locked","at":"2026-10-19T13:07:50.430Z"} +{"id":"c-6c5676","taskId":"tsk-mvf9rlkh-oyyusj","status":"locked","at":"2026-10-19T13:07:50.557Z"} +{"id":"c-cf44a1","taskId":"tsk-mvf9rlnr-ts175i","status":"locked","at":"2026-10-19T13:07:50.681Z"} +{"id":"c-3d5ca3","taskId":"tsk-mvf9rlqz-0bc6j8","status":"locked","at":"2026-10-19T13:07:50.801Z"} +{"id":"c-670353","taskId":"tsk-mvf9twme-9avzxk","status":"locked","at":"2026-10-19T13:09:30.130Z"} +{"id":"c-4537bf","taskId":"tsk-mvfamuiv-lyz0jg","status":"locked","at":"2026-10-19T13:32:02.114Z"} +{"id":"c-a3cc1c","taskId":"tsk-mvfc5h86-z8ef14","status":"locked","at":"2026-10-19T14:14:31.141Z"} +{"id":"c-d76698","taskId":"tsk-mvfdi1tt-t8vr7z","status":"locked","at":"2026-10-19T14:52:16.227Z"} +{"id":"c-d2023b","taskId":"tsk-mvffsow8-ogb7fz","status":"locked","at":"2026-10-19T15:56:36.455Z"} +{"id":"c-c62743","taskId":"tsk-mvfiv513-vvk5an","status":"locked","at":"2026-10-19T17:22:24.635Z"} +{"id":"c-127d7d","taskId":"tsk-mvfixl0v-di486t","status":"locked","at":"2026-10-19T17:24:21.500Z"} +{"id":"c-05c7ce","taskId":"tsk-mvfixl4d-wu25gw","status":"locked","at":"2026-10-19T17:24:21.620Z"} +{"id":"c-b48bf0","taskId":"tsk-mvfl8a7k-641iow","status":"locked","at":"2026-10-19T18:28:59.564Z"} +{"id":"c-e1164a","taskId":"tsk-mvfl8acb-0806yq","status":"locked","at":"2026-10-19T18:28:59.683Z"} +{"id":"c-3f05f7","taskId":"tsk-mvfl8agk-vm3y4j","status":"locked","at":"2026-10-19T18:28:59.801Z"} +{"id":"c-6882ee","taskId":"tsk-mvfns901-uz5ej3","status":"locked","at":"2026-10-19T19:40:12.952Z"} +{"id":"c-d0ac4c","taskId":"tsk-mvfo16u4-pdz4fg","status":"locked","at":"2026-10-19T19:47:05.419Z"} +{"id":"c-c44cae","taskId":"tsk-mvfohzqk-xg47t7","status":"locked","at":"2026-10-19T20:00:18.766Z"} +{"id":"c-59e3f5","taskId":"tsk-mvfoqs1j-gik5ls","status":"locked","at":"2026-10-19T20:06:59.040Z"} +{"id":"c-147f9f","taskId":"tsk-mvfr897j-gvxts8","status":"locked","at":"2026-10-19T21:16:33.554Z"} +{"id":"c-e5a1cd","taskId":"tsk-mvfr89bs-sr3cs4","status":"locked","at":"2026-10-19T21:16:33.675Z"} +{"id":"c-46323a","taskId":"tsk-mvfusz4r-iiqduh","status":"locked","at":"2026-10-19T22:56:46.468Z"} +{"id":"c-c296e8","taskId":"tsk-mvfusz8h-6pkxkz","status":"locked","at":"2026-10-19T22:56:46.587Z"} +{"id":"c-c9f395","taskId":"tsk-mvfuszbz-xpikgu","status":"locked","at":"2026-10-19T22:56:46.708Z"} +{"id":"c-f4b245","taskId":"tsk-mvfypvdw-vae18k","status":"locked","at":"2026-10-20T00:46:18.366Z"} +{"id":"c-b4e562","taskId":"tsk-mvg7dsl7-vj0q93","status":"locked","at":"2026-10-20T04:48:47.847Z"} diff --git a/.maestro/tasks/tasks.jsonl b/.maestro/tasks/tasks.jsonl index 3d2670de5..ff9a1b224 100644 --- a/.maestro/tasks/tasks.jsonl +++ b/.maestro/tasks/tasks.jsonl @@ -194,7 +194,7 @@ {"id":"tsk-mv799sew-y6p1j3","slug":"w4-cookbook","title":"W4: Cookbook recipes + 0.5->0.6 migration guide","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/id-effect-consolidation-0-6.md","mission_id":"pln-mv799j4f-1r1u6b","blocked_by":[],"created_at":"2026-10-13T22:31:39.032Z","updated_at":"2026-10-14T06:31:38.920Z","assignee":"id-implementer","claimed_at":"2026-10-14T04:23:07.732Z","merged_at":"2026-10-14T06:31:38.920Z"} {"id":"tsk-mv799sew-hze94u","slug":"w4-ergonomics","title":"W4: Capability boilerplate reduction + diagnostics call-site context","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/id-effect-consolidation-0-6.md","mission_id":"pln-mv799j4f-1r1u6b","blocked_by":[],"created_at":"2026-10-13T22:31:39.032Z","updated_at":"2026-10-14T06:31:39.236Z","assignee":"id-implementer","claimed_at":"2026-10-14T04:23:07.870Z","merged_at":"2026-10-14T06:31:39.235Z"} {"id":"tsk-mv799sew-lozgp9","slug":"w5-release-060","title":"W5: Release 0.6.0 lockstep, tag, publish (outward-facing; explicit go required)","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/id-effect-consolidation-0-6.md","mission_id":"pln-mv799j4f-1r1u6b","blocked_by":[],"created_at":"2026-10-13T22:31:39.032Z","updated_at":"2026-10-14T22:17:11.220Z","assignee":"claude","claimed_at":"2026-10-14T04:57:43.846Z","merged_at":"2026-10-14T22:17:11.219Z"} -{"id":"tsk-mv799sew-y9wf9g","slug":"w5-downstream","title":"W5: Migrate assay and solana to crates.io 0.6; decide webapp archive/migrate","state":"draft","spec_path":"/data/Code/rust/id_effect/.maestro/specs/id-effect-consolidation-0-6.md","mission_id":"pln-mv799j4f-1r1u6b","blocked_by":[],"created_at":"2026-10-13T22:31:39.032Z","updated_at":"2026-10-13T22:31:39.032Z"} +{"id":"tsk-mv799sew-y9wf9g","slug":"w5-downstream","title":"W5: Migrate assay and solana to crates.io 0.6; decide webapp archive/migrate","state":"abandoned","spec_path":"/data/Code/rust/id_effect/.maestro/specs/id-effect-consolidation-0-6.md","mission_id":"pln-mv799j4f-1r1u6b","blocked_by":[],"created_at":"2026-10-13T22:31:39.032Z","updated_at":"2026-10-15T19:40:46.568Z","abandon_reason":"superseded by PLAN-OF-PLANS E0.5 (downstream migration to 0.7+); 0.7.0 shipped, migrating downstream to 0.6 is obsolete"} {"id":"tsk-mv8ohjj3-90iy9k","slug":"w0-draft-pr","title":"W0: Push release/0.7.0 and open draft PR to main (CI witness)","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/0.7/id-effect-runtime-0-7.mission.md","mission_id":"pln-mv8oh29m-3zdrrj","blocked_by":[],"created_at":"2026-10-14T22:25:21.183Z","updated_at":"2026-10-14T22:33:21.596Z","assignee":"claude","claimed_at":"2026-10-14T22:31:19.216Z","pr_url":"https://github.com/Industrial/id_effect/pull/104","merged_at":"2026-10-14T22:33:21.595Z"} {"id":"tsk-mv8ohjj3-6iqjq3","slug":"b0-bench-core","title":"W0: Core gate benches (async bind, flat_map depth, stream, channel/queue)","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/0.7/id-effect-runtime-0-7.mission.md","mission_id":"pln-mv8oh29m-3zdrrj","blocked_by":[],"created_at":"2026-10-14T22:25:21.183Z","updated_at":"2026-10-14T22:38:49.705Z","assignee":"claude","claimed_at":"2026-10-14T22:31:19.381Z","merged_at":"2026-10-14T22:38:49.704Z"} {"id":"tsk-mv8ohjj3-et011r","slug":"b0-bench-fiber","title":"W0: Fiber gate benches (spawn/join, sleep wall + CPU time)","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/0.7/id-effect-runtime-0-7.mission.md","mission_id":"pln-mv8oh29m-3zdrrj","blocked_by":[],"created_at":"2026-10-14T22:25:21.183Z","updated_at":"2026-10-14T22:38:26.158Z","assignee":"claude","claimed_at":"2026-10-14T22:31:19.501Z","merged_at":"2026-10-14T22:38:26.157Z"} @@ -278,3 +278,192 @@ {"id":"tsk-mv8vpo06-lyr4dj","slug":"k-rebuild-10","title":"K3: k-benches-next — benches on next, IDs unchanged","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/0.7/id-effect-runtime-0-7.mission.md","mission_id":"pln-mv8oh29m-3zdrrj","blocked_by":["tsk-mv8vpo06-8zkmpw"],"parent_id":"tsk-mv8ohjj3-7xjiyu","created_at":"2026-10-15T01:47:37.543Z","updated_at":"2026-10-15T03:13:33.619Z","assignee":"claude","claimed_at":"2026-10-15T02:59:52.741Z","merged_at":"2026-10-15T03:13:33.618Z"} {"id":"tsk-mv8vpo06-neqx58","slug":"k-rebuild-11","title":"K4: k-runloop-opt — async_bind_64 toward 1.25x, ablation table","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/0.7/id-effect-runtime-0-7.mission.md","mission_id":"pln-mv8oh29m-3zdrrj","blocked_by":["tsk-mv8vpo06-lyr4dj"],"parent_id":"tsk-mv8ohjj3-7xjiyu","created_at":"2026-10-15T01:47:37.543Z","updated_at":"2026-10-15T05:01:32.297Z","assignee":"claude","claimed_at":"2026-10-15T03:13:33.729Z","merged_at":"2026-10-15T05:01:32.296Z"} {"id":"tsk-mv8vpo06-m6s6ex","slug":"k-rebuild-12","title":"K4: k-ui-suite — trybuild suite + corpus port, delete spikes","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/0.7/id-effect-runtime-0-7.mission.md","mission_id":"pln-mv8oh29m-3zdrrj","blocked_by":["tsk-mv8vpo06-neqx58"],"parent_id":"tsk-mv8ohjj3-7xjiyu","created_at":"2026-10-15T01:47:37.543Z","updated_at":"2026-10-15T05:04:14.312Z","assignee":"claude","claimed_at":"2026-10-15T04:13:18.723Z","merged_at":"2026-10-15T05:04:14.312Z"} +{"id":"tsk-mv9xeqip-pyftp2","slug":"leaf-adr-0012","title":"W0: ADR 0012 platform vision; amend ADR 0010, README scope, QUALITY_BAR and tier-conformance for the platform tier","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/roadmap-bootstrap.md","mission_id":"pln-mv9xenpd-d2lpkn","blocked_by":[],"created_at":"2026-10-15T19:22:52.993Z","updated_at":"2026-10-15T20:31:14.076Z","assignee":"claude-opus","claimed_at":"2026-10-15T19:39:58.330Z","merged_at":"2026-10-15T20:31:14.075Z"} +{"id":"tsk-mv9xeqip-b9jlzp","slug":"leaf-scorecard-tooling","title":"W0: Move scorecard to docs/scorecard; scripts/scorecard.sh compute + self-test + schema validation; moon task + CI step","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/roadmap-bootstrap.md","mission_id":"pln-mv9xenpd-d2lpkn","blocked_by":[],"created_at":"2026-10-15T19:22:52.993Z","updated_at":"2026-10-15T20:31:14.289Z","assignee":"claude-opus","claimed_at":"2026-10-15T19:39:58.458Z","merged_at":"2026-10-15T20:31:14.288Z"} +{"id":"tsk-mv9xeqip-lny39q","slug":"leaf-close-w5","title":"W0: Abandon stale W5 task and close the 0.6 consolidation mission","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/roadmap-bootstrap.md","mission_id":"pln-mv9xenpd-d2lpkn","blocked_by":[],"created_at":"2026-10-15T19:22:52.993Z","updated_at":"2026-10-15T20:31:14.507Z","assignee":"claude-opus","claimed_at":"2026-10-15T19:39:58.585Z","merged_at":"2026-10-15T20:31:14.506Z"} +{"id":"tsk-mv9xeqip-axc0oj","slug":"leaf-scorecard-report","title":"W1: scorecard.sh --diff/--compare-targets/--report/--html; publish REPORT-0.7.0 and index.html","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/roadmap-bootstrap.md","mission_id":"pln-mv9xenpd-d2lpkn","blocked_by":[],"created_at":"2026-10-15T19:22:52.993Z","updated_at":"2026-10-16T03:09:35.661Z","assignee":"claude-opus","claimed_at":"2026-10-15T21:35:19.751Z","merged_at":"2026-10-16T03:09:35.660Z"} +{"id":"tsk-mv9xeqip-pixe4f","slug":"leaf-method-v2-checklists","title":"W1: Method v2 table-stakes checklists for all 82 rows; --method 2; v1 vs v2 report","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/roadmap-bootstrap.md","mission_id":"pln-mv9xenpd-d2lpkn","blocked_by":[],"created_at":"2026-10-15T19:22:52.993Z","updated_at":"2026-10-16T03:09:35.815Z","assignee":"claude-opus","claimed_at":"2026-10-15T21:35:19.924Z","merged_at":"2026-10-16T03:09:35.814Z"} +{"id":"tsk-mv9zvfav-x5368h","slug":"fix-node-double-must-use","title":"Fix clippy double_must_use in id_effect_node","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/fix-node-double-must-use.md","blocked_by":[],"created_at":"2026-10-15T20:31:50.839Z","updated_at":"2026-10-15T21:35:19.598Z","assignee":"claude-opus","claimed_at":"2026-10-15T20:31:53.255Z","merged_at":"2026-10-15T21:35:19.598Z"} +{"id":"tsk-mvaoq06i-s0i7vr","slug":"method-rebase-2-0","title":"Scorecard method re-base to 2.0","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.8.0-method-rebase.md","blocked_by":[],"created_at":"2026-10-16T08:07:28.362Z","updated_at":"2026-10-16T11:31:58.124Z","claimed_at":"2026-10-16T08:07:30.850Z","merged_at":"2026-10-16T11:31:58.122Z"} +{"id":"tsk-mvaw7lqp-9fes2a","slug":"method-2-1-process-rows","title":"Scorecard method 2.1","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.8.0-method-2-1.md","blocked_by":[],"created_at":"2026-10-16T11:37:06.769Z","updated_at":"2026-10-16T11:39:31.365Z","claimed_at":"2026-10-16T11:37:09.119Z","merged_at":"2026-10-16T11:39:31.364Z"} +{"id":"tsk-mvawkapy-lzvg52","slug":"r08-e0-1-mit-relicense","title":"0.8 E0.1 relicense to MIT","state":"shipped","spec_path":".maestro/specs/releases/0.8/e0-1-mit-relicense.md","mission_id":"pln-mvawbp5i-g5m6ot","blocked_by":[],"created_at":"2026-10-16T11:46:59.014Z","updated_at":"2026-10-16T18:59:30.911Z","claimed_at":"2026-10-16T18:57:30.569Z","merged_at":"2026-10-16T18:59:30.910Z"} +{"id":"tsk-mvawkapy-nam47g","slug":"r08-e0-2-security-process","title":"0.8 E0.2 security process","state":"shipped","spec_path":".maestro/specs/releases/0.8/e0-2-security-process.md","mission_id":"pln-mvawbp5i-g5m6ot","blocked_by":[],"created_at":"2026-10-16T11:46:59.014Z","updated_at":"2026-10-16T13:37:25.573Z","claimed_at":"2026-10-16T11:47:21.501Z","merged_at":"2026-10-16T13:37:25.572Z"} +{"id":"tsk-mvawkapy-h9kogq","slug":"r08-e0-3-stability","title":"0.8 E0.3 semver-checks and stability policy","state":"shipped","spec_path":".maestro/specs/releases/0.8/e0-3-stability.md","mission_id":"pln-mvawbp5i-g5m6ot","blocked_by":[],"created_at":"2026-10-16T11:46:59.014Z","updated_at":"2026-10-16T13:37:25.932Z","claimed_at":"2026-10-16T11:47:22.668Z","merged_at":"2026-10-16T13:37:25.931Z"} +{"id":"tsk-mvawkapy-xdespa","slug":"r08-e0-4-governance","title":"0.8 E0.4 governance","state":"shipped","spec_path":".maestro/specs/releases/0.8/e0-4-governance.md","mission_id":"pln-mvawbp5i-g5m6ot","blocked_by":[],"created_at":"2026-10-16T11:46:59.014Z","updated_at":"2026-10-16T13:37:26.165Z","claimed_at":"2026-10-16T11:47:22.812Z","merged_at":"2026-10-16T13:37:26.164Z"} +{"id":"tsk-mvawkapy-tx4pfl","slug":"r08-e5-1-web-hardening","title":"0.8 E5.1 web hardening","state":"shipped","spec_path":".maestro/specs/releases/0.8/e5-1-web-hardening.md","mission_id":"pln-mvawbp5i-g5m6ot","blocked_by":[],"created_at":"2026-10-16T11:46:59.014Z","updated_at":"2026-10-16T13:37:26.434Z","claimed_at":"2026-10-16T11:47:22.944Z","merged_at":"2026-10-16T13:37:26.433Z"} +{"id":"tsk-mvawkapy-uuzduq","slug":"r08-e6-1a-metrics","title":"0.8 E6.1a metrics","state":"shipped","spec_path":".maestro/specs/releases/0.8/e6-1a-metrics.md","mission_id":"pln-mvawbp5i-g5m6ot","blocked_by":[],"created_at":"2026-10-16T11:46:59.014Z","updated_at":"2026-10-16T13:37:26.692Z","claimed_at":"2026-10-16T11:47:23.076Z","merged_at":"2026-10-16T13:37:26.691Z"} +{"id":"tsk-mvawkapy-y6r782","slug":"r08-e6-1b-health","title":"0.8 E6.1b health","state":"shipped","spec_path":".maestro/specs/releases/0.8/e6-1b-health.md","mission_id":"pln-mvawbp5i-g5m6ot","blocked_by":[],"created_at":"2026-10-16T11:46:59.014Z","updated_at":"2026-10-16T13:37:26.932Z","claimed_at":"2026-10-16T11:47:23.220Z","merged_at":"2026-10-16T13:37:26.931Z"} +{"id":"tsk-mvawkapy-km3wy8","slug":"r08-e2-6-compile-time","title":"0.8 E2.6 compile-time measurement","state":"shipped","spec_path":".maestro/specs/releases/0.8/e2-6-compile-time.md","mission_id":"pln-mvawbp5i-g5m6ot","blocked_by":[],"created_at":"2026-10-16T11:46:59.014Z","updated_at":"2026-10-16T13:37:27.448Z","claimed_at":"2026-10-16T11:47:23.525Z","merged_at":"2026-10-16T13:37:27.448Z"} +{"id":"tsk-mvawkapy-uh71va","slug":"r08-e1-10-cli-kit","title":"0.8 E1.10 CLI kit","state":"shipped","spec_path":".maestro/specs/releases/0.8/e1-10-cli-kit.md","mission_id":"pln-mvawbp5i-g5m6ot","blocked_by":[],"created_at":"2026-10-16T11:46:59.014Z","updated_at":"2026-10-16T13:37:27.202Z","claimed_at":"2026-10-16T11:47:23.381Z","merged_at":"2026-10-16T13:37:27.201Z"} +{"id":"tsk-mvawkapy-fwcf2f","slug":"r08-e2-1h-hygiene","title":"0.8 E2.1h hygiene","state":"shipped","spec_path":".maestro/specs/releases/0.8/e2-1h-hygiene.md","mission_id":"pln-mvawbp5i-g5m6ot","blocked_by":[],"created_at":"2026-10-16T11:46:59.014Z","updated_at":"2026-10-16T13:37:27.686Z","claimed_at":"2026-10-16T11:47:23.660Z","merged_at":"2026-10-16T13:37:27.686Z"} +{"id":"tsk-mvb0jm49-3ytgwg","slug":"r08-e2-1c-cron-resilience","title":"0.8 E2.1c+r — cron and resilience","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.8/e2-1c-cron-resilience.md","blocked_by":[],"created_at":"2026-10-16T13:38:25.593Z","updated_at":"2026-10-16T14:49:10.578Z","claimed_at":"2026-10-16T13:38:34.667Z","merged_at":"2026-10-16T14:49:10.576Z"} +{"id":"tsk-mvb0jm8t-w9qmja","slug":"r08-e2-1s-stream-ops","title":"0.8 E2.1s — stream operators","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.8/e2-1s-stream-ops.md","blocked_by":[],"created_at":"2026-10-16T13:38:25.757Z","updated_at":"2026-10-16T14:49:11.087Z","claimed_at":"2026-10-16T13:38:34.846Z","merged_at":"2026-10-16T14:49:11.084Z"} +{"id":"tsk-mvb0jmcb-ajdki6","slug":"r08-e2-3-schema","title":"0.8 E2.3 — schema","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.8/e2-3-schema.md","blocked_by":[],"created_at":"2026-10-16T13:38:25.883Z","updated_at":"2026-10-16T14:49:11.448Z","claimed_at":"2026-10-16T13:38:34.980Z","merged_at":"2026-10-16T14:49:11.446Z"} +{"id":"tsk-mvb0jmfu-jljtq9","slug":"r08-e2-2-property-testing","title":"0.8 E2.2 — property testing and test helpers","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.8/e2-2-property-testing.md","blocked_by":[],"created_at":"2026-10-16T13:38:26.010Z","updated_at":"2026-10-16T14:49:11.861Z","claimed_at":"2026-10-16T13:38:35.108Z","merged_at":"2026-10-16T14:49:11.859Z"} +{"id":"tsk-mvb0jmj8-is6qdv","slug":"r08-e2-4-determinism-idioms","title":"0.8 E2.4 + E1.8","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.8/e2-4-determinism-idioms.md","blocked_by":[],"created_at":"2026-10-16T13:38:26.132Z","updated_at":"2026-10-16T14:49:12.223Z","claimed_at":"2026-10-16T13:38:35.234Z","merged_at":"2026-10-16T14:49:12.222Z"} +{"id":"tsk-mvb3399t-pysl4g","slug":"r08-e2-3b-schema-followups","title":"0.8 E2.3b — schema follow-ups","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.8/e2-3b-schema-followups.md","blocked_by":[],"created_at":"2026-10-16T14:49:41.297Z","updated_at":"2026-10-16T18:54:06.964Z","claimed_at":"2026-10-16T14:49:43.545Z","merged_at":"2026-10-16T18:54:06.963Z"} +{"id":"tsk-mvb8iicl-xlm756","slug":"r08-w3-a-kit","title":"0.8 W3-A — id_effect_kit","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.8/w3-a-kit.md","blocked_by":[],"created_at":"2026-10-16T17:21:30.981Z","updated_at":"2026-10-16T18:54:07.181Z","claimed_at":"2026-10-16T17:21:36.061Z","merged_at":"2026-10-16T18:54:07.181Z"} +{"id":"tsk-mvb8iifv-1kp3tn","slug":"r08-w3-d-diagnostics-gallery","title":"0.8 W3-D — diagnostics gallery","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.8/w3-d-diagnostics-gallery.md","blocked_by":[],"created_at":"2026-10-16T17:21:31.099Z","updated_at":"2026-10-16T18:54:07.397Z","claimed_at":"2026-10-16T17:21:36.241Z","merged_at":"2026-10-16T18:54:07.396Z"} +{"id":"tsk-mvb8iij2-kzsw5d","slug":"r08-w3-e-book-snippets","title":"0.8 W3-E — book snippets","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.8/w3-e-book-snippets.md","blocked_by":[],"created_at":"2026-10-16T17:21:31.214Z","updated_at":"2026-10-16T18:54:07.616Z","claimed_at":"2026-10-16T17:21:36.361Z","merged_at":"2026-10-16T18:54:07.615Z"} +{"id":"tsk-mvb8sxid-wpkrtg","slug":"r08-w3-b-tool-new","title":"0.8 W3-B — id_effect_tool and `id-effect new`","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.8/w3-b-tool-new.md","blocked_by":[],"created_at":"2026-10-16T17:29:37.189Z","updated_at":"2026-10-16T18:54:08.048Z","claimed_at":"2026-10-16T17:29:39.333Z","merged_at":"2026-10-16T18:54:08.047Z"} +{"id":"tsk-mvb9dhfl-6cdwnk","slug":"r08-w3-e4-snippet-deps","title":"0.8 W3-E4 — snippet dependencies","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.8/w3-e4-snippet-deps.md","blocked_by":[],"created_at":"2026-10-16T17:45:36.129Z","updated_at":"2026-10-16T18:54:07.833Z","claimed_at":"2026-10-16T17:45:38.408Z","merged_at":"2026-10-16T18:54:07.832Z"} +{"id":"tsk-mvb9wqu3-mo1a0u","slug":"r08-w3-c-doctor","title":"0.8 W3-C — doctor and layer graph","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.8/w3-c-doctor.md","blocked_by":[],"created_at":"2026-10-16T18:00:34.779Z","updated_at":"2026-10-16T18:54:08.268Z","claimed_at":"2026-10-16T18:00:37.135Z","merged_at":"2026-10-16T18:54:08.268Z"} +{"id":"tsk-mvbanfma-w9xz64","slug":"r08-w3-f-tutorial-kpis","title":"0.8 W3-F — tutorial and DX KPIs","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.8/w3-f-tutorial-kpis.md","blocked_by":[],"created_at":"2026-10-16T18:21:19.954Z","updated_at":"2026-10-16T18:54:08.487Z","claimed_at":"2026-10-16T18:21:20.061Z","merged_at":"2026-10-16T18:54:08.487Z"} +{"id":"tsk-mvbbmmz0-vebdrd","slug":"r08-e0-1a-remove-obix-outbox","title":"0.8 E0.1a — remove the labs obix outbox adapter","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.8/e0-1a-remove-obix-outbox.md","blocked_by":[],"created_at":"2026-10-16T18:48:42.444Z","updated_at":"2026-10-16T18:59:30.675Z","claimed_at":"2026-10-16T18:48:42.605Z","merged_at":"2026-10-16T18:59:30.674Z"} +{"id":"tsk-mvbdsp4f-xmojqz","slug":"r09-t0-adr-skeletons","title":"T0 ADR 0010 amendment 5, new crate skeletons, packaging/publish order","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md","mission_id":"pln-mvbdsaam-th3g19","blocked_by":[],"created_at":"2026-10-16T19:49:24.399Z","updated_at":"2026-10-16T19:53:23.966Z","claimed_at":"2026-10-16T19:49:46.749Z","merged_at":"2026-10-16T19:53:23.965Z"} +{"id":"tsk-mvbdsp4f-wd3fai","slug":"r09-e3-1a-values-rows","title":"E3.1a SqlValue and typed SqlRow (design review first)","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md","mission_id":"pln-mvbdsaam-th3g19","blocked_by":[],"created_at":"2026-10-16T19:49:24.399Z","updated_at":"2026-10-16T20:51:45.932Z","claimed_at":"2026-10-16T19:49:46.900Z","merged_at":"2026-10-16T20:51:45.931Z"} +{"id":"tsk-mvbdsp4f-e7yo8j","slug":"r09-e3-1a-from-row","title":"E3.1a derive(FromRow)","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md","mission_id":"pln-mvbdsaam-th3g19","blocked_by":[],"created_at":"2026-10-16T19:49:24.399Z","updated_at":"2026-10-16T21:16:45.622Z","claimed_at":"2026-10-16T20:51:52.500Z","merged_at":"2026-10-16T21:16:45.621Z"} +{"id":"tsk-mvbdsp4f-spcif4","slug":"r09-e3-1a-tx-stream","title":"E3.1a savepoints, isolation levels, query_stream","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md","mission_id":"pln-mvbdsaam-th3g19","blocked_by":[],"created_at":"2026-10-16T19:49:24.399Z","updated_at":"2026-10-16T21:16:45.856Z","claimed_at":"2026-10-16T20:51:52.813Z","merged_at":"2026-10-16T21:16:45.855Z"} +{"id":"tsk-mvbdsp4f-ua6x6x","slug":"r09-e3-1a-ripple-docs","title":"E3.1a ripple compile fixes and typed-SQL book chapter","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md","mission_id":"pln-mvbdsaam-th3g19","blocked_by":[],"created_at":"2026-10-16T19:49:24.399Z","updated_at":"2026-10-16T20:51:46.163Z","claimed_at":"2026-10-16T20:39:31.168Z","merged_at":"2026-10-16T20:51:46.162Z"} +{"id":"tsk-mvbdsp4f-a1hau9","slug":"r09-e3-2-migrate-api","title":"E3.2 id_effect_sql::migrate with advisory lock","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md","mission_id":"pln-mvbdsaam-th3g19","blocked_by":[],"created_at":"2026-10-16T19:49:24.399Z","updated_at":"2026-10-16T21:16:46.089Z","claimed_at":"2026-10-16T20:51:52.979Z","merged_at":"2026-10-16T21:16:46.088Z"} +{"id":"tsk-mvbdsp4f-2m3dmu","slug":"r09-e3-2-cli","title":"E3.2 id-effect migrate new/run/status/revert/check","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md","mission_id":"pln-mvbdsaam-th3g19","blocked_by":[],"created_at":"2026-10-16T19:49:24.399Z","updated_at":"2026-10-16T21:21:33.932Z","claimed_at":"2026-10-16T21:09:02.510Z","merged_at":"2026-10-16T21:21:33.931Z"} +{"id":"tsk-mvbdsp4f-0yq2ci","slug":"r09-e3-2-docs","title":"E3.2 migrations book chapter and example","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md","mission_id":"pln-mvbdsaam-th3g19","blocked_by":[],"created_at":"2026-10-16T19:49:24.399Z","updated_at":"2026-10-16T21:41:05.396Z","claimed_at":"2026-10-16T21:21:34.047Z","merged_at":"2026-10-16T21:41:05.395Z"} +{"id":"tsk-mvbdsp4f-ex9rcm","slug":"r09-e4-1-route-descriptor","title":"E4.1 typed route descriptor and registry","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md","mission_id":"pln-mvbdsaam-th3g19","blocked_by":[],"created_at":"2026-10-16T19:49:24.399Z","updated_at":"2026-10-16T20:04:01.449Z","claimed_at":"2026-10-16T19:49:47.026Z","merged_at":"2026-10-16T20:04:01.448Z"} +{"id":"tsk-mvbdsp4f-cbg990","slug":"r09-e4-1-extractors-problem","title":"E4.1 typed extractors with Problem+JSON errors","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md","mission_id":"pln-mvbdsaam-th3g19","blocked_by":[],"created_at":"2026-10-16T19:49:24.399Z","updated_at":"2026-10-16T20:31:19.731Z","claimed_at":"2026-10-16T20:04:05.128Z","merged_at":"2026-10-16T20:31:19.730Z"} +{"id":"tsk-mvbdsp4f-y1xx0b","slug":"r09-e4-1-docs","title":"E4.1 typed routes book chapter and example","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md","mission_id":"pln-mvbdsaam-th3g19","blocked_by":[],"created_at":"2026-10-16T19:49:24.399Z","updated_at":"2026-10-16T20:40:04.960Z","claimed_at":"2026-10-16T20:31:59.062Z","merged_at":"2026-10-16T20:40:04.959Z"} +{"id":"tsk-mvbdsp4f-c6kk1f","slug":"r09-e5-2a-passwords-sessions","title":"E5.2a argon2 passwords and server-side sessions","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md","mission_id":"pln-mvbdsaam-th3g19","blocked_by":[],"created_at":"2026-10-16T19:49:24.399Z","updated_at":"2026-10-16T20:16:28.651Z","claimed_at":"2026-10-16T19:53:30.145Z","merged_at":"2026-10-16T20:16:28.650Z"} +{"id":"tsk-mvbdsp4f-i8nl5p","slug":"r09-e5-2a-credentials-jwt","title":"E5.2a credentials and JWT","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md","mission_id":"pln-mvbdsaam-th3g19","blocked_by":[],"created_at":"2026-10-16T19:49:24.399Z","updated_at":"2026-10-16T20:56:27.923Z","claimed_at":"2026-10-16T20:31:57.984Z","merged_at":"2026-10-16T20:56:27.922Z"} +{"id":"tsk-mvbdsp4f-bcinb5","slug":"r09-e5-3-policies","title":"E5.3 id_effect_authz policies as capabilities","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md","mission_id":"pln-mvbdsaam-th3g19","blocked_by":[],"created_at":"2026-10-16T19:49:24.399Z","updated_at":"2026-10-16T20:08:45.675Z","claimed_at":"2026-10-16T19:53:30.018Z","merged_at":"2026-10-16T20:08:45.675Z"} +{"id":"tsk-mvbdsp4f-5hrfbn","slug":"r09-e5-3-guards-testing","title":"E5.3 route guards and authz testing","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md","mission_id":"pln-mvbdsaam-th3g19","blocked_by":[],"created_at":"2026-10-16T19:49:24.399Z","updated_at":"2026-10-16T20:30:26.436Z","claimed_at":"2026-10-16T20:08:55.459Z","merged_at":"2026-10-16T20:30:26.435Z"} +{"id":"tsk-mvbdsp4f-p6l6nv","slug":"r09-e5-4-profiles","title":"E5.4 config profiles","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md","mission_id":"pln-mvbdsaam-th3g19","blocked_by":[],"created_at":"2026-10-16T19:49:24.399Z","updated_at":"2026-10-16T20:03:11.177Z","claimed_at":"2026-10-16T19:49:47.151Z","merged_at":"2026-10-16T20:03:11.176Z"} +{"id":"tsk-mvbdsp4f-souo6g","slug":"r09-e5-4-vault","title":"E5.4 Vault secret provider (feature vault)","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9.0.md","mission_id":"pln-mvbdsaam-th3g19","blocked_by":[],"created_at":"2026-10-16T19:49:24.399Z","updated_at":"2026-10-16T20:23:51.877Z","claimed_at":"2026-10-16T20:03:16.206Z","merged_at":"2026-10-16T20:23:51.876Z"} +{"id":"tsk-mvbg796n-xpvbrc","slug":"r09-e5-2a-pg-session-store","title":"PgSessionStore","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/e5-2a-pg-session-store.md","blocked_by":[],"created_at":"2026-10-16T20:56:42.815Z","updated_at":"2026-10-16T21:10:17.582Z","claimed_at":"2026-10-16T20:56:47.630Z","merged_at":"2026-10-16T21:10:17.580Z"} +{"id":"tsk-mvbh3xq5-gl0mv7","slug":"r09-e3-1b-conformance-routing","title":"r09-e3-1b-conformance-routing","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e3-1b-conformance-routing.md","blocked_by":[],"created_at":"2026-10-16T21:22:07.613Z","updated_at":"2026-10-16T22:13:05.351Z","claimed_at":"2026-10-16T21:22:20.790Z","merged_at":"2026-10-16T22:13:05.350Z"} +{"id":"tsk-mvbh3xu3-3dzjef","slug":"r09-e3-1b-mysql","title":"r09-e3-1b-mysql","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e3-1b-mysql.md","blocked_by":[],"created_at":"2026-10-16T21:22:07.755Z","updated_at":"2026-10-16T23:05:13.504Z","claimed_at":"2026-10-16T22:13:18.095Z","merged_at":"2026-10-16T23:05:13.503Z"} +{"id":"tsk-mvbh3xxf-emsxrb","slug":"r09-e3-1b-sqlite","title":"r09-e3-1b-sqlite","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e3-1b-sqlite.md","blocked_by":[],"created_at":"2026-10-16T21:22:07.875Z","updated_at":"2026-10-16T22:13:04.428Z","claimed_at":"2026-10-16T21:22:20.612Z","merged_at":"2026-10-16T22:13:04.427Z"} +{"id":"tsk-mvbh3y0u-n3qtug","slug":"r09-e3-3-docs","title":"r09-e3-3-docs","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e3-3-docs.md","blocked_by":[],"created_at":"2026-10-16T21:22:07.998Z","updated_at":"2026-10-16T23:20:24.306Z","claimed_at":"2026-10-16T23:01:53.820Z","merged_at":"2026-10-16T23:20:24.305Z"} +{"id":"tsk-mvbh3y45-3zte0e","slug":"r09-e3-3-repository","title":"r09-e3-3-repository","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e3-3-repository.md","blocked_by":[],"created_at":"2026-10-16T21:22:08.116Z","updated_at":"2026-10-16T23:01:53.695Z","claimed_at":"2026-10-16T22:13:17.564Z","merged_at":"2026-10-16T23:01:53.694Z"} +{"id":"tsk-mvbh3y7e-zu7pb4","slug":"r09-e3-3-sea-query","title":"r09-e3-3-sea-query","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e3-3-sea-query.md","blocked_by":[],"created_at":"2026-10-16T21:22:08.234Z","updated_at":"2026-10-16T22:13:06.242Z","claimed_at":"2026-10-16T21:22:20.913Z","merged_at":"2026-10-16T22:13:06.241Z"} +{"id":"tsk-mvbh3yap-l09oxk","slug":"r09-e3-4-redis","title":"r09-e3-4-redis","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e3-4-redis.md","blocked_by":[],"created_at":"2026-10-16T21:22:08.353Z","updated_at":"2026-10-16T23:19:54.789Z","claimed_at":"2026-10-16T22:13:18.600Z","merged_at":"2026-10-16T23:19:54.788Z"} +{"id":"tsk-mvbh3ye3-0dqyir","slug":"r09-e4-1b-layers","title":"r09-e4-1b-layers","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e4-1b-layers.md","blocked_by":[],"created_at":"2026-10-16T21:22:08.475Z","updated_at":"2026-10-16T21:45:58.108Z","claimed_at":"2026-10-16T21:22:21.158Z","merged_at":"2026-10-16T21:45:58.107Z"} +{"id":"tsk-mvbh3yhd-mlyvgg","slug":"r09-e4-1b-static-cache","title":"r09-e4-1b-static-cache","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e4-1b-static-cache.md","blocked_by":[],"created_at":"2026-10-16T21:22:08.593Z","updated_at":"2026-10-16T22:10:57.537Z","claimed_at":"2026-10-16T21:46:06.599Z","merged_at":"2026-10-16T22:10:57.537Z"} +{"id":"tsk-mvbh3yki-lxt7mh","slug":"r09-e4-2-openapi-doc","title":"r09-e4-2-openapi-doc","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e4-2-openapi-doc.md","blocked_by":[],"created_at":"2026-10-16T21:22:08.706Z","updated_at":"2026-10-16T22:10:57.296Z","claimed_at":"2026-10-16T21:22:21.033Z","merged_at":"2026-10-16T22:10:57.295Z"} +{"id":"tsk-mvbh3yns-8zm2bm","slug":"r09-e4-2-ui-routes","title":"r09-e4-2-ui-routes","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e4-2-ui-routes.md","blocked_by":[],"created_at":"2026-10-16T21:22:08.824Z","updated_at":"2026-10-16T22:57:20.357Z","claimed_at":"2026-10-16T22:13:16.995Z","merged_at":"2026-10-16T22:57:20.356Z"} +{"id":"tsk-mvbh3yr2-bsovx7","slug":"r09-e5-2b-oidc","title":"r09-e5-2b-oidc","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e5-2b-oidc.md","blocked_by":[],"created_at":"2026-10-16T21:22:08.942Z","updated_at":"2026-10-16T22:05:27.813Z","claimed_at":"2026-10-16T21:22:21.276Z","merged_at":"2026-10-16T22:05:27.812Z"} +{"id":"tsk-mvbh3yue-83n9vp","slug":"r09-e5-2b-totp","title":"r09-e5-2b-totp","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e5-2b-totp.md","blocked_by":[],"created_at":"2026-10-16T21:22:09.062Z","updated_at":"2026-10-16T22:10:01.618Z","claimed_at":"2026-10-16T21:46:07.113Z","merged_at":"2026-10-16T22:10:01.617Z"} +{"id":"tsk-mvbh3yxp-p9pp0e","slug":"r09-e5-5-audit","title":"r09-e5-5-audit","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e5-5-audit.md","blocked_by":[],"created_at":"2026-10-16T21:22:09.181Z","updated_at":"2026-10-16T22:13:07.253Z","claimed_at":"2026-10-16T21:46:07.636Z","merged_at":"2026-10-16T22:13:07.252Z"} +{"id":"tsk-mvbh3z0z-819ef0","slug":"r09-e5-5-tenancy-doc","title":"r09-e5-5-tenancy-doc","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e5-5-tenancy-doc.md","blocked_by":[],"created_at":"2026-10-16T21:22:09.299Z","updated_at":"2026-10-16T23:20:24.526Z","claimed_at":"2026-10-16T21:46:08.171Z","merged_at":"2026-10-16T23:20:24.525Z"} +{"id":"tsk-mvbh3z49-hbvk3j","slug":"r09-e6-5-containers","title":"r09-e6-5-containers","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e6-5-containers.md","blocked_by":[],"created_at":"2026-10-16T21:22:09.417Z","updated_at":"2026-10-16T22:36:07.078Z","claimed_at":"2026-10-16T21:22:21.398Z","merged_at":"2026-10-16T22:36:07.077Z"} +{"id":"tsk-mvbh3z7s-2ol5gk","slug":"r09-e6-5-isolation-fixtures","title":"r09-e6-5-isolation-fixtures","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-e6-5-isolation-fixtures.md","blocked_by":[],"created_at":"2026-10-16T21:22:09.544Z","updated_at":"2026-10-16T23:14:50.881Z","claimed_at":"2026-10-16T22:36:31.897Z","merged_at":"2026-10-16T23:14:50.880Z"} +{"id":"tsk-mvbl55h5-z4i872","slug":"r09-sql-tls","title":"r09-sql-tls","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-sql-tls.md","blocked_by":[],"created_at":"2026-10-16T23:15:02.777Z","updated_at":"2026-10-16T23:45:47.843Z","claimed_at":"2026-10-16T23:15:05.868Z","merged_at":"2026-10-16T23:45:47.842Z"} +{"id":"tsk-mvblcbcl-ojq8t0","slug":"r09-w3-kit-template","title":"r09-w3-kit-template","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-w3-kit-template.md","blocked_by":[],"created_at":"2026-10-16T23:20:36.981Z","updated_at":"2026-10-16T23:39:01.886Z","claimed_at":"2026-10-16T23:20:44.184Z","merged_at":"2026-10-16T23:39:01.886Z"} +{"id":"tsk-mvblcbgd-lktftw","slug":"r09-w3-ledger","title":"r09-w3-ledger","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-w3-ledger.md","blocked_by":[],"created_at":"2026-10-16T23:20:37.117Z","updated_at":"2026-10-17T00:58:28.483Z","claimed_at":"2026-10-16T23:39:19.768Z","merged_at":"2026-10-17T00:58:28.482Z"} +{"id":"tsk-mvblcbjl-ijn35n","slug":"r09-w3-k3","title":"r09-w3-k3","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-w3-k3.md","blocked_by":[],"created_at":"2026-10-16T23:20:37.233Z","updated_at":"2026-10-17T01:21:32.394Z","claimed_at":"2026-10-17T00:58:35.196Z","merged_at":"2026-10-17T01:21:32.394Z"} +{"id":"tsk-mvblcbms-o8fsc1","slug":"r09-w3-level-audit","title":"r09-w3-level-audit","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-w3-level-audit.md","blocked_by":[],"created_at":"2026-10-16T23:20:37.348Z","updated_at":"2026-10-17T01:09:47.782Z","claimed_at":"2026-10-17T00:58:35.314Z","merged_at":"2026-10-17T01:09:47.781Z"} +{"id":"tsk-mvbm09jg-c92j9a","slug":"r09-w3-tutorial","title":"r09-w3-tutorial","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-w3-tutorial.md","blocked_by":[],"created_at":"2026-10-16T23:39:14.380Z","updated_at":"2026-10-16T23:47:20.433Z","claimed_at":"2026-10-16T23:39:17.662Z","merged_at":"2026-10-16T23:47:20.432Z"} +{"id":"tsk-mvbp968a-2m4p9i","slug":"r09-w3-gaps","title":"r09-w3-gaps","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.9/r09-w3-gaps.md","blocked_by":[],"created_at":"2026-10-17T01:10:08.842Z","updated_at":"2026-10-17T01:49:04.463Z","claimed_at":"2026-10-17T01:10:15.885Z","merged_at":"2026-10-17T01:49:04.462Z"} +{"id":"tsk-mvcjv2xv-k2omgn","slug":"r10-co-schema-errors","title":"r10-co-schema-errors","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-co-schema-errors.md","blocked_by":[],"created_at":"2026-10-17T15:26:59.491Z","updated_at":"2026-10-17T15:40:24.946Z","claimed_at":"2026-10-17T15:28:18.556Z","merged_at":"2026-10-17T15:40:24.945Z"} +{"id":"tsk-mvcjv31o-sevooz","slug":"r10-co-sql-errors","title":"r10-co-sql-errors","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-co-sql-errors.md","blocked_by":[],"created_at":"2026-10-17T15:26:59.628Z","updated_at":"2026-10-17T17:05:08.720Z","claimed_at":"2026-10-17T15:40:33.214Z","merged_at":"2026-10-17T17:05:08.719Z"} +{"id":"tsk-mvcjv35b-3d8syc","slug":"r10-e4-3-client-defaults","title":"r10-e4-3-client-defaults","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e4-3-client-defaults.md","blocked_by":[],"created_at":"2026-10-17T15:26:59.759Z","updated_at":"2026-10-17T17:04:22.461Z","claimed_at":"2026-10-17T15:40:02.954Z","merged_at":"2026-10-17T17:04:22.460Z"} +{"id":"tsk-mvcjv38n-qmey5i","slug":"r10-e4-3-tracing","title":"r10-e4-3-tracing","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e4-3-tracing.md","blocked_by":[],"created_at":"2026-10-17T15:26:59.878Z","updated_at":"2026-10-17T17:56:35.503Z","claimed_at":"2026-10-17T17:07:33.642Z","merged_at":"2026-10-17T17:56:35.502Z"} +{"id":"tsk-mvcjv3bz-jrd70e","slug":"r10-e4-4-container-docs","title":"r10-e4-4-container-docs","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e4-4-container-docs.md","blocked_by":[],"created_at":"2026-10-17T15:26:59.999Z","updated_at":"2026-10-17T18:14:13.791Z","claimed_at":"2026-10-17T17:57:38.500Z","merged_at":"2026-10-17T18:14:13.790Z"} +{"id":"tsk-mvcjv3fd-36woc8","slug":"r10-e4-4-storage","title":"r10-e4-4-storage","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e4-4-storage.md","blocked_by":[],"created_at":"2026-10-17T15:27:00.121Z","updated_at":"2026-10-17T17:04:22.708Z","claimed_at":"2026-10-17T15:41:54.599Z","merged_at":"2026-10-17T17:04:22.707Z"} +{"id":"tsk-mvcjv3iq-y7wzar","slug":"r10-e6-2-fiber-registry","title":"r10-e6-2-fiber-registry","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e6-2-fiber-registry.md","blocked_by":[],"created_at":"2026-10-17T15:27:00.242Z","updated_at":"2026-10-17T17:04:22.162Z","claimed_at":"2026-10-17T15:41:54.459Z","merged_at":"2026-10-17T17:04:22.162Z"} +{"id":"tsk-mvcjv3m4-28cr81","slug":"r10-e6-4-redaction-sampling","title":"r10-e6-4-redaction-sampling","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e6-4-redaction-sampling.md","blocked_by":[],"created_at":"2026-10-17T15:27:00.364Z","updated_at":"2026-10-17T15:39:52.412Z","claimed_at":"2026-10-17T15:28:18.680Z","merged_at":"2026-10-17T15:39:52.411Z"} +{"id":"tsk-mvcjv3pj-tef3al","slug":"r10-e6-5b-faults","title":"r10-e6-5b-faults","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e6-5b-faults.md","blocked_by":[],"created_at":"2026-10-17T15:27:00.487Z","updated_at":"2026-10-17T17:17:46.322Z","claimed_at":"2026-10-17T17:07:33.937Z","merged_at":"2026-10-17T17:17:46.321Z"} +{"id":"tsk-mvcjv3sy-7786hr","slug":"r10-e6-5b-guide","title":"r10-e6-5b-guide","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e6-5b-guide.md","blocked_by":[],"created_at":"2026-10-17T15:27:00.610Z","updated_at":"2026-10-17T18:03:22.874Z","claimed_at":"2026-10-17T17:17:46.430Z","merged_at":"2026-10-17T18:03:22.873Z"} +{"id":"tsk-mvcjv3wa-40y1j9","slug":"r10-e7-1-queue-core","title":"r10-e7-1-queue-core","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-1-queue-core.md","blocked_by":[],"created_at":"2026-10-17T15:27:00.730Z","updated_at":"2026-10-17T18:02:11.655Z","claimed_at":"2026-10-17T15:41:54.305Z","merged_at":"2026-10-17T18:02:11.654Z"} +{"id":"tsk-mvcjv3zi-qxn0mq","slug":"r10-e7-4-promote","title":"r10-e7-4-promote","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-4-promote.md","blocked_by":[],"created_at":"2026-10-17T15:27:00.846Z","updated_at":"2026-10-17T17:35:54.023Z","claimed_at":"2026-10-17T17:07:34.241Z","merged_at":"2026-10-17T17:35:54.022Z"} +{"id":"tsk-mvcjv42t-2myvtg","slug":"r10-e7-4-snapshots-subscriptions","title":"r10-e7-4-snapshots-subscriptions","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-4-snapshots-subscriptions.md","blocked_by":[],"created_at":"2026-10-17T15:27:00.965Z","updated_at":"2026-10-17T18:04:41.259Z","claimed_at":"2026-10-17T17:36:21.532Z","merged_at":"2026-10-17T18:04:41.258Z"} +{"id":"tsk-mvcjv466-ezgto8","slug":"r10-reload-config","title":"r10-reload-config","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-reload-config.md","blocked_by":[],"created_at":"2026-10-17T15:27:01.086Z","updated_at":"2026-10-17T17:13:33.440Z","claimed_at":"2026-10-17T17:07:34.106Z","merged_at":"2026-10-17T17:13:33.439Z"} +{"id":"tsk-mvcjv49j-j19atg","slug":"r10-reload-rolling","title":"r10-reload-rolling","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-reload-rolling.md","blocked_by":[],"created_at":"2026-10-17T15:27:01.207Z","updated_at":"2026-10-17T17:18:16.854Z","claimed_at":"2026-10-17T17:13:33.555Z","merged_at":"2026-10-17T17:18:16.853Z"} +{"id":"tsk-mvcjv4cx-u59do2","slug":"r10-sqlt-mysql-query-ci","title":"r10-sqlt-mysql-query-ci","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-sqlt-mysql-query-ci.md","blocked_by":[],"created_at":"2026-10-17T15:27:01.329Z","updated_at":"2026-10-17T17:05:08.950Z","claimed_at":"2026-10-17T15:43:18.039Z","merged_at":"2026-10-17T17:05:08.949Z"} +{"id":"tsk-mvcjv4gc-nokr7e","slug":"r10-sqlt-pg-wire","title":"r10-sqlt-pg-wire","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-sqlt-pg-wire.md","blocked_by":[],"created_at":"2026-10-17T15:27:01.452Z","updated_at":"2026-10-17T15:43:08.293Z","claimed_at":"2026-10-17T15:28:18.435Z","merged_at":"2026-10-17T15:43:08.293Z"} +{"id":"tsk-mvcjv4jp-snazrp","slug":"r10-t0-adr-skeletons","title":"r10-t0-adr-skeletons","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-t0-adr-skeletons.md","blocked_by":[],"created_at":"2026-10-17T15:27:01.573Z","updated_at":"2026-10-17T15:41:45.473Z","claimed_at":"2026-10-17T15:28:18.244Z","merged_at":"2026-10-17T15:41:45.472Z"} +{"id":"tsk-mvcjv4mz-pvmh4b","slug":"r10-t0-k2-k6","title":"r10-t0-k2-k6","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-t0-k2-k6.md","blocked_by":[],"created_at":"2026-10-17T15:27:01.691Z","updated_at":"2026-10-17T23:03:40.718Z","claimed_at":"2026-10-17T19:17:11.578Z","merged_at":"2026-10-17T23:03:40.716Z"} +{"id":"tsk-mvcpexet-7d5r2f","slug":"r10-e7-1-cron","title":"r10-e7-1-cron","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-1-cron.md","blocked_by":[],"created_at":"2026-10-17T18:02:23.524Z","updated_at":"2026-10-17T18:27:33.833Z","claimed_at":"2026-10-17T18:14:19.203Z","merged_at":"2026-10-17T18:27:33.832Z"} +{"id":"tsk-mvcpexhy-k9wn2c","slug":"r10-e7-1-docs-labs","title":"r10-e7-1-docs-labs","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-1-docs-labs.md","blocked_by":[],"created_at":"2026-10-17T18:02:23.637Z","updated_at":"2026-10-17T18:58:06.270Z","claimed_at":"2026-10-17T18:33:47.433Z","merged_at":"2026-10-17T18:58:06.268Z"} +{"id":"tsk-mvcpexl4-f87btw","slug":"r10-e7-2-broker-api","title":"r10-e7-2-broker-api","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-2-broker-api.md","blocked_by":[],"created_at":"2026-10-17T18:02:23.752Z","updated_at":"2026-10-17T18:17:35.564Z","claimed_at":"2026-10-17T18:04:41.369Z","merged_at":"2026-10-17T18:17:35.563Z"} +{"id":"tsk-mvcpexoh-4d52ro","slug":"r10-e7-2-outbox-inbox","title":"r10-e7-2-outbox-inbox","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-2-outbox-inbox.md","blocked_by":[],"created_at":"2026-10-17T18:02:23.873Z","updated_at":"2026-10-17T18:33:47.202Z","claimed_at":"2026-10-17T18:17:35.672Z","merged_at":"2026-10-17T18:33:47.201Z"} +{"id":"tsk-mvcpexrp-ffsx4n","slug":"r10-e7-2-redis-streams","title":"r10-e7-2-redis-streams","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-2-redis-streams.md","blocked_by":[],"created_at":"2026-10-17T18:02:23.989Z","updated_at":"2026-10-17T19:02:02.241Z","claimed_at":"2026-10-17T18:33:47.308Z","merged_at":"2026-10-17T19:02:02.240Z"} +{"id":"tsk-mvcpexut-0na9la","slug":"r10-e7-3-transports","title":"r10-e7-3-transports","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-3-transports.md","blocked_by":[],"created_at":"2026-10-17T18:02:24.101Z","updated_at":"2026-10-17T18:15:45.294Z","claimed_at":"2026-10-17T18:02:38.990Z","merged_at":"2026-10-17T18:15:45.293Z"} +{"id":"tsk-mvcpexxx-4ohdhl","slug":"r10-e7-3-templates-preview","title":"r10-e7-3-templates-preview","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-3-templates-preview.md","blocked_by":[],"created_at":"2026-10-17T18:02:24.213Z","updated_at":"2026-10-17T18:34:51.727Z","claimed_at":"2026-10-17T18:15:45.403Z","merged_at":"2026-10-17T18:34:51.726Z"} +{"id":"tsk-mvcpey14-2k0wis","slug":"r10-e7-3-deliver-later","title":"r10-e7-3-deliver-later","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e7-3-deliver-later.md","blocked_by":[],"created_at":"2026-10-17T18:02:24.328Z","updated_at":"2026-10-17T19:00:59.702Z","claimed_at":"2026-10-17T18:34:51.838Z","merged_at":"2026-10-17T19:00:59.701Z"} +{"id":"tsk-mvcpey4b-b89h8y","slug":"r10-auth8-accounts","title":"r10-auth8-accounts","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-auth8-accounts.md","blocked_by":[],"created_at":"2026-10-17T18:02:24.443Z","updated_at":"2026-10-17T19:23:33.128Z","claimed_at":"2026-10-17T19:00:59.816Z","merged_at":"2026-10-17T19:23:33.127Z"} +{"id":"tsk-mvcpey7z-zm5f97","slug":"r10-e6-2-console-api","title":"r10-e6-2-console-api","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e6-2-console-api.md","blocked_by":[],"created_at":"2026-10-17T18:02:24.575Z","updated_at":"2026-10-17T18:18:37.847Z","claimed_at":"2026-10-17T18:06:10.509Z","merged_at":"2026-10-17T18:18:37.846Z"} +{"id":"tsk-mvcpeyb9-t3b4bu","slug":"r10-e6-2-dashboard","title":"r10-e6-2-dashboard","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e6-2-dashboard.md","blocked_by":[],"created_at":"2026-10-17T18:02:24.693Z","updated_at":"2026-10-17T18:36:43.096Z","claimed_at":"2026-10-17T18:18:37.954Z","merged_at":"2026-10-17T18:36:43.095Z"} +{"id":"tsk-mvcpeyei-jtr1wv","slug":"r10-e1-5-dev","title":"r10-e1-5-dev","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e1-5-dev.md","blocked_by":[],"created_at":"2026-10-17T18:02:24.810Z","updated_at":"2026-10-17T18:36:56.446Z","claimed_at":"2026-10-17T18:27:33.953Z","merged_at":"2026-10-17T18:36:56.445Z"} +{"id":"tsk-mvcpeyhp-jmyzba","slug":"r10-e1-5-devcontainer","title":"r10-e1-5-devcontainer","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e1-5-devcontainer.md","blocked_by":[],"created_at":"2026-10-17T18:02:24.925Z","updated_at":"2026-10-17T18:43:41.433Z","claimed_at":"2026-10-17T18:36:56.563Z","merged_at":"2026-10-17T18:43:41.432Z"} +{"id":"tsk-mvcpeyky-peqniz","slug":"r10-e1-3-generate-job","title":"r10-e1-3-generate-job","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-e1-3-generate-job.md","blocked_by":[],"created_at":"2026-10-17T18:02:25.042Z","updated_at":"2026-10-17T19:03:01.809Z","claimed_at":"2026-10-17T18:43:41.542Z","merged_at":"2026-10-17T19:03:01.808Z"} +{"id":"tsk-mvcrl0u6-q95oms","slug":"r10-w3-kit-template","title":"r10-w3-kit-template","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-w3-kit-template.md","blocked_by":[],"created_at":"2026-10-17T19:03:07.134Z","updated_at":"2026-10-17T19:37:50.825Z","claimed_at":"2026-10-17T19:03:11.855Z","merged_at":"2026-10-17T19:37:50.824Z"} +{"id":"tsk-mvcrl0xj-ldfumo","slug":"r10-w3-ledger","title":"r10-w3-ledger","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-w3-ledger.md","blocked_by":[],"created_at":"2026-10-17T19:03:07.255Z","updated_at":"2026-10-17T20:47:34.846Z","claimed_at":"2026-10-17T19:38:13.772Z","merged_at":"2026-10-17T20:47:34.845Z"} +{"id":"tsk-mvcrl10t-m63g70","slug":"r10-w3-kpis","title":"r10-w3-kpis","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-w3-kpis.md","blocked_by":[],"created_at":"2026-10-17T19:03:07.373Z","updated_at":"2026-10-17T23:03:40.142Z","claimed_at":"2026-10-17T22:28:30.400Z","merged_at":"2026-10-17T23:03:40.141Z"} +{"id":"tsk-mvcrl146-fp6v3u","slug":"r10-w3-level-audit","title":"r10-w3-level-audit","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10/r10-w3-level-audit.md","blocked_by":[],"created_at":"2026-10-17T19:03:07.494Z","updated_at":"2026-10-17T19:46:39.739Z","claimed_at":"2026-10-17T19:38:13.894Z","merged_at":"2026-10-17T19:46:39.738Z"} +{"id":"tsk-mvd9mln9-g1f798","slug":"r101-http","title":"r101-http","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10.1/r101-http.md","blocked_by":[],"created_at":"2026-10-18T03:28:13.845Z","updated_at":"2026-10-18T06:04:13.823Z","claimed_at":"2026-10-18T03:28:19.516Z","merged_at":"2026-10-18T06:04:13.822Z"} +{"id":"tsk-mvd9mlqi-lnd416","slug":"r101-mesh","title":"r101-mesh","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10.1/r101-mesh.md","blocked_by":[],"created_at":"2026-10-18T03:28:13.962Z","updated_at":"2026-10-18T04:01:55.550Z","claimed_at":"2026-10-18T03:28:19.702Z","merged_at":"2026-10-18T04:01:55.549Z"} +{"id":"tsk-mvd9mltr-5gll5q","slug":"r101-auth","title":"r101-auth","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10.1/r101-auth.md","blocked_by":[],"created_at":"2026-10-18T03:28:14.079Z","updated_at":"2026-10-18T04:01:15.147Z","claimed_at":"2026-10-18T03:28:19.816Z","merged_at":"2026-10-18T04:01:15.146Z"} +{"id":"tsk-mvd9mlx1-w6ietq","slug":"r101-client","title":"r101-client","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.10.1/r101-client.md","blocked_by":[],"created_at":"2026-10-18T03:28:14.197Z","updated_at":"2026-10-18T03:35:37.887Z","claimed_at":"2026-10-18T03:28:19.933Z","merged_at":"2026-10-18T03:35:37.886Z"} +{"id":"tsk-mvdf5hvl-qkcn8e","slug":"r11-sec-adr0013","title":"r11-sec-adr0013","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-adr0013.md","blocked_by":[],"created_at":"2026-10-18T06:02:53.505Z","updated_at":"2026-10-18T06:02:54.484Z","claimed_at":"2026-10-18T06:02:53.643Z","merged_at":"2026-10-18T06:02:54.483Z"} +{"id":"tsk-mvdfbkr7-8wkwg5","slug":"r11-t0-skeletons","title":"r11-t0-skeletons","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-t0-skeletons.md","blocked_by":[],"created_at":"2026-10-18T06:07:37.171Z","updated_at":"2026-10-18T06:07:38.637Z","claimed_at":"2026-10-18T06:07:37.409Z","merged_at":"2026-10-18T06:07:38.636Z"} +{"id":"tsk-mvdfoc36-i9tzlz","slug":"r11-sec-logging","title":"r11-sec-logging","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-logging.md","blocked_by":[],"created_at":"2026-10-18T06:17:32.466Z","updated_at":"2026-10-18T06:17:32.959Z","claimed_at":"2026-10-18T06:17:32.585Z","merged_at":"2026-10-18T06:17:32.958Z"} +{"id":"tsk-mvdfsdm7-rk163d","slug":"r11-m22-rebase","title":"r11-m22-rebase","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-m22-rebase.md","blocked_by":[],"created_at":"2026-10-18T06:20:41.071Z","updated_at":"2026-10-18T06:20:41.899Z","claimed_at":"2026-10-18T06:20:41.218Z","merged_at":"2026-10-18T06:20:41.898Z"} +{"id":"tsk-mvdftqvl-77wo59","slug":"r11-e8-1-mailbox","title":"r11-e8-1-mailbox","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e8-1-mailbox.md","blocked_by":[],"created_at":"2026-10-18T06:21:44.913Z","updated_at":"2026-10-18T06:21:46.080Z","claimed_at":"2026-10-18T06:21:45.090Z","merged_at":"2026-10-18T06:21:46.079Z"} +{"id":"tsk-mvdfwlxi-vkqc20","slug":"r11-e2-5-stm-core","title":"r11-e2-5-stm-core","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e2-5-stm-core.md","blocked_by":[],"created_at":"2026-10-18T06:23:58.470Z","updated_at":"2026-10-18T06:24:05.601Z","claimed_at":"2026-10-18T06:23:59.097Z","merged_at":"2026-10-18T06:24:05.600Z"} +{"id":"tsk-mvdgoa0t-18vdc1","slug":"r11-sec-tenancy","title":"r11-sec-tenancy","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-tenancy.md","blocked_by":[],"created_at":"2026-10-18T06:45:29.405Z","updated_at":"2026-10-18T06:45:35.595Z","claimed_at":"2026-10-18T06:45:29.940Z","merged_at":"2026-10-18T06:45:35.594Z"} +{"id":"tsk-mvdgtk7t-ly5lul","slug":"r11-sec-sql-redis","title":"r11-sec-sql-redis","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-sql-redis.md","blocked_by":[],"created_at":"2026-10-18T06:49:35.897Z","updated_at":"2026-10-18T06:49:39.419Z","claimed_at":"2026-10-18T06:49:36.042Z","merged_at":"2026-10-18T06:49:39.418Z"} +{"id":"tsk-mvdgvhpd-9mxdxa","slug":"r11-e7-5-engine","title":"r11-e7-5-engine","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e7-5-engine.md","blocked_by":[],"created_at":"2026-10-18T06:51:05.953Z","updated_at":"2026-10-18T06:51:06.388Z","claimed_at":"2026-10-18T06:51:06.060Z","merged_at":"2026-10-18T06:51:06.387Z"} +{"id":"tsk-mvdh79dx-xb500e","slug":"r11-fuzz-ci","title":"r11-fuzz-ci","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-fuzz-ci.md","blocked_by":[],"created_at":"2026-10-18T07:00:15.045Z","updated_at":"2026-10-18T07:00:18.068Z","claimed_at":"2026-10-18T07:00:15.408Z","merged_at":"2026-10-18T07:00:18.067Z"} +{"id":"tsk-mvdh8or6-37dwa9","slug":"r11-e8-3-global-registry","title":"r11-e8-3-global-registry","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e8-3-global-registry.md","blocked_by":[],"created_at":"2026-10-18T07:01:21.618Z","updated_at":"2026-10-18T07:01:22.159Z","claimed_at":"2026-10-18T07:01:21.725Z","merged_at":"2026-10-18T07:01:22.158Z"} +{"id":"tsk-mvdhyh5g-5yq01o","slug":"r11-sc-sast-scorecard","title":"r11-sc-sast-scorecard","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sc-sast-scorecard.md","blocked_by":[],"created_at":"2026-10-18T07:21:24.820Z","updated_at":"2026-10-18T07:21:34.705Z","claimed_at":"2026-10-18T07:21:25.826Z","merged_at":"2026-10-18T07:21:34.704Z"} +{"id":"tsk-mvdhzjr6-6xpm3i","slug":"r11-e1-9-dylint","title":"r11-e1-9-dylint","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e1-9-dylint.md","blocked_by":[],"created_at":"2026-10-18T07:22:14.850Z","updated_at":"2026-10-19T03:17:34.659Z","claimed_at":"2026-10-18T07:22:15.068Z","merged_at":"2026-10-19T03:17:34.659Z"} +{"id":"tsk-mvdi4g74-udxxs3","slug":"r11-e7-5-activities-timers","title":"r11-e7-5-activities-timers","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e7-5-activities-timers.md","blocked_by":[],"created_at":"2026-10-18T07:26:03.520Z","updated_at":"2026-10-18T07:26:07.847Z","claimed_at":"2026-10-18T07:26:04.085Z","merged_at":"2026-10-18T07:26:07.846Z"} +{"id":"tsk-mveorm3d-m113ii","slug":"r11-sec-csrf-headers","title":"r11-sec-csrf-headers","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-csrf-headers.md","blocked_by":[],"created_at":"2026-10-19T03:19:48.121Z","updated_at":"2026-10-19T03:45:26.945Z","assignee":"r11-sec-csrf-headers","claimed_at":"2026-10-19T03:19:53.382Z","merged_at":"2026-10-19T03:45:26.944Z"} +{"id":"tsk-mveouc1i-byube1","slug":"r11-sec-limits","title":"r11-sec-limits","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-limits.md","blocked_by":[],"created_at":"2026-10-19T03:21:55.062Z","updated_at":"2026-10-19T04:02:06.002Z","assignee":"r11-sec-limits","claimed_at":"2026-10-19T03:21:58.668Z","merged_at":"2026-10-19T04:02:06.001Z"} +{"id":"tsk-mveovcwy-q3fo5g","slug":"r11-sc-pin-permissions","title":"r11-sc-pin-permissions","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sc-pin-permissions.md","blocked_by":[],"created_at":"2026-10-19T03:22:42.850Z","updated_at":"2026-10-19T03:33:36.330Z","assignee":"r11-sc-pin-permissions","claimed_at":"2026-10-19T03:22:47.446Z","merged_at":"2026-10-19T03:33:36.329Z"} +{"id":"tsk-mveovhgv-tk9wbj","slug":"r11-e8-1-timers-hibernate","title":"r11-e8-1-timers-hibernate","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e8-1-timers-hibernate.md","blocked_by":[],"created_at":"2026-10-19T03:22:48.751Z","updated_at":"2026-10-19T03:30:18.816Z","assignee":"r11-e8-1-timers-hibernate","claimed_at":"2026-10-19T03:22:52.596Z","merged_at":"2026-10-19T03:30:18.815Z"} +{"id":"tsk-mveovo0t-2qcudv","slug":"r11-sec-secrets","title":"r11-sec-secrets","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-secrets.md","blocked_by":[],"created_at":"2026-10-19T03:22:57.245Z","updated_at":"2026-10-19T03:50:33.532Z","assignee":"r11-sec-secrets","claimed_at":"2026-10-19T03:22:59.980Z","merged_at":"2026-10-19T03:50:33.531Z"} +{"id":"tsk-mveox57m-tvw0xp","slug":"r11-e2-5-stm-collections","title":"r11-e2-5-stm-collections","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e2-5-stm-collections.md","blocked_by":[],"created_at":"2026-10-19T03:24:06.178Z","updated_at":"2026-10-19T03:36:41.205Z","assignee":"r11-e2-5-stm-collections","claimed_at":"2026-10-19T03:24:11.773Z","merged_at":"2026-10-19T03:36:41.203Z"} +{"id":"tsk-mveox92p-bcbotq","slug":"r11-sec-h8-routing","title":"r11-sec-h8-routing","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-h8-routing.md","blocked_by":[],"created_at":"2026-10-19T03:24:11.185Z","updated_at":"2026-10-19T04:42:39.550Z","assignee":"r11-sec-h8-routing","claimed_at":"2026-10-19T03:24:14.692Z","merged_at":"2026-10-19T04:42:39.549Z"} +{"id":"tsk-mveoxlor-3b9mxh","slug":"r11-e2-6-bench-ci","title":"r11-e2-6-bench-ci","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e2-6-bench-ci.md","blocked_by":[],"created_at":"2026-10-19T03:24:27.531Z","updated_at":"2026-10-19T03:44:20.900Z","assignee":"r11-e2-6-bench-ci","claimed_at":"2026-10-19T03:24:33.509Z","merged_at":"2026-10-19T03:44:20.899Z"} +{"id":"tsk-mveoxp2g-hrduy2","slug":"r11-sc-dco-docs","title":"r11-sc-dco-docs","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sc-dco-docs.md","blocked_by":[],"created_at":"2026-10-19T03:24:31.912Z","updated_at":"2026-10-19T03:33:04.468Z","assignee":"r11-sc-dco-docs","claimed_at":"2026-10-19T03:24:37.737Z","merged_at":"2026-10-19T03:33:04.466Z"} +{"id":"tsk-mveoxua6-li0n6j","slug":"r11-e8-3-quic-mtls","title":"r11-e8-3-quic-mtls","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e8-3-quic-mtls.md","blocked_by":[],"created_at":"2026-10-19T03:24:38.670Z","updated_at":"2026-10-19T03:51:37.181Z","assignee":"r11-e8-3-quic-mtls","claimed_at":"2026-10-19T03:24:41.356Z","merged_at":"2026-10-19T03:51:37.180Z"} +{"id":"tsk-mveoxvjj-lwme2p","slug":"r11-sc-publish","title":"r11-sc-publish","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sc-publish.md","blocked_by":[],"created_at":"2026-10-19T03:24:40.303Z","updated_at":"2026-10-19T03:28:18.655Z","assignee":"r11-sc-publish","claimed_at":"2026-10-19T03:24:42.950Z","merged_at":"2026-10-19T03:28:18.654Z"} +{"id":"tsk-mvep8nuq-do4lci","slug":"r11-e8-3-reliable-delivery","title":"r11-e8-3-reliable-delivery","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e8-3-reliable-delivery.md","blocked_by":[],"created_at":"2026-10-19T03:33:03.554Z","updated_at":"2026-10-19T04:01:25.116Z","assignee":"r11-e8-3-reliable-delivery","claimed_at":"2026-10-19T03:33:08.402Z","merged_at":"2026-10-19T04:01:25.115Z"} +{"id":"tsk-mvepmv97-fe5jxj","slug":"r11-e8-2-statem","title":"r11-e8-2-statem","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e8-2-statem.md","blocked_by":[],"created_at":"2026-10-19T03:44:06.331Z","updated_at":"2026-10-19T03:48:42.186Z","assignee":"r11-e8-2-statem","claimed_at":"2026-10-19T03:44:09.629Z","merged_at":"2026-10-19T03:48:42.185Z"} +{"id":"tsk-mvepyvw7-ohtsdc","slug":"r11-sec-auth-state","title":"r11-sec-auth-state","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-auth-state.md","blocked_by":[],"created_at":"2026-10-19T03:53:27.031Z","updated_at":"2026-10-19T04:12:15.963Z","assignee":"r11-sec-auth-state","claimed_at":"2026-10-19T03:53:30.616Z","merged_at":"2026-10-19T04:12:15.962Z"} +{"id":"tsk-mvepzuqe-owgjul","slug":"r11-e8-3-k8s-discovery","title":"r11-e8-3-k8s-discovery","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e8-3-k8s-discovery.md","blocked_by":[],"created_at":"2026-10-19T03:54:12.182Z","updated_at":"2026-10-19T04:08:34.095Z","assignee":"r11-e8-3-k8s-discovery","claimed_at":"2026-10-19T03:54:14.758Z","merged_at":"2026-10-19T04:08:34.093Z"} +{"id":"tsk-mveq0q6b-7wurki","slug":"r11-e8-3-tcp-tls","title":"r11-e8-3-tcp-tls","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e8-3-tcp-tls.md","blocked_by":[],"created_at":"2026-10-19T03:54:52.931Z","updated_at":"2026-10-19T04:15:12.861Z","assignee":"r11-e8-3-tcp-tls","claimed_at":"2026-10-19T03:54:57.985Z","merged_at":"2026-10-19T04:15:12.860Z"} +{"id":"tsk-mveqc5k9-6weba4","slug":"r11-e7-5-sagas-promote","title":"r11-e7-5-sagas-promote","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e7-5-sagas-promote.md","blocked_by":[],"created_at":"2026-10-19T04:03:46.089Z","updated_at":"2026-10-19T04:31:14.950Z","assignee":"r11-e7-5-sagas-promote","claimed_at":"2026-10-19T04:03:50.770Z","merged_at":"2026-10-19T04:31:14.948Z"} +{"id":"tsk-mverrnjm-v3bnoo","slug":"r11-sec-ops-surfaces","title":"r11-sec-ops-surfaces","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-ops-surfaces.md","blocked_by":[],"created_at":"2026-10-19T04:43:48.850Z","updated_at":"2026-10-19T05:19:25.101Z","assignee":"r11-sec-ops-surfaces","claimed_at":"2026-10-19T04:43:53.006Z","merged_at":"2026-10-19T05:19:25.101Z"} +{"id":"tsk-mvervb9q-tizqjp","slug":"r11-sec-client-ssrf","title":"r11-sec-client-ssrf","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-sec-client-ssrf.md","blocked_by":[],"created_at":"2026-10-19T04:46:39.566Z","updated_at":"2026-10-19T05:09:48.579Z","assignee":"r11-sec-client-ssrf","claimed_at":"2026-10-19T04:46:45.070Z","merged_at":"2026-10-19T05:09:48.578Z"} +{"id":"tsk-mvet31ys-gowf3l","slug":"r11-w3-kit-template","title":"r11-w3-kit-template","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-w3-kit-template.md","blocked_by":[],"created_at":"2026-10-19T05:20:40.372Z","updated_at":"2026-10-19T06:02:52.103Z","assignee":"r11-w3-kit-template","claimed_at":"2026-10-19T05:20:45.673Z","merged_at":"2026-10-19T06:02:52.102Z"} +{"id":"tsk-mveutmw9-l3uhuv","slug":"r11-e11-4-compile-fail","title":"r11-e11-4-compile-fail","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-e11-4-compile-fail.md","blocked_by":[],"created_at":"2026-10-19T06:09:20.169Z","updated_at":"2026-10-19T06:15:16.121Z","assignee":"r11-e11-4-compile-fail","claimed_at":"2026-10-19T06:09:27.507Z","merged_at":"2026-10-19T06:15:16.120Z"} +{"id":"tsk-mveuyqcl-h6cafd","slug":"r11-w3-ledger","title":"r11-w3-ledger","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-w3-ledger.md","blocked_by":[],"created_at":"2026-10-19T06:13:17.925Z","updated_at":"2026-10-19T06:36:51.489Z","assignee":"r11-w3-ledger","claimed_at":"2026-10-19T06:13:22.237Z","merged_at":"2026-10-19T06:36:51.488Z"} +{"id":"tsk-mvey8ngt-tghgvt","slug":"r11-t0-migration-guide","title":"r11-t0-migration-guide","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-t0-migration-guide.md","blocked_by":[],"created_at":"2026-10-19T07:44:59.597Z","updated_at":"2026-10-19T08:14:39.766Z","assignee":"r11-t0-migration-guide","claimed_at":"2026-10-19T07:45:03.493Z","merged_at":"2026-10-19T08:14:39.765Z"} +{"id":"tsk-mvey915n-8cs2b5","slug":"r11-w3-kpis","title":"r11-w3-kpis","state":"shipped","spec_path":"/data/Code/rust/id_effect/.maestro/specs/releases/0.11/r11-w3-kpis.md","blocked_by":[],"created_at":"2026-10-19T07:45:17.339Z","updated_at":"2026-10-19T08:28:59.950Z","assignee":"r11-w3-kpis","claimed_at":"2026-10-19T07:45:20.943Z","merged_at":"2026-10-19T08:28:59.949Z"} +{"id":"tsk-mvf4c3oa-dc40fo","slug":"r12-t0-adr0014","title":"r12-t0-adr0014","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-t0-adr0014.md","blocked_by":[],"created_at":"2026-10-19T10:35:38.266Z","updated_at":"2026-10-19T10:44:41.991Z","assignee":"a0-roadmap","claimed_at":"2026-10-19T10:35:43.231Z","merged_at":"2026-10-19T10:44:41.990Z"} +{"id":"tsk-mvf4c3s4-xn4847","slug":"r12-t0-skeletons","title":"r12-t0-skeletons","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-t0-skeletons.md","blocked_by":[],"created_at":"2026-10-19T10:35:38.404Z","updated_at":"2026-10-19T10:51:42.342Z","assignee":"a1-governance","claimed_at":"2026-10-19T10:35:43.392Z","merged_at":"2026-10-19T10:51:42.341Z"} +{"id":"tsk-mvf4xez4-00j1za","slug":"r12-e12-1-promote","title":"r12-e12-1-promote","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-1-promote.md","blocked_by":[],"created_at":"2026-10-19T10:52:12.688Z","updated_at":"2026-10-19T12:03:57.618Z","assignee":"b1-rpc","claimed_at":"2026-10-19T10:52:16.171Z","merged_at":"2026-10-19T12:03:57.617Z"} +{"id":"tsk-mvf4xf42-qjodki","slug":"r12-e12-2-axum-ws-sse","title":"r12-e12-2-axum-ws-sse","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-2-axum-ws-sse.md","blocked_by":[],"created_at":"2026-10-19T10:52:12.866Z","updated_at":"2026-10-19T11:34:44.815Z","assignee":"b2-axum","claimed_at":"2026-10-19T10:52:16.345Z","merged_at":"2026-10-19T11:34:44.814Z"} +{"id":"tsk-mvf4xf8t-hyoe72","slug":"r12-e12-3-quic-endpoint","title":"r12-e12-3-quic-endpoint","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-3-quic-endpoint.md","blocked_by":[],"created_at":"2026-10-19T10:52:13.037Z","updated_at":"2026-10-19T11:49:43.206Z","assignee":"b3-quic","claimed_at":"2026-10-19T10:52:16.467Z","merged_at":"2026-10-19T11:49:43.204Z"} +{"id":"tsk-mvf70f4h-isuu0c","slug":"r12-e12-3-tls-h2","title":"r12-e12-3-tls-h2","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-3-tls-h2.md","blocked_by":[],"created_at":"2026-10-19T11:50:32.081Z","updated_at":"2026-10-19T13:07:19.943Z","assignee":"c1-tls-h2","claimed_at":"2026-10-19T11:50:36.857Z","merged_at":"2026-10-19T13:07:19.943Z"} +{"id":"tsk-mvf70fjh-fmn5my","slug":"r12-e12-3-h3-client","title":"r12-e12-3-h3-client","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-3-h3-client.md","blocked_by":[],"created_at":"2026-10-19T11:50:32.621Z","updated_at":"2026-10-19T12:35:49.029Z","assignee":"c2-h3-client","claimed_at":"2026-10-19T11:50:37.219Z","merged_at":"2026-10-19T12:35:49.028Z"} +{"id":"tsk-mvf70fq3-r9ruuc","slug":"r12-e12-3-certs-acme","title":"r12-e12-3-certs-acme","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-3-certs-acme.md","blocked_by":[],"created_at":"2026-10-19T11:50:32.859Z","updated_at":"2026-10-19T12:34:04.801Z","assignee":"c3-acme","claimed_at":"2026-10-19T11:50:37.364Z","merged_at":"2026-10-19T12:34:04.800Z"} +{"id":"tsk-mvf7jpll-z8910m","slug":"r12-e12-1-kinds","title":"r12-e12-1-kinds","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-1-kinds.md","blocked_by":[],"created_at":"2026-10-19T12:05:32.121Z","updated_at":"2026-10-19T13:06:47.398Z","assignee":"c4-kinds","claimed_at":"2026-10-19T12:05:49.615Z","merged_at":"2026-10-19T13:06:47.397Z"} +{"id":"tsk-mvf9rld7-x1htmf","slug":"r12-e12-2-rpc-ws-sse","title":"r12-e12-2-rpc-ws-sse","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-2-rpc-ws-sse.md","blocked_by":[],"created_at":"2026-10-19T13:07:39.115Z","updated_at":"2026-10-19T17:23:33.021Z","assignee":"d1-rpc-ws-sse","claimed_at":"2026-10-19T13:07:50.235Z","merged_at":"2026-10-19T17:23:33.020Z"} +{"id":"tsk-mvf9rlgq-6v0ddu","slug":"r12-e12-1-batching","title":"r12-e12-1-batching","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-1-batching.md","blocked_by":[],"created_at":"2026-10-19T13:07:39.242Z","updated_at":"2026-10-19T16:26:39.561Z","assignee":"d2-batching","claimed_at":"2026-10-19T13:07:50.414Z","merged_at":"2026-10-19T16:26:39.560Z"} +{"id":"tsk-mvf9rlkh-oyyusj","slug":"r12-e12-1-macro","title":"r12-e12-1-macro","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-1-macro.md","blocked_by":[],"created_at":"2026-10-19T13:07:39.377Z","updated_at":"2026-10-19T14:50:39.462Z","assignee":"d3-macro","claimed_at":"2026-10-19T13:07:50.542Z","merged_at":"2026-10-19T14:50:39.461Z"} +{"id":"tsk-mvf9rlnr-ts175i","slug":"r12-e12-1-postcard","title":"r12-e12-1-postcard","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-1-postcard.md","blocked_by":[],"created_at":"2026-10-19T13:07:39.495Z","updated_at":"2026-10-19T14:13:46.863Z","assignee":"d4-postcard","claimed_at":"2026-10-19T13:07:50.666Z","merged_at":"2026-10-19T14:13:46.861Z"} +{"id":"tsk-mvf9rlqz-0bc6j8","slug":"r12-e12-2-subscriptions","title":"r12-e12-2-subscriptions","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-2-subscriptions.md","blocked_by":[],"created_at":"2026-10-19T13:07:39.611Z","updated_at":"2026-10-19T13:31:18.663Z","assignee":"d5-subscriptions","claimed_at":"2026-10-19T13:07:50.787Z","merged_at":"2026-10-19T13:31:18.662Z"} +{"id":"tsk-mvf9twme-9avzxk","slug":"r12-e12-3-h3-server","title":"r12-e12-3-h3-server","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-3-h3-server.md","blocked_by":[],"created_at":"2026-10-19T13:09:27.014Z","updated_at":"2026-10-19T15:55:23.816Z","assignee":"d6-h3-server","claimed_at":"2026-10-19T13:09:30.115Z","merged_at":"2026-10-19T15:55:23.812Z"} +{"id":"tsk-mvfamuiv-lyz0jg","slug":"r12-e12-2-channels","title":"r12-e12-2-channels","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-2-channels.md","blocked_by":[],"created_at":"2026-10-19T13:31:57.319Z","updated_at":"2026-10-19T16:37:59.781Z","assignee":"e1-channels","claimed_at":"2026-10-19T13:32:01.835Z","merged_at":"2026-10-19T16:37:59.780Z"} +{"id":"tsk-mvfc5h86-z8ef14","slug":"r12-e12-2-mesh-rpc","title":"r12-e12-2-mesh-rpc","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-2-mesh-rpc.md","blocked_by":[],"created_at":"2026-10-19T14:14:26.166Z","updated_at":"2026-10-19T16:50:47.547Z","assignee":"e2-mesh-rpc","claimed_at":"2026-10-19T14:14:31.009Z","merged_at":"2026-10-19T16:50:47.546Z"} +{"id":"tsk-mvfdi1tt-t8vr7z","slug":"r12-e12-4-openapi-asyncapi","title":"r12-e12-4-openapi-asyncapi","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-4-openapi-asyncapi.md","blocked_by":[],"created_at":"2026-10-19T14:52:12.353Z","updated_at":"2026-10-19T15:52:44.211Z","assignee":"e3-openapi","claimed_at":"2026-10-19T14:52:15.751Z","merged_at":"2026-10-19T15:52:44.210Z"} +{"id":"tsk-mvffsow8-ogb7fz","slug":"r12-f-h3-platform","title":"r12-f-h3-platform","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-f-h3-platform.md","blocked_by":[],"created_at":"2026-10-19T15:56:28.040Z","updated_at":"2026-10-19T17:21:23.150Z","assignee":"f1-h3-platform","claimed_at":"2026-10-19T15:56:36.397Z","merged_at":"2026-10-19T17:21:23.149Z"} +{"id":"tsk-mvfiv513-vvk5an","slug":"r12-e12-3-deploy-guide","title":"r12-e12-3-deploy-guide","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-3-deploy-guide.md","blocked_by":[],"created_at":"2026-10-19T17:22:21.111Z","updated_at":"2026-10-19T17:37:24.417Z","assignee":"e4-deploy-guide","claimed_at":"2026-10-19T17:22:24.568Z","merged_at":"2026-10-19T17:37:24.416Z"} +{"id":"tsk-mvfixl0v-di486t","slug":"r12-e12-2-bidi-resume","title":"r12-e12-2-bidi-resume","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-2-bidi-resume.md","blocked_by":[],"created_at":"2026-10-19T17:24:15.151Z","updated_at":"2026-10-19T18:25:21.743Z","assignee":"f2-bidi-resume","claimed_at":"2026-10-19T17:24:21.486Z","merged_at":"2026-10-19T18:25:21.742Z"} +{"id":"tsk-mvfixl4d-wu25gw","slug":"r12-e12-5-benches","title":"r12-e12-5-benches","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-5-benches.md","blocked_by":[],"created_at":"2026-10-19T17:24:15.277Z","updated_at":"2026-10-19T21:45:25.234Z","assignee":"f3-benches","claimed_at":"2026-10-19T17:24:21.605Z","block_reason":"D5: rpc/unary_json - rpc/axum_json_handler measured +3,399 ns (clean run; +3.38..+3.59 us over 5 runs, +3,501 ns on 82beb0f2) > 3,000 ns budget. Per the leaf AC the delta gate is not written and the budget not loosened; owner decision needed (release spec Open question 2). Everything else committed in 9f422370 (batch pair 0.111, secure stack +2,648 ns, h3/h2 0.065 userspace, soak 1.040 PASS).","merged_at":"2026-10-19T21:45:25.233Z"} +{"id":"tsk-mvfl8a7k-641iow","slug":"r12-e12-4-gen-ts","title":"r12-e12-4-gen-ts","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-4-gen-ts.md","blocked_by":[],"created_at":"2026-10-19T18:28:33.584Z","updated_at":"2026-10-19T19:38:41.161Z","assignee":"g1-gen-ts","claimed_at":"2026-10-19T18:28:59.481Z","merged_at":"2026-10-19T19:38:41.160Z"} +{"id":"tsk-mvfl8acb-0806yq","slug":"r12-e12-3-webtransport","title":"r12-e12-3-webtransport","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-3-webtransport.md","blocked_by":[],"created_at":"2026-10-19T18:28:33.755Z","updated_at":"2026-10-19T19:46:07.527Z","assignee":"g2-webtransport","claimed_at":"2026-10-19T18:28:59.669Z","merged_at":"2026-10-19T19:46:07.526Z"} +{"id":"tsk-mvfl8agk-vm3y4j","slug":"r12-e12-5-fuzz-security","title":"r12-e12-5-fuzz-security","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-5-fuzz-security.md","blocked_by":[],"created_at":"2026-10-19T18:28:33.908Z","updated_at":"2026-10-19T19:21:56.904Z","assignee":"g3-fuzz-security","claimed_at":"2026-10-19T18:28:59.786Z","merged_at":"2026-10-19T19:21:56.903Z"} +{"id":"tsk-mvfns901-uz5ej3","slug":"r12-e12-4-rpc-check","title":"r12-e12-4-rpc-check","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-e12-4-rpc-check.md","blocked_by":[],"created_at":"2026-10-19T19:40:04.369Z","updated_at":"2026-10-19T19:59:14.192Z","assignee":"g4-rpc-check","claimed_at":"2026-10-19T19:40:12.840Z","merged_at":"2026-10-19T19:59:14.191Z"} +{"id":"tsk-mvfo16u4-pdz4fg","slug":"r12-w6-kit-template","title":"r12-w6-kit-template","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-w6-kit-template.md","blocked_by":[],"created_at":"2026-10-19T19:47:01.468Z","updated_at":"2026-10-19T21:15:18.059Z","assignee":"h1-kit-template","claimed_at":"2026-10-19T19:47:05.346Z","merged_at":"2026-10-19T21:15:18.058Z"} +{"id":"tsk-mvfohzqk-xg47t7","slug":"r12-f-client-batching","title":"r12-f-client-batching","state":"blocked","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-f-client-batching.md","blocked_by":[],"created_at":"2026-10-19T20:00:05.420Z","updated_at":"2026-10-20T04:48:28.996Z","assignee":"h2-client-batching","claimed_at":"2026-10-19T20:00:18.542Z","block_reason":"Deferred to 0.13: ~135 lines (per-env batch resolver table) because id_effect lacks a public run-env-free-effect-under-any-env helper; patch saved at .maestro/specs/releases/0.13/f27-client-batching.patch (applies on a344bd9e)"} +{"id":"tsk-mvfoqs1j-gik5ls","slug":"r12-f-unary-perf","title":"r12-f-unary-perf","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-f-unary-perf.md","blocked_by":[],"created_at":"2026-10-19T20:06:55.351Z","updated_at":"2026-10-19T21:43:39.555Z","assignee":"h3-unary-perf","claimed_at":"2026-10-19T20:06:58.948Z","merged_at":"2026-10-19T21:43:39.554Z"} +{"id":"tsk-mvfr897j-gvxts8","slug":"r12-w7-relay","title":"r12-w7-relay","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-w7-relay.md","blocked_by":[],"created_at":"2026-10-19T21:16:29.983Z","updated_at":"2026-10-19T22:55:29.417Z","assignee":"i1-relay","claimed_at":"2026-10-19T21:16:33.469Z","merged_at":"2026-10-19T22:55:29.416Z"} +{"id":"tsk-mvfr89bs-sr3cs4","slug":"r12-w7-ledger","title":"r12-w7-ledger","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-w7-ledger.md","blocked_by":[],"created_at":"2026-10-19T21:16:30.136Z","updated_at":"2026-10-19T22:32:55.184Z","assignee":"i2-ledger","claimed_at":"2026-10-19T21:16:33.660Z","merged_at":"2026-10-19T22:32:55.183Z"} +{"id":"tsk-mvfusz4r-iiqduh","slug":"r12-w8-level-audit","title":"r12-w8-level-audit","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-w8-level-audit.md","blocked_by":[],"created_at":"2026-10-19T22:56:35.547Z","updated_at":"2026-10-20T00:43:32.550Z","assignee":"j1-level-audit","claimed_at":"2026-10-19T22:56:46.371Z","merged_at":"2026-10-20T00:43:32.549Z"} +{"id":"tsk-mvfusz8h-6pkxkz","slug":"r12-w8-kpis","title":"r12-w8-kpis","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-w8-kpis.md","blocked_by":[],"created_at":"2026-10-19T22:56:35.681Z","updated_at":"2026-10-20T04:46:51.150Z","assignee":"j2-kpis","claimed_at":"2026-10-19T22:56:46.573Z","merged_at":"2026-10-20T04:46:51.149Z"} +{"id":"tsk-mvfuszbz-xpikgu","slug":"r12-f-ts-cursor","title":"r12-f-ts-cursor","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-f-ts-cursor.md","blocked_by":[],"created_at":"2026-10-19T22:56:35.807Z","updated_at":"2026-10-20T00:09:24.432Z","assignee":"j3-ts-cursor","claimed_at":"2026-10-19T22:56:46.694Z","merged_at":"2026-10-20T00:09:24.431Z"} +{"id":"tsk-mvfypvdw-vae18k","slug":"r12-f-rpc-spans","title":"r12-f-rpc-spans","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-f-rpc-spans.md","blocked_by":[],"created_at":"2026-10-20T00:46:09.188Z","updated_at":"2026-10-20T03:41:30.033Z","assignee":"k1-rpc-spans","claimed_at":"2026-10-20T00:46:18.271Z","merged_at":"2026-10-20T03:41:30.032Z"} +{"id":"tsk-mvg7dsl7-vj0q93","slug":"r12-t0-migration-guide","title":"r12-t0-migration-guide","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-t0-migration-guide.md","blocked_by":[],"created_at":"2026-10-20T04:48:42.235Z","updated_at":"2026-10-20T05:21:34.605Z","assignee":"l1-exit","claimed_at":"2026-10-20T04:48:47.763Z","merged_at":"2026-10-20T05:21:34.604Z"} diff --git a/.maestro/verdicts/tsk-mv9xeqip-pyftp2/vrd-1792095960897-05f747.json b/.maestro/verdicts/tsk-mv9xeqip-pyftp2/vrd-1792095960897-05f747.json new file mode 100644 index 000000000..86dd6bb3d --- /dev/null +++ b/.maestro/verdicts/tsk-mv9xeqip-pyftp2/vrd-1792095960897-05f747.json @@ -0,0 +1,56 @@ +{ + "schemaVersion": 1, + "id": "vrd-1792095960897-05f747", + "taskId": "tsk-mv9xeqip-pyftp2", + "contractVersion": 1, + "computedAt": "2026-10-15T20:26:00.897Z", + "decision": "HUMAN", + "proposedRiskClass": "high", + "effectiveRiskClass": "high", + "reasons": [ + { + "category": "evidence", + "code": "evidence-witness-level-insufficient", + "message": "1 evidence row(s) are below the required witness level \"witnessed-by-maestro\" for high-risk tasks.", + "evidenceIds": ["evd-1792095951808-9fe3db"] + } + ], + "evidenceConsulted": [ + "evd-1792093364425-07f4a3", + "evd-1792093365529-0b63dc", + "evd-1792093367174-b0f3cd", + "evd-1792093367967-025934", + "evd-1792095951808-9fe3db" + ], + "policiesConsulted": [ + { + "file": "policies/risk.yaml", + "version": "1" + }, + { + "file": "policies/autopilot.yaml", + "version": "1" + }, + { + "file": "policies/release.yaml", + "version": "1" + } + ], + "trustVerifier": { + "findingsCount": 1, + "errors": 0, + "warns": 1, + "infos": 0, + "findings": [ + { + "check": "empty-diff", + "severity": "warn", + "paths": [], + "details": "Diff between f6d730760da0ec0c181e90675118b9277cebe6d4 and f6d730760da0ec0c181e90675118b9277cebe6d4 is empty (base equals HEAD). Commit work after locking the contract — the verifier diffs from the lock-commit." + } + ] + }, + "subject": { + "tree_sha": "a640c7938f9db421a8668eab98ee41f97720a4e0" + } +} diff --git a/.maestro/verdicts/tsk-mv9zvfav-x5368h/vrd-1792100100814-45442b.json b/.maestro/verdicts/tsk-mv9zvfav-x5368h/vrd-1792100100814-45442b.json new file mode 100644 index 000000000..5d422eb65 --- /dev/null +++ b/.maestro/verdicts/tsk-mv9zvfav-x5368h/vrd-1792100100814-45442b.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 1, + "id": "vrd-1792100100814-45442b", + "taskId": "tsk-mv9zvfav-x5368h", + "contractVersion": 1, + "computedAt": "2026-10-15T21:35:00.814Z", + "decision": "HUMAN", + "proposedRiskClass": "medium", + "effectiveRiskClass": "medium", + "reasons": [ + { + "category": "policy", + "code": "auto-merge-not-allowed", + "message": "Auto-merge is opt-in and not enabled for risk class \"medium\" in autopilot.yaml; the task can still complete via human review (run `maestro handoff emit`, or set autoMergeAllowed.medium: true — note: enabling auto-merge is a \"loosening\" and soaks for 30 days before taking effect; run `maestro policy pending` to see in-flight changes)." + } + ], + "evidenceConsulted": [ + "evd-1792100100101-b4cbff", + "evd-1792100100229-f188b7", + "evd-1792100100354-57a86b" + ], + "policiesConsulted": [ + { + "file": "policies/risk.yaml", + "version": "1" + }, + { + "file": "policies/autopilot.yaml", + "version": "1" + }, + { + "file": "policies/release.yaml", + "version": "1" + } + ], + "trustVerifier": { + "findingsCount": 1, + "errors": 0, + "warns": 1, + "infos": 0, + "findings": [ + { + "check": "empty-diff", + "severity": "warn", + "paths": [], + "details": "Diff between f6d730760da0ec0c181e90675118b9277cebe6d4 and f6d730760da0ec0c181e90675118b9277cebe6d4 is empty (base equals HEAD). Commit work after locking the contract — the verifier diffs from the lock-commit." + } + ] + }, + "subject": { + "tree_sha": "4b5f1177ffef48a77c55997a0c3561760a517376" + } +} diff --git a/.maestro/verdicts/tsk-mvaoq06i-s0i7vr/vrd-1792150235104-564363.json b/.maestro/verdicts/tsk-mvaoq06i-s0i7vr/vrd-1792150235104-564363.json new file mode 100644 index 000000000..a3387367b --- /dev/null +++ b/.maestro/verdicts/tsk-mvaoq06i-s0i7vr/vrd-1792150235104-564363.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 1, + "id": "vrd-1792150235104-564363", + "taskId": "tsk-mvaoq06i-s0i7vr", + "contractVersion": 1, + "computedAt": "2026-10-16T11:30:35.104Z", + "decision": "HUMAN", + "proposedRiskClass": "medium", + "effectiveRiskClass": "medium", + "reasons": [ + { + "category": "policy", + "code": "auto-merge-not-allowed", + "message": "Auto-merge is opt-in and not enabled for risk class \"medium\" in autopilot.yaml; the task can still complete via human review (run `maestro handoff emit`, or set autoMergeAllowed.medium: true — note: enabling auto-merge is a \"loosening\" and soaks for 30 days before taking effect; run `maestro policy pending` to see in-flight changes)." + }, + { + "category": "evidence", + "code": "proof-map-incomplete", + "message": "6 acceptance criterion/criteria lack covering evidence: dw-f3e7b8 (\"Step 1 (alignment): every competitor v2 level/met cell is re-judged with one rule: a check met only through a third-party library counts at production level only if the framework's own docs recommend that library, otherwise the row is scored at labs (or without that check, whichever scores higher). The per-check classification is committed as docs/scorecard/alignment-2.0.json.\"); dw-77c1a9 (\"Step 2 (method 1.1): every id_effect v1 score above the cap of its v2 level is lowered to that cap; the v1 numbers are kept per row under rows[].v1 {competitors, id_effect, targets} and remain reproducible with scripts/scorecard.sh --method 1.\"); dw-a364a5 (\"Step 3 (method 2.0): method_version is \"2.0\"; rows[].competitors and rows[].id_effect hold the checklist scores and --validate fails when a stored score differs from its checklist score; rows[].targets are re-based to the v2 scale by one documented rule; --method 2 is the default.\"); dw-a0ae06 (\"METHOD.md §5.2 and §7, PLAN-OF-PLANS §1 and §6, results/v0.6.0.json, results/v0.7.0.json, REPORT-0.7.0.md, REPORT-method-v2.md and index.html are regenerated or re-stated with 2.0 numbers; the v1 numbers are kept in a history table.\"); dw-6e75f3 (\"bash scripts/scorecard.sh --validate --self-test passes, including a fixture that fails on a stored score that differs from its checklist score; the CI scorecard step passes unchanged or is updated in the same change.\"); dw-a593ac (\"Step 4: the owner accepts the re-based numbers before 0.8 scope selection (§7.1.3) uses them.\"). Record covering evidence with `maestro evidence record --task --criterion dw-f3e7b8 --command \"...\" --exit 0` (criterion id matches the spec's acceptance_criteria — see `maestro spec show ` or the spec frontmatter)." + } + ], + "evidenceConsulted": [ + "evd-1792138651098-4031aa", + "evd-1792138655783-986c6a", + "evd-1792138659989-3eb4cc" + ], + "policiesConsulted": [ + { + "file": "policies/risk.yaml", + "version": "1" + }, + { + "file": "policies/autopilot.yaml", + "version": "1" + }, + { + "file": "policies/release.yaml", + "version": "1" + } + ], + "trustVerifier": { + "findingsCount": 1, + "errors": 0, + "warns": 1, + "infos": 0, + "findings": [ + { + "check": "empty-diff", + "severity": "warn", + "paths": [], + "details": "Diff between f6d730760da0ec0c181e90675118b9277cebe6d4 and f6d730760da0ec0c181e90675118b9277cebe6d4 is empty (base equals HEAD). Commit work after locking the contract — the verifier diffs from the lock-commit." + } + ] + }, + "subject": { + "tree_sha": "5dd3cb528a978a4c508f7ca16da06b18a8112789" + } +} diff --git a/.maestro/verdicts/tsk-mvaoq06i-s0i7vr/vrd-1792150263387-703eaf.json b/.maestro/verdicts/tsk-mvaoq06i-s0i7vr/vrd-1792150263387-703eaf.json new file mode 100644 index 000000000..9a7441114 --- /dev/null +++ b/.maestro/verdicts/tsk-mvaoq06i-s0i7vr/vrd-1792150263387-703eaf.json @@ -0,0 +1,67 @@ +{ + "schemaVersion": 1, + "id": "vrd-1792150263387-703eaf", + "taskId": "tsk-mvaoq06i-s0i7vr", + "contractVersion": 1, + "computedAt": "2026-10-16T11:31:03.387Z", + "decision": "HUMAN", + "proposedRiskClass": "medium", + "effectiveRiskClass": "critical", + "reasons": [ + { + "category": "policy", + "code": "threat-model-required", + "message": "Diff intersects security-relevant sensitive paths with critical risk class; a threat-model Evidence row is required." + }, + { + "category": "risk", + "code": "effective-risk-critical", + "message": "Effective risk class is critical (proposed: medium, derived: critical). Human review always required." + } + ], + "evidenceConsulted": [ + "evd-1792138651098-4031aa", + "evd-1792138655783-986c6a", + "evd-1792138659989-3eb4cc", + "evd-1792150257115-94b81f", + "evd-1792150257239-0d280c", + "evd-1792150257353-5d1ef0", + "evd-1792150257476-c50feb", + "evd-1792150257603-d899d4", + "evd-1792150262505-228c4d" + ], + "policiesConsulted": [ + { + "file": "policies/risk.yaml", + "version": "1" + }, + { + "file": "policies/autopilot.yaml", + "version": "1" + }, + { + "file": "policies/release.yaml", + "version": "1" + } + ], + "trustVerifier": { + "findingsCount": 1, + "errors": 0, + "warns": 1, + "infos": 0, + "findings": [ + { + "check": "sensitive-paths", + "severity": "warn", + "paths": [ + ".github/workflows/ci.yml", + "examples/http-showcase/migrations/0001_init.sql" + ], + "details": "2 path(s) match sensitive-path policy globs." + } + ] + }, + "subject": { + "tree_sha": "5dd3cb528a978a4c508f7ca16da06b18a8112789" + } +} diff --git a/.maestro/verdicts/tsk-mvaoq06i-s0i7vr/vrd-1792150299594-567e52.json b/.maestro/verdicts/tsk-mvaoq06i-s0i7vr/vrd-1792150299594-567e52.json new file mode 100644 index 000000000..f7937ff1d --- /dev/null +++ b/.maestro/verdicts/tsk-mvaoq06i-s0i7vr/vrd-1792150299594-567e52.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 1, + "id": "vrd-1792150299594-567e52", + "taskId": "tsk-mvaoq06i-s0i7vr", + "contractVersion": 1, + "computedAt": "2026-10-16T11:31:39.594Z", + "decision": "HUMAN", + "proposedRiskClass": "medium", + "effectiveRiskClass": "critical", + "reasons": [ + { + "category": "risk", + "code": "effective-risk-critical", + "message": "Effective risk class is critical (proposed: medium, derived: critical). Human review always required." + } + ], + "evidenceConsulted": [ + "evd-1792138651098-4031aa", + "evd-1792138655783-986c6a", + "evd-1792138659989-3eb4cc", + "evd-1792150257115-94b81f", + "evd-1792150257239-0d280c", + "evd-1792150257353-5d1ef0", + "evd-1792150257476-c50feb", + "evd-1792150257603-d899d4", + "evd-1792150262505-228c4d", + "evd-1792150297916-299fa9" + ], + "policiesConsulted": [ + { + "file": "policies/risk.yaml", + "version": "1" + }, + { + "file": "policies/autopilot.yaml", + "version": "1" + }, + { + "file": "policies/release.yaml", + "version": "1" + } + ], + "trustVerifier": { + "findingsCount": 1, + "errors": 0, + "warns": 1, + "infos": 0, + "findings": [ + { + "check": "sensitive-paths", + "severity": "warn", + "paths": [ + ".github/workflows/ci.yml", + "examples/http-showcase/migrations/0001_init.sql" + ], + "details": "2 path(s) match sensitive-path policy globs." + } + ] + }, + "subject": { + "tree_sha": "5dd3cb528a978a4c508f7ca16da06b18a8112789" + } +} diff --git a/.maestro/verdicts/tsk-mvaw7lqp-9fes2a/vrd-1792150761543-e282f5.json b/.maestro/verdicts/tsk-mvaw7lqp-9fes2a/vrd-1792150761543-e282f5.json new file mode 100644 index 000000000..90f581d7d --- /dev/null +++ b/.maestro/verdicts/tsk-mvaw7lqp-9fes2a/vrd-1792150761543-e282f5.json @@ -0,0 +1,57 @@ +{ + "schemaVersion": 1, + "id": "vrd-1792150761543-e282f5", + "taskId": "tsk-mvaw7lqp-9fes2a", + "contractVersion": 1, + "computedAt": "2026-10-16T11:39:21.543Z", + "decision": "HUMAN", + "proposedRiskClass": "medium", + "effectiveRiskClass": "critical", + "reasons": [ + { + "category": "policy", + "code": "threat-model-required", + "message": "Diff intersects security-relevant sensitive paths with critical risk class; a threat-model Evidence row is required." + }, + { + "category": "risk", + "code": "effective-risk-critical", + "message": "Effective risk class is critical (proposed: medium, derived: critical). Human review always required." + } + ], + "evidenceConsulted": [], + "policiesConsulted": [ + { + "file": "policies/risk.yaml", + "version": "1" + }, + { + "file": "policies/autopilot.yaml", + "version": "1" + }, + { + "file": "policies/release.yaml", + "version": "1" + } + ], + "trustVerifier": { + "findingsCount": 1, + "errors": 0, + "warns": 1, + "infos": 0, + "findings": [ + { + "check": "sensitive-paths", + "severity": "warn", + "paths": [ + ".github/workflows/ci.yml", + "examples/http-showcase/migrations/0001_init.sql" + ], + "details": "2 path(s) match sensitive-path policy globs." + } + ] + }, + "subject": { + "tree_sha": "900223345e200b57273002a10ef70aa21bdde6a7" + } +} From 0a0c0f6aad46d225dee0160a565ed3737b276b92 Mon Sep 17 00:00:00 2001 From: Tom Wieland Date: Wed, 21 Oct 2026 03:05:10 +0200 Subject: [PATCH 02/87] chore(maestro): 0.13.0 release spec, leaf specs, F63 follow-up Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Wieland --- .maestro/missions/release-0-12.execution.md | 1 + .maestro/specs/releases/0.13.0.md | 540 ++++++++++++++++++ .maestro/specs/releases/0.13/r13-e-benches.md | 24 + .../releases/0.13/r13-e-fuzz-security.md | 24 + .../releases/0.13/r13-e1-3-generate-admin.md | 25 + .../specs/releases/0.13/r13-e3-3-hooks.md | 25 + .../releases/0.13/r13-e3-3-pagination.md | 25 + .../specs/releases/0.13/r13-e3-3-relations.md | 26 + .../specs/releases/0.13/r13-e5-5-tenancy.md | 26 + .../releases/0.13/r13-e9-1-embeddings.md | 25 + .maestro/specs/releases/0.13/r13-e9-1-mcp.md | 26 + .../specs/releases/0.13/r13-e9-1-promote.md | 25 + .../specs/releases/0.13/r13-e9-1-tools.md | 26 + .../releases/0.13/r13-e9-3-admin-core.md | 27 + .../releases/0.13/r13-e9-3-admin-edit.md | 27 + .../specs/releases/0.13/r13-e9-4-i18n-core.md | 25 + .../specs/releases/0.13/r13-e9-4-i18n-web.md | 26 + .../specs/releases/0.13/r13-e9-5-leptos.md | 23 + .../specs/releases/0.13/r13-e9-5-views.md | 25 + .maestro/specs/releases/0.13/r13-f-any-env.md | 22 + .../releases/0.13/r13-f-client-batching.md | 25 + .../releases/0.13/r13-f-eventlog-head.md | 23 + .../specs/releases/0.13/r13-f-ext-connect.md | 23 + .../specs/releases/0.13/r13-f-h3-settings.md | 25 + .../specs/releases/0.13/r13-f-mesh-session.md | 23 + .maestro/specs/releases/0.13/r13-f-rcgen.md | 25 + .../specs/releases/0.13/r13-f-relay-host.md | 26 + .../specs/releases/0.13/r13-f-rpc-retire.md | 24 + .../releases/0.13/r13-f-stream-chunking.md | 25 + .../releases/0.13/r13-f-webtransport-host.md | 24 + .../releases/0.13/r13-f-ws-client-tls.md | 24 + .../specs/releases/0.13/r13-f-ws-credit.md | 22 + .../specs/releases/0.13/r13-t0-adr0015.md | 24 + .../releases/0.13/r13-t0-migration-guide.md | 22 + .../specs/releases/0.13/r13-t0-skeletons.md | 25 + .../releases/0.13/r13-w5-kit-template.md | 27 + .../specs/releases/0.13/r13-w6-pipeline.md | 25 + .../0.13/r13-w7-pipeline-backoffice.md | 23 + .maestro/specs/releases/0.13/r13-w8-kpis.md | 23 + .../specs/releases/0.13/r13-w8-level-audit.md | 23 + .maestro/specs/releases/0.13/r13-x-rescore.md | 23 + 41 files changed, 1497 insertions(+) create mode 100644 .maestro/specs/releases/0.13.0.md create mode 100644 .maestro/specs/releases/0.13/r13-e-benches.md create mode 100644 .maestro/specs/releases/0.13/r13-e-fuzz-security.md create mode 100644 .maestro/specs/releases/0.13/r13-e1-3-generate-admin.md create mode 100644 .maestro/specs/releases/0.13/r13-e3-3-hooks.md create mode 100644 .maestro/specs/releases/0.13/r13-e3-3-pagination.md create mode 100644 .maestro/specs/releases/0.13/r13-e3-3-relations.md create mode 100644 .maestro/specs/releases/0.13/r13-e5-5-tenancy.md create mode 100644 .maestro/specs/releases/0.13/r13-e9-1-embeddings.md create mode 100644 .maestro/specs/releases/0.13/r13-e9-1-mcp.md create mode 100644 .maestro/specs/releases/0.13/r13-e9-1-promote.md create mode 100644 .maestro/specs/releases/0.13/r13-e9-1-tools.md create mode 100644 .maestro/specs/releases/0.13/r13-e9-3-admin-core.md create mode 100644 .maestro/specs/releases/0.13/r13-e9-3-admin-edit.md create mode 100644 .maestro/specs/releases/0.13/r13-e9-4-i18n-core.md create mode 100644 .maestro/specs/releases/0.13/r13-e9-4-i18n-web.md create mode 100644 .maestro/specs/releases/0.13/r13-e9-5-leptos.md create mode 100644 .maestro/specs/releases/0.13/r13-e9-5-views.md create mode 100644 .maestro/specs/releases/0.13/r13-f-any-env.md create mode 100644 .maestro/specs/releases/0.13/r13-f-client-batching.md create mode 100644 .maestro/specs/releases/0.13/r13-f-eventlog-head.md create mode 100644 .maestro/specs/releases/0.13/r13-f-ext-connect.md create mode 100644 .maestro/specs/releases/0.13/r13-f-h3-settings.md create mode 100644 .maestro/specs/releases/0.13/r13-f-mesh-session.md create mode 100644 .maestro/specs/releases/0.13/r13-f-rcgen.md create mode 100644 .maestro/specs/releases/0.13/r13-f-relay-host.md create mode 100644 .maestro/specs/releases/0.13/r13-f-rpc-retire.md create mode 100644 .maestro/specs/releases/0.13/r13-f-stream-chunking.md create mode 100644 .maestro/specs/releases/0.13/r13-f-webtransport-host.md create mode 100644 .maestro/specs/releases/0.13/r13-f-ws-client-tls.md create mode 100644 .maestro/specs/releases/0.13/r13-f-ws-credit.md create mode 100644 .maestro/specs/releases/0.13/r13-t0-adr0015.md create mode 100644 .maestro/specs/releases/0.13/r13-t0-migration-guide.md create mode 100644 .maestro/specs/releases/0.13/r13-t0-skeletons.md create mode 100644 .maestro/specs/releases/0.13/r13-w5-kit-template.md create mode 100644 .maestro/specs/releases/0.13/r13-w6-pipeline.md create mode 100644 .maestro/specs/releases/0.13/r13-w7-pipeline-backoffice.md create mode 100644 .maestro/specs/releases/0.13/r13-w8-kpis.md create mode 100644 .maestro/specs/releases/0.13/r13-w8-level-audit.md create mode 100644 .maestro/specs/releases/0.13/r13-x-rescore.md diff --git a/.maestro/missions/release-0-12.execution.md b/.maestro/missions/release-0-12.execution.md index bf9e66520..6116a8181 100644 --- a/.maestro/missions/release-0-12.execution.md +++ b/.maestro/missions/release-0-12.execution.md @@ -181,4 +181,5 @@ webtransport (wave 5). | F60 | kpis | F50 decided: soak RSS mark moves to minute 5 (0.997; minute-1 1.040 still reported) — the climb is bounded replay-buffer warm-up; shrinking the replay window would weaken resume. Change `crates/id_effect_rpc/examples/streams_soak.rs` + bench.yml/gates | r12-t0-migration-guide (exit) | | F61 | kpis | security run builds the workspace `--all-features` (30+ min loaded); `/tmp` too small for `DX_KPIS_DIR` — use `.tmp/`; stale-artifact `QuicLimits::with_datagrams` not found in an old target dir → `cargo clean -p id_effect_platform` | note | | F62 | CI (PR #112) | first `http3-conformance` run: h3spec v0.1.14 77 examples, 19 failures. **Ours (server/h3.rs adapter):** connection-specific headers not rejected as malformed (connection, keep-alive, proxy-connection, te, transfer-encoding, upgrade — HTTP/3 4.2), duplicated / misplaced pseudo-headers (4.1.1, 4.1.3), frame longer than SETTINGS_MAX_FIELD_SECTION_SIZE buffered (7.1). **h3 0.0.8:** QPACK encoder/decoder stream errors (QPACK 4.1.3, 4.2, 4.4.3), SETTINGS mid-parameter / repeated id (7.1, 7.2.4.1), CANCEL_PUSH on a request stream (7.2.5), client push stream (6.2.2). **quinn/rustls:** missing_extension alert without quic_transport_parameters (TLS 8.2). Job is not in `ci-pass` (F26). Low risk today (the server terminates h3; those headers never frame bodies) | 0.13 leaf: adapter validation + upstream h3 issues/PRs; add the job to ci-pass when green | +| F63 | CI (PR #112) | flaky: `id_effect_sql::conformance mysql::dropped_streams_release_their_connections` failed once in Container integration (2627692d: `SELECT 1 AS n: sql client not connected` after 88 s, i.e. pool acquire timed out); passed on aa84c378 and #111 with no sql source change between 0.11 and 0.12. Make it deterministic (wait for the pool's idle count instead of a fixed timeout; check the MySQL container's max_connections) | 0.13 | | F21 | subscriptions | no cursor = replay from `starting_after` (default whole log), not "from now" — `EventLog` has no head read; document, or add a head read in id_effect_events | 0.13 / document in ch33-08 | diff --git a/.maestro/specs/releases/0.13.0.md b/.maestro/specs/releases/0.13.0.md new file mode 100644 index 000000000..01088bf71 --- /dev/null +++ b/.maestro/specs/releases/0.13.0.md @@ -0,0 +1,540 @@ +--- +title: id_effect 0.13.0 — Breadth +slug: release-0-13 +mode: heavy +work_type: initiative +risk_class: high +version: 1 +status: approved under the owner's L15 goal of 2026-10-18 (scope = PLAN-OF-PLANS §6 row 0.13.0, E1.3 admin, E3.3 part 2, E5.5 tenancy, E9.1, E9.3–E9.5, plus the 0.12 follow-ups targeted at 0.13); every row below marked "Taken (default, L15 goal)" is a recorded default the owner may overturn at review, and the "Owner step" rows are not done by this file +prerequisites: + - '0.12.0 tagged and merged into release/0.13.0 (branch from the v0.12.0 tag after release/0.12.0 merged). 0.12 owns the files the 0.13 follow-up leaves edit again: `crates/id_effect_rpc/src/{service.rs,client/**,transport/**,channels/**}`, `crates/id_effect_axum/src/{realtime/**,server/**}`, `crates/id_effect_platform/src/{quic/**,tls/**,http/h3.rs}`, `crates/id_effect_tool/src/rpc/**`, `crates/id_effect_kit/**`, `crates/id_effect_tool/templates/**`, `examples/reference/{relay,ledger}/**`.' + - '0.12 exit facts this plan builds on, re-checked at kickoff: `docs/scorecard/results/v0.12.0.json` and `REPORT-0.12.0.md` stored (the baseline; absent at writing); web.rpc 100, web.realtime >= 88, dist.tls >= 88 measured; ADR 0014 Accepted (0.12 D15); the 0.12 overlay follow-ups F51, F58, F60 closed by the 0.12 exit; `security_surface.rs` PENDING list empty (F41).' +scorecard: + method: "2.2 (PLAN-OF-PLANS §7.0b)" + provisional: true # results/v0.12.0.json is not stored yet; r13-t0-adr0015 restates every number below on the measured 0.12.0 baseline + baseline_version: "0.12.0" # planned 0.12.0 = E 80.9 (#1), RT 84.8, red 7 (scorecard.sh --targets 0.12.0) + target_E: 84.3 # bash scripts/scorecard.sh --targets 0.13.0 (method 2.2), rank #1; .NET 78.8; gate = 83.3 + target_RT: 86.6 # same command, rank #1 + max_red_rows: 3 # same command (eco.backing, eco.community, eco.production stay red: people and time, PLAN-OF-PLANS §9) + rows_in_scope: [data.orm, web.ui, web.admin, ai.llm, eco.i18n, eco.tenancy] # stored 0.13.0 targets 88, 67, 50, 75, 63, 75 (moved from 0.12.0 by 0.12 D1, values unchanged) + rows_kept: [web.rpc, web.realtime, dist.tls] # the T12 follow-up leaves edit their code; no drop allowed (scorecard.sh --diff) + planned_margin: { web.ui: 83, web.admin: 88, ai.llm: 100, eco.tenancy: 88, E: 85.9, RT: 87.5 } # if every margin check lands; never written as targets (D13) + carry_over: [] # filled at kickoff from REPORT-0.12.0 (any 0.12 row below its target becomes a carry-over row here) + non_goal_rows: [web.graphql] +security_kpis: { open_highs: 0, adr_0013: "every new surface complies (R1 R2 R3 R4 R6 R7 R9 R10 enforcement points named per leaf)", fuzz_targets: "≥ 5 new decoder targets (≥ 22 total) in CI, no open crash", compile_fail_security_cases: "+2 (admin site without a guard; MCP endpoint without an access decision)", secure_stack_overhead: "secure_stack − bare ≤ 5 µs (unchanged)", unary_rpc: "rpc/unary_json − rpc/axum_json_handler ≤ 3 µs (unchanged, ADR 0014 D5)" } +bench_gates: { eager: "orm/eager_100x10 / orm/n_plus_one_100x10 ≤ 0.25 (pair gate, SQLite in-memory)", i18n: "i18n/format_plural reported, budget written after measuring (D15)", ui: "ui/render_list_50 reported, budget written after measuring (D15)", unchanged: "secure-stack delta, unary RPC delta, batch pair, h3/h2 pair" } +dx_kpis: { K1: "≤ 180 s (no regression; new kit features off by default)", K2: "≤ 8 s (no regression)", K4: "≥ 17/20", K5: "≥ 80 %", K6: "within the 0.10 budget; cold-build delta of kit features ai, admin, ui, i18n reported" } +acceptance_criteria: + - 'T0 (ADR 0010 Rule 5): Amendment 9 names id_effect_ai (leaves labs, enters integration), id_effect_admin and id_effect_i18n (new, integration), the id_effect_axum `ui` feature, and examples/reference/pipeline (example); each new crate moves to platform when the Pipeline depends on it (check P); scripts/validate-crate-packaging.sh publishes the three crates in dependency order; docs/MIGRATION-0.13.md lists every semver break with its shim (L9).' + - 'ADR 0015 "Breadth batteries" accepted by the owner: the Pipeline definition, crate placement (D2 D3 D5 D6), the MCP and AI-testing decisions (D7 D8), the tool-safety rule (D9), tenant resolution (D10), the cursor format (D11), hooks (D12) and every row of the Security section, each with a mechanical enforcement point.' + - 'Rows reach their stored 0.13.0 targets: data.orm >= 88 (#3 relations with eager loading, #7 lifecycle hooks), web.ui >= 67 (4/6 scored checks: #1 #2 #6 #8), web.admin >= 50 (#1 #2 #3 #8), ai.llm >= 75 (6/8 incl. #3 #5 #6), eco.i18n >= 63 (#1 #2 #3 #5 #7), eco.tenancy >= 75 (kept; #8 is margin); each at level documented or production; every check id evidenced by a path, a test and a book chapter (METHOD §7.2); web.rpc, web.realtime and dist.tls do not drop.' + - 'Pipeline reference app (examples/reference/pipeline), the §6 exit KPI: a multi-tenant document-intake pipeline (ingest → AI extraction → classification with tools → embeddings → review in the generated admin → publish), built only through id_effect_kit, green in CI job reference-pipeline on Postgres with deterministic AI (no live vendor calls), two tenants in two locales (en, de), one tenant on its own database, an MCP server and client, and a clean prod posture with zero accepted risks.' + - 'Every 0.12 overlay follow-up targeted at 0.13 (F5 F15 F21 F25 F27 F30 F34 F36 F39 F40 F43 F45 F46 F52 F55 F56 F57 F59) is closed by its leaf or by an explicit line in the "0.12 follow-ups" table; F53 is recorded for 0.14.' + - 'Fuzz: ≥ 5 new cargo-fuzz targets (i18n_accept_language, sql_page_cursor, mcp_jsonrpc_message, ai_sse_event, ai_tool_arguments, admin_form_body) run in ClusterFuzzLite with no open crash; compile-fail security cases r1_admin_site_without_guard and r1_mcp_endpoint_without_access_decision pass; every Security-table test id exists and is green.' + - 'Blind re-score (METHOD §6) recorded as 0.13.0; bash scripts/scorecard.sh --compare-targets 0.13.0 passes (measured E ≥ planned E − 1.0) or each missed row has a written carry-over; bash scripts/scorecard.sh --diff 0.12.0 0.13.0 passes (no drop of 5 or more without an ADR note).' + - 'QUALITY_BAR gates green (coverage ≥ 96 % regions including id_effect_ai, id_effect_admin, id_effect_i18n); cargo semver-checks clean except breaks listed in docs/MIGRATION-0.13.md; every bench budget/delta/pair gate green; CHANGELOG has Scorecard and Security sections.' +non_goals: + - 'web.ui #3 server-driven interactive views and #4 asset pipeline (non-goal checks under L7, method 2.2); no LiveView-style socket UI, no bundler, no npm (L12 spirit). htmx is one vendored, hash-pinned file.' + - 'A first-party Leptos or Dioxus integration crate (PLAN-OF-PLANS §9 eco.fit lever, 1.x): Leptos is a recipe (an example outside the workspace with its own CI job), and web.ui #7 is not counted.' + - 'web.admin #7 customizable widgets beyond field overrides, and admin extensions (E13.3, 0.15).' + - 'data.orm #4 changesets / dirty tracking (E13.3, 0.15).' + - 'eco.i18n #4 locale-aware number/date/currency formatting, #6 string extraction tooling and #8 localized routes (E13.3, 0.15; Open question 5 offers #6 early).' + - 'eco.tenancy #4 per-tenant configuration and feature flags (E13.3 "tenant config", 0.15).' + - 'A vector database or pgvector integration: embeddings are an API; the Pipeline compares vectors in the application over a bounded candidate set.' + - 'Live calls to AI vendors in CI (D8); fine-tuning, image/audio models, hosted agent platforms; the Warden durable agent runtime (0.14, E13.2).' + - 'Postgres and Redis idempotency stores for RPC (F53): 0.14 sweep.' + - 'GraphQL in any form (L15). New RPC procedure kinds or wire changes: 0.13 only closes 0.12 follow-ups on the call surface.' +--- + +# id_effect 0.13.0 — Breadth + +Scorecard method 2.2 · baseline 0.12.0 (planned E 80.9 #1, RT 84.8 #1, red 7; replaced by the 0.12.0 +measured numbers at kickoff). Release slot: PLAN-OF-PLANS §6 row 0.13.0 ("Breadth": E1.3 admin, E3.3 +part 2, E5.5 tenancy, E9.1, E9.3–E9.5; 6 rows; target E 84.3 (#1), RT 86.6 (#1), red ≤ 3; exit KPI +"Pipeline reference app green"), track T9 (§5) plus E1.3, E3.3 and E5.5. The six row targets were +moved here from 0.12.0 by 0.12 D1 (`r12-t0-adr0014`, PLAN-OF-PLANS §11 row 2026-10-19) with their +values unchanged. The release also closes the 0.12 overlay follow-ups targeted at 0.13 +(`.maestro/missions/release-0-12.execution.md`, "Follow-ups recorded during the release"). + +All figures are reproducible with `bash scripts/scorecard.sh --targets 0.13.0` and, for the +scenarios, with `jq` copies of `docs/scorecard/scorecard.json` scored by +`bash scripts/scorecard.sh --file --targets 0.13.0` (the edits are written in each row of +"Capacity and index effect"). Code paths and line numbers are at the 0.12 exit worktree +(`.tmp/wt-012`, workspace version still 0.11.0 before the 0.12 bump); files the 0.12 exit still edits +will shift. + +## Prerequisite: 0.12.0 + +**No 0.13 leaf starts before the v0.12.0 tag is merged into `release/0.13.0`.** The 0.13 follow-up +leaves edit code 0.12 wrote, and the 0.12 exit is still editing it. + +| 0.12 leaf | What 0.13 builds on | 0.13 leaves that edit it again | +|---|---|---| +| r12-e12-1-macro, r12-f-client-batching (blocked → F27) | `RpcTransport`, `HttpTransport` (`crates/id_effect_rpc/src/service.rs:451,714`), the batcher (`client/http.rs`) | r13-f-client-batching | +| r12-e12-2-rpc-ws-sse, r12-e12-2-axum-ws-sse | `WsConfig::messages_per_second` (`crates/id_effect_axum/src/realtime/ws.rs:185`, limiter `:861`), `WsClient` (`crates/id_effect_rpc/src/client/ws.rs:85`) | r13-f-ws-credit, r13-f-ws-client-tls, r13-f-ext-connect | +| r12-e12-2-channels, r12-e12-2-mesh-rpc | `attach_session` (`crates/id_effect_rpc/src/channels/backplane.rs:708`), `Hub::spawn_heartbeat` (`channels/hub.rs:494`), `client/mesh.rs`, `transport/mesh.rs` | r13-f-mesh-session | +| r12-e12-2-subscriptions | `EventLog::read_all(after, limit)` (`crates/id_effect_events/src/subscription.rs:42`), `subscription/events.rs` | r13-f-eventlog-head | +| r12-e12-3-quic-endpoint, r12-f-h3-platform, r12-e12-3-h3-server | `QuicLimits` early data off (`crates/id_effect_platform/src/quic/config.rs:220`), `Route::congestion_control` (`quic/router.rs:79`), `Http3Options::dangerous_allow_zero_rtt_for_queries` (`crates/id_effect_axum/src/server/h3.rs:42`) | r13-f-h3-settings | +| r12-e12-3-webtransport | `serve_h3` + `Http3Options::webtransport()` only; host entry points keep it off (`server/{bootstrap,lifecycle,webtransport}.rs`) | r13-f-webtransport-host | +| r12-e12-3-tls-h2 | TLS + HTTP/2 on TCP (`crates/id_effect_axum/src/server/{tls,connection}.rs`), RFC 8441 off | r13-f-ext-connect | +| r12-e12-4-rpc-check, r12-e12-4-gen-ts | `crates/id_effect_tool/src/rpc/{check,gen_ts,mod}.rs`, `tests/ts/*.test.ts` | r13-f-rpc-retire, r13-f-webtransport-host (Deno test) | +| r12-w6-kit-template | kit features `rpc`, `rpc-ws`, `http3` (`crates/id_effect_kit/Cargo.toml:68-76`), posture probes (`crates/id_effect_kit/src/posture.rs`) | r13-w5-kit-template | +| r12-w7-relay | `examples/reference/relay/**` (h3 on its own endpoint, host h3 off; F59) | r13-f-relay-host | + +## What exists today (at the 0.12 exit worktree) + +| Area | Code state (path:line) | Gap for 0.13 | +|---|---|---| +| ORM | `Repository`, `BoundRepository`, `Entity`, `CurrentTenant`, offset `Page` (`crates/id_effect_sql/src/repository.rs:247,278,82,125,166`, 667 lines); `#[derive(Entity)]` with `#[entity(table, soft_delete, tenant)]` (`crates/id_effect_proc_macro/src/lib.rs:263`, `src/repository.rs`); global filters soft-delete/tenant/custom; sea-query builder (`src/query/mod.rs`). data.orm 63 (5/8: #1 #2 #5 #6 #8, production via the Ledger). | #3 relations + eager loading, #7 lifecycle hooks; keyset pagination (E3.3 "pagination"); field metadata for the admin. | +| Tenancy | `CurrentTenant` filter (fail closed), `id_effect_authz::{Tenanted, TenantScope, TenantScoped}` (`crates/id_effect_authz/src/resource.rs:48,59,88`), `m19_tenanted_resource_requires_tenant_match_by_default` (0.11); audit hash chain (`crates/id_effect_sql/src/audit.rs`); replica router (`src/routing.rs`, read/write split only). eco.tenancy 75 (#1 #2 #3 #5 #6 #7). | E5.5 helpers: one tenant resolver from the principal shared by sql and authz; #8 per-tenant connection routing (margin). | +| AI | `labs/id_effect_ai` (`publish = false`, excluded from the workspace, root `Cargo.toml:4`): OpenAI + Anthropic chat (`src/vendors/{openai,anthropic}.rs`), streaming (`src/streaming.rs`, `src/sse.rs`), retries + spans (`src/retry.rs`, `src/tracing_util.rs`), Cursor agents (`src/cursor/**`), toy `examples/mcp_server_template.rs`; book `part6/ch35-00-ai.md` (labs). ai.llm 38 (#1 #2 #7, labs cap 39). | Promote; #3 tools, #4 structured output, #5 embeddings, #6 MCP SDK, #8 agent loop. | +| Schema → JSON Schema | `crates/id_effect/src/schema/json_schema_export.rs`; parse error codes `schema/parse_errors.rs` | Reused for tool parameters, structured output and translated validation messages. | +| Admin | Only `crates/id_effect_cli/templates/admin-stub/` (referenced by `scorecard.json` evidence only); `id_effect_console` is an operator console. `id-effect generate` has `job` only (`crates/id_effect_tool/src/generate.rs:100`, templates `templates/generate/job`). web.admin 1. | Everything: #1 #2 #3 #8 (+ #4 #5 #6 margin). | +| UI | No web templating; minijinja is used by mail only (`crates/id_effect_mail/src/template.rs`). web.ui 1 (6 scored checks; #3 #4 non-goal, L7). | #1 #2 #6 #8 (+ #5 margin). | +| i18n | Nothing (git grep finds only unrelated words). eco.i18n 1. | #1 #2 #3 #5 #7. | +| T12 follow-ups | See "0.12 follow-ups" below; `rcgen` 0.13.2 pinned in `crates/id_effect_{tool,kit,axum}/Cargo.toml`, `0.13` in `crates/id_effect_{sql,node,platform}/Cargo.toml`, 0.14.10 also in `Cargo.lock` (instant-acme); 19 `.rs` files use rcgen. Core: `Effect::from_ctx` (`crates/id_effect/src/kernel/constructors.rs:107`), `Effect::widen` for errors (`kernel/effect.rs:237`), no env widening; `DEFAULT_CHUNK_SIZE = 64` (`crates/id_effect/src/streaming/stream.rs:28`), `run_for_each_effect` (`:728`). | F5 F15 F21 F25 F27 F30 F34 F36 F39 F40 F43 F45 F46 F52 F55 F56 F57 F59. | +| Fuzz / compile-fail | 17 targets (`fuzz/fuzz_targets/`); `tests/security_compile_fail/{ui,src,tests}` | New decoders: Accept-Language, page cursor, MCP JSON-RPC, AI SSE and tool arguments, admin form body. | +| Book | part6 ends at `ch35-00-ai.md`; data `ch28-0[0-5]`; application `ch30-0[0-7]` | New chapters listed per leaf. | + +## The Pipeline reference app (D1) + +PLAN-OF-PLANS names "Pipeline" in §6 (0.13 exit KPI), E10.4 and E13.2 but never defines it. This plan +defines it from the slot's epics (owner may overturn, Open question 1); r13-t0-adr0015 writes the +definition into ADR 0015 and PLAN-OF-PLANS §5 T9. + +**Pipeline** (`examples/reference/pipeline`, package `pipeline`, tier `example`, `publish = false`, +built only through `id_effect_kit`) is a multi-tenant document-intake pipeline and its back office: + +1. **Ingest:** documents arrive by RPC mutation or upload (storage) for a tenant; a job per stage + (`id_effect_jobs`). +2. **Extract:** an AI model turns text into a typed `Extraction` (structured output decoded through + `Schema`, ai.llm #4). +3. **Classify:** a bounded tool loop calls `lookup_source` and `list_labels` (typed tools, #3, #8) and + attaches `Label`s. +4. **Embed:** embeddings (#5) flag near-duplicates within the tenant (bounded candidate set). +5. **Review:** reviewers work in the admin generated by `id-effect generate admin` (web.admin) and an + htmx + maud review queue (web.ui), in English or German (eco.i18n; Accept-Language plus a per-user + override). +6. **Publish:** an `after_update` lifecycle hook (data.orm #7) writes `DocumentPublished` to the + outbox; an RPC subscription shows live status. +7. **Agents:** an MCP server exposes `search_documents` / `get_document` to outside agents (guarded, + Origin-checked); an MCP client enriches documents from a fixture MCP tool server (#6). + +Data model: `Source` 1:N `Document` N:M `Label` (join table `document_labels`), `Document` 1:N +`Extraction`, all tenant-scoped; tenant `acme` (en) shares the main database, tenant `globex` (de) is +routed to its own database (eco.tenancy #8, margin). AI in CI is deterministic (D8): a scripted model +plus wiremock'ed OpenAI and Anthropic adapters; no API key exists in CI. + +The name collides with `id_effect_resilience::Pipeline` (a policy type); the book and README always +say "the Pipeline reference app". + +## Scope selection (PLAN-OF-PLANS §7.1) + +"E carried" = the drop of the planned 0.13.0 E when only that epic's row target is removed +(`del(.targets["0.13.0"])` on the row, scored with `--targets 0.13.0`); it is the exact form of +`value` for single-row epics. Effort S 1, M 2, L 4, XL 8. + +| Epic | Track | Rows (current → 0.13.0 target, checks) | E carried | effort | priority | In | +|---|---|---|---|---|---|---| +| T0 (ADR 0015, Amendment 9, skeletons, migration guide) | T0 | none directly | — | S | — | ✓ T0 | +| E9.1 AI (promote, tools, embeddings, MCP) | T9 | ai.llm 38 → 75 (#3 #5 #6 at documented+; margin #4 #8) | 1.2 | XL | 0.15 | ✓ | +| E9.4 i18n | T9 | eco.i18n 1 → 63 (#1 #2 #3 #5 #7) | 0.8 | L | 0.20 | ✓ | +| E9.5 UI (views; Leptos recipe) | T9 | web.ui 1 → 67 (#1 #2 #6 #8; margin #5) | 0.6 | L (+M recipe) | 0.15 | ✓ | +| E9.3 + E1.3 admin | T9, T1 | web.admin 1 → 50 (#1 #2 #3 #8; margin #4 #5 #6) | 0.4 | XL | 0.05 | ✓ | +| E3.3 part 2 (relations, pagination, hooks) | T3 | data.orm 63 → 88 (#3 #7) | 0.3 | L | 0.08 | ✓ | +| E5.5 tenancy | T5 | eco.tenancy 75 → 75 (kept; margin #8) | 0 | M | — | ✓ (§6 slot) | +| T12 carry (0.12 follow-ups) | T12 | web.rpc / web.realtime / dist.tls kept | 0 | L (13 S/M leaves) | — | ✓ (owner train) | +| Proof (Pipeline, fuzz, benches, kit, audits) | — | level production for the six rows | in the epics | XL | — | ✓ (§6 KPI) | + +Totals: **3 XL + 3 L + M + T0 S + carry L ≈ 47 units**, no stretch. 0.12 committed 33 and shipped +it with 6 coordinator follow-up leaves; this slot is larger and has no rank pressure (Capacity below), +so the scope-cut table is ordered and pre-scored. + +## Capacity and index effect + +| Scenario | U | E (rank) | RT (rank) | ≥80 | <40 | Edit to `scorecard.json` copy | +|---|---|---|---|---|---|---| +| 0.12.0 planned (baseline until measured) | 81.4 | 80.9 (#1) | 84.8 (#1) | 58 | 7 | `--targets 0.12.0` | +| **Committed: the six stored 0.13.0 targets** | 84.4 | **84.3 (#1)** | 86.6 (#1) | 59 | **3** | none (`--targets 0.13.0`) | +| Margin: web.ui 83, ai.llm 88, eco.tenancy 88 | 84.9 | 85.1 (#1) | 87.0 (#1) | 62 | 3 | those three targets | +| Full margin: + web.admin 88, ai.llm 100 | 85.5 | 85.9 (#1) | 87.5 (#1) | 63 | 3 | four targets | +| One check short on two rows (eco.i18n 50, data.orm 75) | 84.0 | 84.0 (#1) | 86.5 (#1) | 58 | 3 | two targets | +| Cut data.orm | 84.1 | 84.0 (#1) | 86.5 (#1) | 58 | 3 | `del` data.orm 0.13.0 | +| Cut web.admin | 83.8 | 83.9 (#1) | 86.3 (#1) | 59 | 4 | `del` web.admin | +| Cut web.ui | 83.6 | 83.7 (#1) | 86.3 (#1) | 59 | 4 | `del` web.ui | +| Cut eco.i18n | 83.6 | 83.5 (#1) | 86.2 (#1) | 59 | 4 | `del` eco.i18n | +| Cut web.admin and web.ui | 83.0 | 83.2 (#1) | 86.0 (#1) | 59 | 5 | both `del` — **below the gate 83.3** | +| Cut ai.llm | 83.9 | 83.1 (#1) | 85.8 (#1) | 59 | 4 | `del` ai.llm — **below the gate 83.3** | + +Competitors (frozen snapshot 2026-10-14, method 2.2): .NET E 78.8, Spring 73.6. Every scenario stays +#1, so 0.13 carries no rank risk; the risk is the gate (planned − 1.0 = 83.3). **E9.1 carries 1.2 E +and cannot be cut alone.** The red-row count (≤ 3) needs every one of web.ui, web.admin, ai.llm and +eco.i18n to leave red (≥ 40). + +## Owner decisions + +Decisions already taken are not re-asked (L15: everything buildable ships before 1.0; GraphQL +non-goal; 0.12 D1 moved the six rows here). Every other row carries **Taken (default, L15 goal)** so no +leaf is blocked. + +| # | Decision | Options | Decision / default | +|---|---|---|---| +| D1 | **Pipeline reference app** (undefined in PLAN-OF-PLANS). | A data pipeline; an admin-only app; a chat/agent app. | **Taken (default, L15 goal):** the multi-tenant document-intake pipeline above, because it is the smallest app that exercises all six rows at production level; r13-t0-adr0015 writes it into ADR 0015 and PLAN-OF-PLANS §5. It is split in two leaves (core, back office) to keep each agent's context bounded. | +| D2 | **Admin placement.** | Generated code only; an `id_effect_axum` feature; a new crate. | **Taken (default):** new crate `crates/id_effect_admin` (tier `integration`; depends on axum `ui`, sql `repository`/`audit`, authz; optional i18n) with a registration API (`AdminSite::register::()`), plus `id-effect generate admin ` writing a registration module. Generated-code-only cannot reach "documented" (no published, tested crate) and axum must not depend on sql. | +| D3 | **Server-rendered UI.** | minijinja (already used by mail); maud; a new `id_effect_ui` crate. | **Taken (default):** `id_effect_axum` feature `ui` with maud (compile-time, escaped by default, typed components), htmx helpers (request/response headers, fragment vs page), layouts, and form helpers bound to `Schema` errors. htmx 2.0.x is one vendored file served from the app with its sha384 recorded (no CDN, CSP `script-src 'self'`, `htmx.config.allowEval = false`, `selfRequestsOnly = true`). No new crate. | +| D4 | **Leptos.** | First-party integration crate; recipe. | **Taken (default):** recipe only: `examples/recipes/leptos-ssr` outside the workspace (like `labs/`), own CI job `recipe-leptos`, book chapter; first in the scope-cut order; web.ui #7 not counted. | +| D5 | **i18n library and scope.** | fluent; gettext; ICU4X messageformat. | **Taken (default):** new crate `crates/id_effect_i18n` over Project Fluent (`fluent-bundle`, `fluent-langneg`, CLDR plurals via `intl_pluralrules`), exact pins recorded in the leaf; scope #1 #2 #3 #5 #7 + views (#8 of web.ui); #4 #6 #8 stay 0.15 (E13.3). | +| D6 | **AI crate scope.** | Promote as is; promote and split; drop the Cursor module. | **Taken (default):** `git mv labs/id_effect_ai crates/id_effect_ai` (tier `integration`); chat: OpenAI + Anthropic; embeddings: OpenAI + Ollama; the Cursor agents module stays behind a non-default `cursor` feature with its fixture tests (no API loss; Open question 3). All HTTP goes through `id_effect_platform`'s client (SSRF policy, body caps, timeouts). | +| D7 | **MCP.** | Wrap the `rmcp` SDK; own implementation. | **Taken (default):** own effect-native implementation (server and client; tools, resources, prompts; stdio and Streamable HTTP), pinned to the newest stable MCP specification revision at leaf start (recorded in ADR 0015; 2025-06-18 is the floor). `rmcp` is a **dev-dependency only**, used as the interop peer in tests. Tools are the same `Tool` values the agent loop uses. | +| D8 | **AI in CI.** | Live vendor calls with secrets; recorded fixtures. | **Taken (default):** no live vendor call on any PR or push job; wiremock fixtures per vendor plus `id_effect_ai::testing::ScriptedModel`; an optional `workflow_dispatch` smoke with repository secrets, never required. | +| D9 | **Tool safety.** | Tools callable by name; deny by default. | **Taken (default):** a model can call only tools registered on the call's `ToolSet` (unknown name → typed refusal fed back, never executed); arguments decoded through the tool's `Schema` before the handler runs; loop budget 8 steps / 32 tool calls / 60 s; tool output capped at 64 KiB; each limit configurable, none infinite; tools that write declare `effects = write` and need an explicit `ToolPolicy::allow_writes` (recorded in the posture registry when granted to an MCP-exposed set). | +| D10 | **Tenant resolution.** | From the principal; Host; a header. | **Taken (default):** from the authenticated principal's tenant claim (`id_effect_authz::Tenanted`); Host-based resolution through a configured allowlist; header-based only with `dangerous_tenant_from_header()` (recorded). One resolver feeds `id_effect_authz::TenantScope` and `id_effect_sql::CurrentTenant` through kit feature `tenancy`. Per-tenant connection routing (`TenantRouter`) is in scope; unknown tenants have no connection (fail closed). | +| D11 | **Keyset cursor format.** | Signed (HMAC) cursor; opaque, unsigned. | **Taken (default):** opaque base64url of the sort-key tuple and a fingerprint of the filter set, ≤ 1 KiB, decoded through a bounded `Schema`; not signed, because every query re-applies the tenant and access scope, so a forged cursor only moves inside the caller's own rows; a cursor whose fingerprint differs from the request is refused. Page size default 50, max 500. | +| D12 | **Lifecycle hooks.** | Callbacks outside the transaction; inside; events only. | **Taken (default):** `EntityHooks` (`before_insert`, `after_insert`, `before_update`, `after_update`, `before_delete`, `after_delete`) run inside the write's transaction; a failing hook rolls the write back; an optional `outbox` adapter writes an event through `id_effect_messaging` in the same transaction. | +| D13 | **Margins as targets.** | Raise stored targets to the margins; keep them. | **Taken (default):** stored 0.13.0 targets unchanged; margins are reported, not targeted (`planned_margin` in the frontmatter). r13-t0-adr0015 restates §6 on the measured 0.12.0 baseline only. | +| D14 | **F27 core helper.** | `Effect::any_env::()`; a free function. | **Taken (default):** `impl Effect { pub fn any_env(self) -> Effect }` in `kernel/effect.rs` beside `widen` (F52's pattern without the closure), and the `LocalEffect` twin; the name is Open question 6. | +| D15 | **New bench numbers.** | Write budgets now; measure first. | **Taken (default, as 0.12 D5):** i18n format and UI render are `report` gates first; budgets are written by r13-e-benches after measuring, and the owner confirms them at exit. The eager-loading pair gate ≤ 0.25 is written now (a ratio of two measurements of the same stack). | +| D16 | **ADR 0015 status.** | — | **Owner step:** r13-t0-adr0015 writes it as Proposed; the exit gate needs "Accepted". | +| D17 | **crates.io names.** | — | **Owner step (default: before the v0.13.0 tag):** `id_effect_admin` and `id_effect_i18n` need crates.io trusted-publisher setup (as 0.12 F1); r13-t0-skeletons records the availability of all three names. | + +## Security — ADR 0013 applied to the breadth batteries + +Every new surface follows ADR 0013 (`docs/adrs/0013-secure-by-default.md:55-66`): deny by default, +finite limits, every weakening a named `dangerous_*` opt-out that calls +`id_effect::observability::posture::record_dangerous` and is listed by `scripts/check-dangerous.sh`, +and a prod posture probe in `crates/id_effect_kit/src/posture.rs`. + +| Threat | Default | ADR 0013 rule | Enforcement point (mechanical) | Leaf | +|---|---|---|---|---| +| Unguarded admin | `AdminSite` mounts only through `.guard(g)` (H8 typestate); no public admin | R1, R7 | compile-fail `r1_admin_site_without_guard`; `admin_denied_without_permission`; posture probe "admin guarded" | r13-e9-3-admin-core, r13-w5-kit-template | +| Admin action authorization | each entity × action is a permission (`admin..`), default deny | R1 | `admin_action_requires_entity_permission` | r13-e9-3-admin-core | +| Mass assignment | only fields declared editable are bound; others ignored and logged | R1, R4 | `admin_form_ignores_undeclared_fields` | r13-e9-3-admin-edit | +| Admin bulk amplification | ≤ 100 ids per bulk action, form body ≤ 64 KiB | R4 | `r4_admin_bulk_action_over_cap_rejected`, `r4_admin_form_over_cap_413` | r13-e9-3-admin-edit | +| CSRF on admin / htmx forms | masked tokens on every non-GET (0.11 layer); htmx sends the token header | R9 | `r9_admin_post_without_csrf_token_refused`, `r9_htmx_requests_carry_masked_token` | r13-e9-3-admin-edit, r13-e9-5-views | +| XSS in views | maud escapes; raw HTML only through `ui::trusted_html` (a reviewed allowlist) | R9 | `ui_escapes_untrusted_by_default`, `no_preescaped_outside_trusted_module` (grep test over `crates/id_effect_{axum,admin}/src`) | r13-e9-5-views, r13-e9-3-admin-core | +| Admin changes unaudited | every create/update/delete/bulk writes the 0.9 hash-chained audit log | R10 (integrity) | `admin_change_written_to_audit_log` | r13-e9-3-admin-edit | +| Tenant leak through relations | eager loads and joins re-apply the child's tenant and soft-delete filters | R1 | `m19_eager_loaded_children_scoped_to_tenant`, `relation_join_cannot_cross_tenant` | r13-e3-3-relations | +| Forged / oversize page cursor | cursor ≤ 1 KiB, fingerprint must match, page size ≤ 500 | R4 | `r4_page_cursor_over_limit_rejected`, `cursor_with_other_filter_refused`, `r4_page_size_capped`; fuzz `sql_page_cursor` | r13-e3-3-pagination | +| Tenant from an untrusted source | principal claim by default; header only through `dangerous_tenant_from_header` | R1, R10 | `tenant_resolved_from_principal_by_default`, `dangerous_tenant_from_header_is_recorded` | r13-e5-5-tenancy | +| Per-tenant routing | unknown tenant has no connection; pool count bounded (LRU, default 64) | R1, R4 | `unknown_tenant_has_no_connection`, `r4_tenant_pool_count_bounded` | r13-e5-5-tenancy | +| Prompt injection → tool abuse | D9: registered tools only; Schema-decoded arguments; write tools need `allow_writes`; bounded loop | R1, R4 | `ai_unregistered_tool_call_refused`, `ai_tool_arguments_schema_decoded`, `ai_write_tool_denied_without_policy`, `ai_tool_loop_step_budget_enforced` | r13-e9-1-tools | +| AI resource exhaustion | finite `max_tokens`, request timeout, response and stream caps, tool output 64 KiB | R4 | `r4_ai_max_tokens_finite_by_default`, `r4_ai_stream_over_cap_closed`; fuzz `ai_sse_event`, `ai_tool_arguments` | r13-e9-1-promote, r13-e9-1-tools | +| Secret and prompt leakage | API keys are `id_effect_config::Secret`; prompts and completions are not logged by default | R2, R3, R10 | `ai_api_key_redacted_in_debug`, `ai_prompts_not_logged_by_default`, `dangerous_log_prompts_is_recorded` | r13-e9-1-promote | +| Outbound SSRF (provider base URL, MCP client) | the 0.11 platform `SsrfPolicy`; private ranges only with the platform's existing opt-out | R6 | `ai_base_url_obeys_ssrf_policy`, `mcp_client_obeys_ssrf_policy` | r13-e9-1-promote, r13-e9-1-mcp | +| MCP server exposure | Streamable HTTP endpoint needs an access decision; Origin allowlist (DNS rebinding); session ids 128-bit random bound to the principal; message ≤ 1 MiB; closed in prod unless guarded | R1, R4, R7, R9 | compile-fail `r1_mcp_endpoint_without_access_decision`; `r9_mcp_foreign_origin_refused`, `mcp_session_bound_to_principal`, `r4_mcp_message_over_cap_rejected`, `dangerous_mcp_write_tools_is_recorded`; posture probe "mcp guarded"; fuzz `mcp_jsonrpc_message` | r13-e9-1-mcp, r13-w5-kit-template | +| Accept-Language / locale abuse | header parse bounded (≤ 32 ranges, ≤ 256 bytes); catalogs load only allowlisted locales (no path from input) | R4 | `r4_accept_language_over_limit_ignored`, `locale_outside_allowlist_falls_back`; fuzz `i18n_accept_language` | r13-e9-4-i18n-web | +| Message arguments in HTML | arguments escaped by maud; Unicode bidi isolation on | R9 | `i18n_args_escaped_in_html`, `i18n_bidi_isolation_on_by_default` | r13-e9-4-i18n-web | +| HTTP/2 WebSocket (RFC 8441) | Origin allowlist and the 0.12 limits apply to extended CONNECT | R9, R4 | `r9_h2_extended_connect_foreign_origin_refused`, `h2_websocket_limits_match_h1` | r13-f-ext-connect | +| 0-RTT made real (F25) | early data off; the opt-out admits safe methods on the `h3` route only, never `idm/1` | R10 | `zero_rtt_admits_only_safe_methods`, `zero_rtt_mutation_gets_425`, `mesh_route_never_accepts_early_data` | r13-f-h3-settings | +| Control frames uncounted (F39) | credit/ping frames leave the message rate but have their own finite cap | R4 | `r4_control_frames_have_own_cap`, `credit_frames_do_not_throttle_data` | r13-f-ws-credit | +| `wss://` client trust (F40) | platform roots; no "accept invalid certs" API | R8 | `ws_client_rejects_untrusted_server_cert` | r13-f-ws-client-tls | +| Header injection in generated clients (F56) | header names/values validated; `x-rpc` and `content-type` cannot be overridden | R4, R9 | `http_transport_rejects_invalid_header`, `http_transport_cannot_override_rpc_header` | r13-f-client-batching | + +## Epics, checks and leaves + +Leaf slugs are `r13--`; each has a file in `.maestro/specs/releases/0.13/` and becomes a +Maestro task. **Rules for every leaf:** it names the crate paths it owns (disjoint within a wave) and +its ADR 0010 tier; it passes its gate plus `cargo clippy -p --all-targets --all-features -- +-D warnings`; coverage of each crate it touches ≥ 96 % regions and of its new code ≥ 96 % regions +(`cargo llvm-cov nextest -p --all-features --summary-only --fail-under-regions 96`; examples +are outside the workspace gate, 0.12 F54); public API denies `missing_docs` and adds a CHANGELOG line; +a semver break adds its section to `docs/MIGRATION-0.13.md`; every `dangerous_*` API records itself +with `posture::record_dangerous` and is listed by `scripts/check-dangerous.sh`; book chapters live under +`crates/id_effect/book/src/` and are compiled by `examples/book-snippets` (K5). Live tests read +`ID_EFFECT_REQUIRE_PG=1` / `ID_EFFECT_REQUIRE_DOCKER=1`, fail rather than skip, and start their own +container through `id_effect_test` (never the ambient `DATABASE_URL`). `` = `ID_EFFECT_REQUIRE_PG=1 +cargo nextest run`; `` = `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run`. Gates run with `TMPDIR` under `/data`. New dependencies are exact-pinned where the +crate is pre-1.0 (maud, fluent-*, htmx file) and pass `cargo deny check`. + +Tiers (ADR 0010 / ADR 0012): `core` = strict bar (id_effect); `runtime` = strict (id_effect_node); +`integration` = standard (id_effect_{sql,axum,authz,platform,rpc,events,tool,kit,ai,admin,i18n, +proc_macro}); `example` = builds and tests (reference apps, recipes, book-snippets, +security_compile_fail). Crates already at `platform` (ADR 0012 rule 4) keep the integration bar. + +### T0 (wave 0 head, then exit) + +| Leaf | Tier | Owns | Acceptance (mechanical) | Gate | +|---|---|---|---|---| +| `r13-t0-adr0015` | docs | `docs/adrs/0015-breadth-batteries.md`, `.maestro/specs/roadmap/PLAN-OF-PLANS.md` (§5 T9 Pipeline definition, §6 row 0.13.0 restated, §11 row), this file's `scorecard:` block | ADR 0015 (Proposed) with D1–D15 and the Security table as rules; §6 0.13.0 restated from `scorecard.sh --targets 0.13.0` on the measured 0.12.0 baseline; carry-over rows from REPORT-0.12.0 added to `carry_over`; `scorecard.json` untouched (D13). | `bash scripts/scorecard.sh --validate --self-test`; `bash scripts/scorecard.sh --targets 0.13.0` equals the frontmatter | +| `r13-t0-skeletons` | governance | `docs/adrs/0010-crate-tiers.md` (Amendment 9), root `Cargo.toml` (members, `exclude`), `labs/Cargo.toml`, `git mv labs/id_effect_ai crates/id_effect_ai` (paths and manifest path fixes only), `crates/id_effect_admin/` and `crates/id_effect_i18n/` skeletons, `examples/reference/pipeline/` skeleton, `crates/id_effect_cli/templates/admin-stub/**` (deleted), `scripts/validate-crate-packaging.sh`, `.github/workflows/ci.yml` (`-p` lines, labs job without ai), `docs/MIGRATION-0.13.md` (headings) | Amendment 9 per D2/D3/D5/D6; `--print-order`: i18n after core/axum, ai after platform/config, admin after sql/axum/authz/i18n, all before kit and tool; crates.io availability of the three names recorded (D17); the moved ai crate builds and its tests pass unchanged; skeletons with README `rust` fence, one example, one test, `#![deny(missing_docs)]`. | `bash scripts/validate-crate-packaging.sh`; `cargo nextest run -p id_effect_ai -p id_effect_admin -p id_effect_i18n -p pipeline`; `cargo nextest run --manifest-path labs/Cargo.toml --workspace` | +| `r13-t0-migration-guide` (exit) | docs | `docs/MIGRATION-0.13.md`, `CHANGELOG.md` | One section per `cargo semver-checks` break with its shim: `id_effect_sql::repository` module split (re-exports keep paths), `Entity` new associated items (all defaulted), `EventLog::head` (defaulted), `WsConfig` control-frame cap, `Http3Options` early-data/congestion fields, `HttpTransport` headers, rcgen 0.14 in any public signature, admin-stub template removed. CHANGELOG Scorecard + Security sections. | `cargo semver-checks` output ⊆ MIGRATION-0.13 list | + +### E9.1 — AI (XL, waves 1–3) · ai.llm 38 → 75 (#3 #5 #6; margin #4 #8) + +| Leaf | Tier | Owns | Acceptance | Gate | +|---|---|---|---|---| +| `r13-e9-1-promote` (wave 1) | integration | `crates/id_effect_ai/**` (all files in wave 1; declares every 0.13 feature and optional dependency once: `openai`, `anthropic`, `ollama`, `cursor`, `tools`, `embeddings`, `mcp`, `mcp-http`, `test-util`), book `part6/ch35-00-ai.md` (labs wording) | Tier integration, `publish` removed; module layout `chat`, `stream`, `vendors/{openai,anthropic,ollama}/{mod,chat,embed}.rs`, `tools/`, `embeddings/`, `mcp/` (empty modules for later leaves), `testing`; all HTTP through `id_effect_platform` (SSRF, caps, timeouts); API keys `Secret`; prompts never logged (`dangerous_log_prompts`); finite `max_tokens` default; `ScriptedModel`. Tests `ai_api_key_redacted_in_debug`, `ai_prompts_not_logged_by_default`, `dangerous_log_prompts_is_recorded`, `ai_base_url_obeys_ssrf_policy`, `r4_ai_max_tokens_finite_by_default`, `r4_ai_stream_over_cap_closed`, existing wiremock tests. | `bash scripts/tier-conformance.sh id_effect_ai`; `cargo nextest run -p id_effect_ai --all-features`; `cargo test -p id_effect_ai --doc` | +| `r13-e9-1-tools` (wave 2) | integration | `crates/id_effect_ai/src/{tools/**,structured.rs,agent.rs}`, `src/vendors/{openai,anthropic}/chat.rs` (tool-call fields), `tests/{tools.rs,structured.rs,agent_loop.rs}`, book `part6/ch35-01-tools-structured-output.md` | #3 `Tool` with `HasSchema` input/output → JSON Schema (`schema/json_schema_export.rs`), both vendors' tool-call formats; #4 structured output decoded through `Schema` (refinements run; a decode failure is a typed error, one bounded repair retry); #8 `ToolLoop` with D9 budgets and `ToolPolicy`. Tests `tool_schema_matches_has_schema`, `structured_output_runs_refinements`, `ai_unregistered_tool_call_refused`, `ai_tool_arguments_schema_decoded`, `ai_write_tool_denied_without_policy`, `ai_tool_loop_step_budget_enforced`, `tool_loop_round_trip_openai_and_anthropic` (wiremock). Exposes `tools::decode_arguments` for the fuzz target. | `cargo nextest run -p id_effect_ai --features tools,openai,anthropic` | +| `r13-e9-1-embeddings` (wave 2) | integration | `crates/id_effect_ai/src/embeddings/**`, `src/vendors/{openai,ollama}/embed.rs`, `tests/embeddings.rs`, book `part6/ch35-02-embeddings.md` | #5 `Embedder` with OpenAI and Ollama adapters, batch size cap, dimension check, cosine/dot helpers, typed errors, retries + spans as chat. Tests `embeddings_openai_and_ollama_same_shape`, `embedding_batch_over_cap_split`, `embedding_dimension_mismatch_is_typed_error`. | `cargo nextest run -p id_effect_ai --features embeddings,openai,ollama` | +| `r13-e9-1-mcp` (wave 3) | integration | `crates/id_effect_ai/src/mcp/**`, `crates/id_effect_ai/examples/mcp_{server,client}.rs` (replaces `mcp_server_template.rs`), `tests/{mcp_stdio.rs,mcp_http.rs,mcp_interop.rs}`, book `part6/ch35-03-mcp.md` | #6 MCP server and client (D7): initialize/capabilities, tools (from `ToolSet`), resources, prompts, cancellation, stdio and Streamable HTTP (axum route through `id_effect_axum`, typed `.guard(g)`); Origin allowlist, session ids bound to the principal, 1 MiB message cap; client through the platform client (SSRF). Interop with `rmcp` as dev-dependency both directions. Tests `mcp_tools_round_trip_stdio`, `mcp_streamable_http_round_trip`, `mcp_interop_rmcp_client_against_our_server`, `mcp_interop_our_client_against_rmcp_server`, `r9_mcp_foreign_origin_refused`, `mcp_session_bound_to_principal`, `r4_mcp_message_over_cap_rejected`, `mcp_client_obeys_ssrf_policy`. Exposes `mcp::decode_message`. | `cargo nextest run -p id_effect_ai --features mcp,mcp-http` | + +### E3.3 part 2 — Relations, pagination, hooks (L, waves 1–2) · data.orm 63 → 88 (#3 #7) + +| Leaf | Tier | Owns | Acceptance | Gate | +|---|---|---|---|---| +| `r13-e3-3-relations` (wave 1) | integration | `crates/id_effect_sql/src/repository.rs` → `src/repository/{mod,relations}.rs` (split; creates empty `page.rs`, `hooks.rs`), `crates/id_effect_proc_macro/src/repository.rs` (every 0.13 entity attribute once: `belongs_to`, `has_many`, `many_to_many(through)`, `editable`, `label`), `crates/id_effect_sql/tests/{relations.rs,repository_relations_pg.rs}`, book `part6/ch28-06-relations.md` | #3 1:N, N:1, N:M (through a join table); `Repository::with(Rel)` eager loading in one `IN` query per relation per level (no N+1), nested ≤ 3 levels; children re-scoped by tenant and soft delete; `Entity::FIELDS: &[FieldMeta]` (name, column, Schema type, editable, label) with defaults so manual impls compile. Tests `eager_load_issues_one_query_per_relation` (query-count probe), `many_to_many_through_join_table`, `m19_eager_loaded_children_scoped_to_tenant`, `relation_join_cannot_cross_tenant`, `entity_fields_metadata_from_derive`; SQLite + ``. | `cargo nextest run -p id_effect_sql --features repository,sqlite`; ` -p id_effect_sql --features repository,pg --test repository_relations_pg`; `cargo nextest run -p id_effect_proc_macro` | +| `r13-e3-3-pagination` (wave 2) | integration | `crates/id_effect_sql/src/repository/page.rs`, `tests/pagination.rs`, book `part6/ch28-07-pagination.md` | Keyset pagination `find_after(PageCursor)` / `Paged { items, next, prev }` over primary key and declared sort columns (stable under inserts), D11 cursor format and limits; offset `Page` kept. Tests `keyset_stable_under_concurrent_inserts`, `r4_page_cursor_over_limit_rejected`, `cursor_with_other_filter_refused`, `r4_page_size_capped`, `tampered_cursor_stays_in_scope`. Exposes `PageCursor::decode`. | `cargo nextest run -p id_effect_sql --features repository,sqlite --test pagination` | +| `r13-e3-3-hooks` (wave 2) | integration | `crates/id_effect_sql/src/repository/hooks.rs`, `crates/id_effect_sql/src/repository/outbox.rs` (feature `outbox` → `id_effect_messaging`; declared in sql `Cargo.toml` by this leaf), `tests/hooks.rs`, book `part6/ch28-08-lifecycle-hooks.md` | #7 D12 `EntityHooks` inside the transaction; failing hook rolls back; outbox adapter in the same transaction. Tests `hooks_run_in_order_inside_transaction`, `hook_failure_rolls_back_write`, `after_update_writes_outbox_event_atomically`. | `cargo nextest run -p id_effect_sql --features repository,sqlite --test hooks`; ` -p id_effect_sql --features repository,pg,outbox --test hooks` | + +### E5.5 — Tenancy helpers (M, wave 2) · eco.tenancy kept 75 (margin #8) + +| Leaf | Tier | Owns | Acceptance | Gate | +|---|---|---|---|---| +| `r13-e5-5-tenancy` (wave 2) | integration | `crates/id_effect_authz/src/tenant.rs` (resolver; layer behind the existing `axum` feature), `crates/id_effect_sql/src/tenant_routing.rs`, `crates/id_effect_authz/tests/tenant_resolver.rs`, `crates/id_effect_sql/tests/tenant_routing.rs`, book `part6/ch30-07-audit-tenancy.md` (new sections) | D10: `TenantResolver` (principal claim default, Host allowlist, `dangerous_tenant_from_header`); `TenantRouter` (#8) as an `SqlClient` mapping tenant → pool, bounded LRU (64), unknown tenant fails closed, works with `RoutingSqlClient` replicas. Tests `tenant_resolved_from_principal_by_default`, `dangerous_tenant_from_header_is_recorded`, `host_outside_allowlist_has_no_tenant`, `unknown_tenant_has_no_connection`, `r4_tenant_pool_count_bounded`, `tenant_router_composes_with_replicas`. | `cargo nextest run -p id_effect_authz --all-features --test tenant_resolver`; ` -p id_effect_sql --features pg --test tenant_routing` | + +### E9.5 — Server-rendered UI (L + M, waves 1 and 3) · web.ui 1 → 67 (#1 #2 #6 #8; margin #5) + +| Leaf | Tier | Owns | Acceptance | Gate | +|---|---|---|---|---| +| `r13-e9-5-views` (wave 1) | integration | `crates/id_effect_axum/{Cargo.toml (features ui, maud dep, once),src/ui/**,assets/htmx-*.min.js,assets/README.md}`, `crates/id_effect_axum/tests/ui.rs`, `crates/id_effect_axum/examples/095_htmx_todo.rs`, book `part6/ch29-10-server-rendered-ui.md` | D3: #1 maud `Markup` as a response (typed routes); #2 `Component` (props struct + slot children); #6 `Layout` and partials, fragment vs page by `HX-Request`; htmx helpers (`HxRequest`, `HxTrigger`, same-origin `HxRedirect`), vendored htmx served with sha384 + CSP; #5 form helpers bound to `Schema` errors (margin). Tests `markup_route_renders_escaped`, `ui_escapes_untrusted_by_default`, `no_preescaped_outside_trusted_module`, `component_slots_render_in_order`, `layout_wraps_page_not_fragment`, `r9_htmx_requests_carry_masked_token`, `hx_redirect_refuses_foreign_origin`, `form_errors_bound_to_fields`, `htmx_asset_hash_matches_readme`. | `cargo nextest run -p id_effect_axum --features ui`; `cargo run -p id_effect_axum --features ui --example 095_htmx_todo -- --self-test` | +| `r13-e9-5-leptos` (wave 3) | example | `examples/recipes/leptos-ssr/**` (outside the workspace, own lockfile), `.github/workflows/ci.yml` (job `recipe-leptos` only), book `part6/ch29-11-leptos-recipe.md` | Leptos SSR + hydration served by the id_effect axum host, calling an id_effect RPC service; pinned Leptos; no npm (cargo-leptos or the wasm target only, recorded). Not scored (#7). Tests `leptos_recipe_ssr_renders`, `leptos_recipe_calls_rpc`. | `cargo nextest run --manifest-path examples/recipes/leptos-ssr/Cargo.toml`; CI job `recipe-leptos` | + +### E9.4 — i18n (L, waves 1–2) · eco.i18n 1 → 63 (#1 #2 #3 #5 #7) · web.ui #8 + +| Leaf | Tier | Owns | Acceptance | Gate | +|---|---|---|---|---| +| `r13-e9-4-i18n-core` (wave 1) | integration | `crates/id_effect_i18n/**` except the wave-2 files (declares features `axum`, `maud`, `schema` and their deps once), book `part6/ch30-09-i18n.md` | D5: #1 `Catalog` of Fluent resources per locale (loaded from an allowlisted directory or embedded), missing-key fallback chain; #2 CLDR plurals (Fluent selectors); #7 `Locale` capability (`CurrentLocale` in the env) and `with_locale` switching per request/fiber; startup check that every locale has every key of the default locale. Tests `plural_categories_en_de_pl_ar_ja`, `missing_key_falls_back_to_default_locale`, `catalog_check_reports_missing_keys`, `with_locale_switches_per_fiber`. | `cargo nextest run -p id_effect_i18n` | +| `r13-e9-4-i18n-web` (wave 2) | integration | `crates/id_effect_i18n/src/{negotiate.rs,axum.rs,schema_messages.rs,maud.rs}`, `tests/{negotiate.rs,web.rs,schema_messages.rs}`, `crates/id_effect_i18n/examples/i18n_web.rs` | #3 Accept-Language negotiation (fluent-langneg) with cookie/user override, bounded parse, locale allowlist; axum layer providing `CurrentLocale`; #5 translated `Schema` validation messages (error codes from `schema/parse_errors.rs` → message ids, en + de shipped); web.ui #8 `maud::Render` for messages with escaped arguments and bidi isolation. Tests `negotiates_best_supported_locale`, `r4_accept_language_over_limit_ignored`, `locale_outside_allowlist_falls_back`, `validation_errors_translated_de`, `i18n_args_escaped_in_html`, `i18n_bidi_isolation_on_by_default`. Exposes `negotiate::parse_accept_language`. | `cargo nextest run -p id_effect_i18n --all-features`; `cargo run -p id_effect_i18n --all-features --example i18n_web -- --self-test` | + +### E9.3 + E1.3 — Admin (XL, waves 2–4) · web.admin 1 → 50 (#1 #2 #3 #8; margin #4 #5 #6) + +| Leaf | Tier | Owns | Acceptance | Gate | +|---|---|---|---|---| +| `r13-e9-3-admin-core` (wave 2) | integration | `crates/id_effect_admin/**` except the wave-3 files (declares features `i18n`, `audit`, `test-util` once), book `part6/ch30-08-admin.md` | D2: `AdminSite` registration API (#8) mounted only with `.guard(g)`; list + detail pages from `Entity::FIELDS` (#1 read half) with search, filter, sort and pagination (offset now; keyset when r13-e3-3-pagination merged) (#2); per-entity × action permissions through `id_effect_authz` (#3); tenant-scoped repositories from `CurrentTenant`; htmx + maud views from axum `ui`; labels through i18n when enabled. Tests `admin_list_search_filter_sort_page`, `admin_detail_renders_fields`, `admin_denied_without_permission`, `admin_action_requires_entity_permission`, `admin_list_scoped_to_tenant`, `no_preescaped_outside_trusted_module` (admin src). | `cargo nextest run -p id_effect_admin --all-features`; `bash scripts/tier-conformance.sh id_effect_admin` | +| `r13-e9-3-admin-edit` (wave 3) | integration | `crates/id_effect_admin/src/{edit.rs,forms.rs,bulk.rs,relations.rs,audit.rs}`, `tests/{edit.rs,bulk.rs,relations.rs}`, `crates/id_effect_admin/examples/admin_site.rs` | #1 create/edit/delete with `Schema` validation errors bound to fields (translated when i18n is on); #4 belongs_to pickers and has_many/many_to_many inline lists (relations); #5 bulk actions (≤ 100 ids); #6 every change in the audit log; mass-assignment guard; CSRF. Tests `admin_create_update_delete_round_trip`, `admin_form_ignores_undeclared_fields`, `r4_admin_bulk_action_over_cap_rejected`, `r4_admin_form_over_cap_413`, `r9_admin_post_without_csrf_token_refused`, `admin_change_written_to_audit_log`, `admin_fk_picker_lists_only_tenant_rows`. Exposes `forms::decode_form` for the fuzz target. | `cargo nextest run -p id_effect_admin --all-features`; `cargo run -p id_effect_admin --all-features --example admin_site -- --self-test` | +| `r13-e1-3-generate-admin` (wave 4) | integration | `crates/id_effect_tool/src/generate.rs` (`GenerateCommand::Admin`), `crates/id_effect_tool/templates/generate/admin/**`, `crates/id_effect_tool/tests/{generate_admin.rs,golden/generate/admin/**}`, book `part6/ch30-08-admin.md` (section "Generating an admin", after admin-core) | #8 `id-effect generate admin [--read-only] [--permissions ]` writes `src/admin/.rs` and registers it in `src/admin/mod.rs` idempotently (same insertion helpers as `generate job`); generated code compiles and passes its test in a generated `service` project (path dependency on id_effect_admin until kit-template adds kit feature `admin` and switches the template import). Tests `generate_admin_golden_output`, `generate_admin_is_idempotent`, `generated_admin_compiles_in_service_template`. | `cargo nextest run -p id_effect_tool --test generate_admin` | + +### T12 carry — the 0.12 follow-ups (waves 0–6) + +| Leaf | Tier | Owns | Acceptance | Gate | +|---|---|---|---|---| +| `r13-f-rcgen` (wave 0) | integration | the `rcgen` lines of `crates/id_effect_{tool,kit,axum,sql,node,platform}/Cargo.toml`, the 19 `.rs` files that use rcgen (list in the leaf), `Cargo.lock`, `deny.toml` (skip entry removed if any) | F5: one rcgen (0.14.x) in `cargo tree -d --workspace --all-features`; no public signature exposes rcgen types, or the break is in MIGRATION-0.13; 0.11/0.12 TLS tests unchanged and green. | `cargo tree -d --workspace --all-features \| grep -c '^rcgen'` = 0 duplicates; `cargo nextest run -p id_effect_platform -p id_effect_node -p id_effect_axum -p id_effect_sql -p id_effect_tool -p id_effect_kit --all-features`; `cargo deny check` | +| `r13-f-any-env` (wave 0) | core | `crates/id_effect/src/kernel/{effect.rs,local.rs}` (the `any_env` methods only), `crates/id_effect/tests/any_env.rs`, book `part2` section in the effect-environment chapter (one section) | F52 / D14: `Effect::any_env::()` and the `LocalEffect` twin; zero-cost (no boxing beyond the existing representation); doc example lifting into a procedure's `DynEnv`. Tests `any_env_runs_under_unit_and_dyn_env`, `any_env_preserves_interruption_and_finalizers`. | `cargo nextest run -p id_effect`; `cargo test -p id_effect --doc any_env` | +| `r13-f-client-batching` (wave 1) | integration | `crates/id_effect_rpc/src/{service.rs,client/http.rs}`, `crates/id_effect_rpc/tests/service_macro.rs` (new tests only) | F27: apply `.maestro/specs/releases/0.13/f27-client-batching.patch` then shrink it with `any_env` (no per-caller-env resolver table); F56: `HttpTransport::with_header` / `with_bearer` (validated names and values; `x-rpc`/`content-type` reserved). Tests `generated_client_batches_same_tick_calls`, `generated_client_lone_call_is_plain_envelope`, `http_transport_attaches_bearer`, `http_transport_rejects_invalid_header`, `http_transport_cannot_override_rpc_header`. | `cargo nextest run -p id_effect_rpc --all-features`; same default and `--no-default-features --features client` | +| `r13-f-stream-chunking` (wave 1) | core | `crates/id_effect/src/streaming/{stream.rs,pump.rs}` (pull path only), `crates/id_effect/tests/stream_live_chunks.rs` | F57: a pull yields the items available when the upstream would otherwise wait (≤ requested size), so `run_for_each` on a live stream delivers each item without waiting for 64; batch throughput unchanged within noise. Tests `live_stream_delivers_partial_chunk`, `run_for_each_live_no_stall` (TestClock), `bounded_stream_chunking_unchanged`. | `cargo nextest run -p id_effect`; `bash scripts/bench-run.sh streaming && bash scripts/bench-compare.sh docs/benchmarks/v0.12.json ` | +| `r13-f-eventlog-head` (wave 1) | integration | `crates/id_effect_events/src/{subscription.rs,sql_journal.rs,event_store.rs}` (the `head` method), `crates/id_effect_rpc/src/subscription/events.rs` ("from now" start), `crates/id_effect_events/tests/head.rs`, book `part6/ch33-08-subscriptions.md` (one paragraph) | F21: `EventLog::head() -> Effect` with a provided default (pages `read_all`), O(1) overrides for the SQL journal and memory store; `Subscription::from_now()` starts at head. Tests `head_is_last_global_position`, `subscription_from_now_skips_history`. | `cargo nextest run -p id_effect_events -p id_effect_rpc --features events`; ` -p id_effect_events --test head` | +| `r13-f-h3-settings` (wave 3) | integration | `crates/id_effect_platform/src/quic/{config.rs,router.rs,congestion.rs}`, `crates/id_effect_axum/src/server/h3.rs`, `crates/id_effect_platform/tests/quic_early_data.rs`, `crates/id_effect_axum/tests/http3_settings.rs` | F25: per-route early-data setting (h3 only; `idm/1` refuses it); `dangerous_allow_zero_rtt_for_queries` stops being a no-op and admits safe methods only. F36: `Http3Options::congestion(Cubic|Bbr)` (default BBR on h3 as 0.12 F22; mesh stays Cubic). Tests `zero_rtt_admits_only_safe_methods`, `mesh_route_never_accepts_early_data`, `zero_rtt_off_by_default`, `http3_congestion_switch_to_cubic`. | `cargo nextest run -p id_effect_platform -p id_effect_axum --features http3` | +| `r13-f-mesh-session` (wave 3) | runtime (node) + integration (rpc) | `crates/id_effect_node/src/{session.rs,session_pump.rs}`, `crates/id_effect_node/tests/session_pump.rs`, `crates/id_effect_rpc/src/{channels/backplane.rs,client/mesh.rs,transport/mesh.rs}`, `crates/id_effect_rpc/tests/mesh_liveness.rs` | F30: a node `SessionPump` (verified ingress, no `handle_envelope_from` bypass, 0.12 F33) replaces `channels::attach_session`'s private mesh; F34: `MeshTransport` carries trace context; mesh streams heartbeat and fail with a typed error when the peer is silent past the deadline; `client/mesh.rs` ≥ 96 % regions. Tests `session_pump_delivers_verified_envelopes_only`, `channels_over_node_session_pump`, `mesh_transport_carries_trace_context`, `mesh_stream_detects_silent_peer`. | `cargo nextest run -p id_effect_node --all-features`; `cargo nextest run -p id_effect_rpc --features mesh,channels` | +| `r13-f-ws-credit` (wave 3) | integration | `crates/id_effect_axum/src/realtime/ws.rs` (limiter classification hook), `crates/id_effect_rpc/src/transport/ws.rs` (classifies `credit`/`ping`), `crates/id_effect_rpc/tests/ws_credit_rate.rs` | F39: control frames leave the message rate and get their own finite cap (default 4× the data rate). Tests `credit_frames_do_not_throttle_data`, `r4_control_frames_have_own_cap`, existing `r4_ws_message_rate_closes_1008`. | `cargo nextest run -p id_effect_axum --features ws`; `cargo nextest run -p id_effect_rpc --features ws` | +| `r13-f-ws-client-tls` (wave 3) | integration | `crates/id_effect_rpc/{Cargo.toml,src/client/ws.rs}`, `crates/id_effect_rpc/tests/ws_client_tls.rs` | F40: `WsClient` dials `wss://` with platform roots (rustls connector, no insecure option) and negotiates `idrpc.postcard.v1`. Tests `ws_client_wss_round_trip`, `ws_client_rejects_untrusted_server_cert`, `ws_client_postcard_round_trip`. | `cargo nextest run -p id_effect_rpc --features ws,postcard` | +| `r13-f-ext-connect` (wave 4) | integration | `crates/id_effect_axum/src/server/{connection.rs,tls.rs}` (h2 builder), `crates/id_effect_axum/src/realtime/ws.rs` (upgrade path only, after ws-credit), `crates/id_effect_axum/tests/h2_websocket.rs` | F15: RFC 8441 extended CONNECT on TLS HTTP/2; WebSockets over h2 with the same Origin check and limits. Tests `websocket_over_h2_round_trip`, `r9_h2_extended_connect_foreign_origin_refused`, `h2_websocket_limits_match_h1`. | `cargo nextest run -p id_effect_axum --features tls,ws` | +| `r13-f-webtransport-host` (wave 4) | integration | `crates/id_effect_axum/src/server/{bootstrap.rs,lifecycle.rs,webtransport.rs}`, `crates/id_effect_axum/tests/host_webtransport.rs`, `crates/id_effect_tool/tests/ts/webtransport.test.ts`, `crates/id_effect_tool/tests/rpc_gen_ts.rs` (one new test) | F43: `ServerHandle`/`serve_router` serve WebTransport when `server.http3.webtransport = true` (default off) on the host endpoint; F45: Deno test of the generated TS client over WebTransport (pinned Deno, `--unstable-net` recorded); if the pinned Deno cannot, the leaf records it and the browser test stays the evidence (Risk 6). Tests `host_serves_webtransport_when_enabled`, `host_webtransport_off_by_default`, `generated_client_webtransport_round_trip_deno`. | `cargo nextest run -p id_effect_axum --features webtransport`; `cargo nextest run -p id_effect_tool --test rpc_gen_ts` | +| `r13-f-rpc-retire` (wave 4) | integration | `crates/id_effect_tool/src/rpc/{mod.rs,check.rs}`, `crates/id_effect_tool/tests/rpc_check.rs` (new cases) | F46: `--allow-retired Service:N` (repeatable) lets a removed version pass and prints it; any other break still fails. Tests `retired_version_allowed_with_flag`, `retired_flag_does_not_hide_other_breaks`. | `cargo nextest run -p id_effect_tool --test rpc_check` | +| `r13-f-relay-host` (wave 6) | example | `examples/reference/relay/**` | F59 (0.13 part): Relay serves HTTP/3 and WebTransport from the host (no private endpoint), uses `HttpTransport::with_bearer` (F56, removes the wrapper), `WsClient` over `wss://` (F40), the node session pump (F30), and depends on `id_effect_kit` only (F55 re-export); `reference-relay` steps rebased after the pipeline job. The remaining F59 items (exercise server/client streams, SSE, postcard, batching, WS resume) are 0.14 E13.2. Tests `relay_*` from 0.12 green; `relay_uses_host_webtransport`. | ` -p relay`; CI job `reference-relay` | + +### Proof (waves 4–8) + +| Leaf | Tier | Owns | Acceptance | Gate | +|---|---|---|---|---| +| `r13-e-fuzz-security` (wave 4) | example + docs | `fuzz/**` (targets `i18n_accept_language`, `sql_page_cursor`, `mcp_jsonrpc_message`, `ai_sse_event`, `ai_tool_arguments`, `admin_form_body` + corpora), `tests/security_compile_fail/**` (cases `r1_admin_site_without_guard`, `r1_mcp_endpoint_without_access_decision`; test `surface_013.rs`), `THREAT-MODEL.md` (breadth section), `docs/security/fuzzing.md` | ≥ 5 new targets build and run 5 min each in ClusterFuzzLite PR mode, no crash; `surface_013.rs` re-asserts every Security-table test id exists (grep-based, PENDING list empty at exit) and runs the cross-crate cases. | `cargo +nightly- fuzz build` (in `fuzz/`); `cargo nextest run -p security_compile_fail` | +| `r13-e-benches` (wave 4) | integration benches | `crates/id_effect_sql/benches/repository_eager.rs`, `crates/id_effect_i18n/benches/format.rs`, `crates/id_effect_axum/benches/ui_render.rs`, `docs/benchmarks/{gates.toml,README.md,v0.13.json}`, `scripts/bench-run.sh` | Pair gate `orm/eager_100x10 / orm/n_plus_one_100x10 ≤ 0.25`; `report` gates for `i18n/format_plural` and `ui/render_list_50`, then budgets written after measuring (D15); every 0.12 gate unchanged and green. | `bash scripts/bench-run.sh && bash scripts/bench-compare.sh docs/benchmarks/v0.12.json ` | +| `r13-w5-kit-template` (wave 5) | integration | `crates/id_effect_kit/**`, `crates/id_effect_tool/{templates/**,Cargo.toml}`, `scripts/{template-smoke.sh,check-dangerous.sh}` | Kit features `ai`, `mcp`, `embeddings`, `admin`, `ui`, `i18n`, `tenancy` (+ in `full`; all off by default); `tenancy` bridges `TenantResolver` → `TenantScope` + `CurrentTenant` (D10); F55 `id_effect_process` re-export (and the `cluster` template uses it); prelude items; posture probes "admin guarded", "mcp guarded", "prompts not logged", "tenant not from header"; template smoke runs `generate admin` on the `service` template. Tests `kit_tenancy_bridge_scopes_sql_and_authz`, `posture_reports_breadth_probes`, `posture_prod_refuses_public_admin`, `template_service_generate_admin_round_trip`, `kit_reexports_id_effect_process`. | `cargo nextest run -p id_effect_kit -p id_effect_tool --all-features`; `bash scripts/template-smoke.sh`; `bash scripts/check-dangerous.sh` | +| `r13-w6-pipeline` (wave 6) | example | `examples/reference/pipeline/{Cargo.toml,README.md,moon.yml,compose.yaml,migrations/**,src/{main,lib,domain,stages,ai,tenancy,mcp,rpc}.rs,src/stages/**,tests/{core,tenancy,ai,mcp}.rs,fixtures/**}`, `.github/workflows/ci.yml` (job `reference-pipeline`), the tier lines of `crates/id_effect_ai/Cargo.toml` and `crates/id_effect_i18n/Cargo.toml` | D1 stages 1–4, 6, 7 on Postgres via kit only; relations + keyset + hooks; two tenants, `globex` on its own database; deterministic AI (D8); MCP server + client; ai/i18n tier → platform (check P). Tests `pipeline_ingest_to_publish_end_to_end`, `pipeline_tenant_isolation_across_relations`, `pipeline_tenant_db_routing`, `pipeline_eager_loading_no_n_plus_one`, `pipeline_keyset_pagination_stable_under_inserts`, `pipeline_ai_tool_loop_bounded`, `pipeline_prompt_injection_cannot_call_unlisted_tool`, `pipeline_mcp_tools_round_trip`, `pipeline_publish_hook_writes_outbox`. | ` -p pipeline`; CI job `reference-pipeline`; `bash scripts/tier-conformance.sh` | +| `r13-w7-pipeline-backoffice` (wave 7) | example | `examples/reference/pipeline/src/{admin,ui}/**`, `examples/reference/pipeline/locales/**`, `examples/reference/pipeline/tests/{admin,ui,i18n,posture}.rs`, `.github/workflows/ci.yml` (`reference-pipeline` steps only), the tier line of `crates/id_effect_admin/Cargo.toml` | D1 stage 5: admin generated with `id-effect generate admin` for Source/Document/Label (edits after generation listed in the README), htmx review queue, en + de; prod posture clean, zero accepted risks; admin tier → platform. Tests `pipeline_admin_crud_and_audit`, `pipeline_admin_denied_without_role`, `pipeline_admin_bulk_rerun_extraction`, `pipeline_ui_renders_de_and_en`, `pipeline_validation_errors_translated`, `pipeline_prod_posture_is_clean`. | ` -p pipeline`; CI job `reference-pipeline`; `bash scripts/tier-conformance.sh` (all PASS) | +| `r13-w8-level-audit` (wave 8) | read-only | none | For data.orm, web.ui, web.admin, ai.llm, eco.i18n, eco.tenancy, and the kept web.rpc, web.realtime, dist.tls: every planned check id maps to a path at HEAD, a passing test, a book chapter and an example; production evidenced by `reference-pipeline` (and relay/ledger for the kept rows); gaps become tasks. | `jq` over the nine rows' check ids against the evidence map (`test -e`, `cargo nextest run -E 'test()'`) | +| `r13-w8-kpis` (wave 8) | docs + scripts | `docs/security/kpis-0.13.json`, `scripts/security-kpis.sh` (0.13 fields), `docs/benchmarks/dx-kpis-0.13.json` | Security KPIs (open highs 0, fuzz ≥ 22, compile-fail +2, secure-stack and unary budgets); DX K1–K6 with kit-feature cold-build deltas. | `bash scripts/security-kpis.sh --self-test && bash scripts/security-kpis.sh 0.13.0`; `DX_KPIS_RELEASE=0.13 bash scripts/dx-kpis.sh` | +| `r13-x-rescore` (exit) | docs | `docs/scorecard/scorecard.json` (0.13.0 measured column only), `docs/scorecard/results/v0.13.0.json`, `docs/scorecard/REPORT-0.13.0.md`, `docs/scorecard/index.html`, PLAN-OF-PLANS §11 row | Blind re-score per METHOD §6 by an assessor who did not build the leaves; adjudications recorded; `--compare-targets` and `--diff` pass or carry-overs are written. | `bash scripts/scorecard.sh --validate`; `bash scripts/scorecard.sh --compare-targets 0.13.0`; `bash scripts/scorecard.sh --diff 0.12.0 0.13.0` | + +## 0.12 follow-ups (every row the 0.12 overlay targets at 0.13) + +| # | Follow-up | Disposition | +|---|---|---| +| F5 | rcgen 0.13 + 0.14 duplicated | leaf `r13-f-rcgen` | +| F15 | RFC 8441 extended CONNECT off | leaf `r13-f-ext-connect` | +| F21 | `EventLog` has no head read | leaf `r13-f-eventlog-head` | +| F25 | no early-data setting; the 0-RTT opt-out is a no-op | leaf `r13-f-h3-settings` | +| F27 | generated-client batching; patch `0.13/f27-client-batching.patch` after the env helper | leaf `r13-f-client-batching` (after `r13-f-any-env`) | +| F30 | no node session pump | leaf `r13-f-mesh-session` | +| F34 | mesh transport: no trace context, no heartbeat; `client/mesh.rs` coverage | leaf `r13-f-mesh-session` | +| F36 | Cubic switch in `Http3Options` "if needed" | leaf `r13-f-h3-settings` (built regardless: quinn marks BBR experimental, operators need an escape hatch without code) | +| F39 | credit frames counted against the message rate | leaf `r13-f-ws-credit` | +| F40 | `WsClient` JSON-only over `ws://` | leaf `r13-f-ws-client-tls` | +| F43 | WebTransport not in the host | leaf `r13-f-webtransport-host` | +| F45 | no Deno WebTransport test | leaf `r13-f-webtransport-host` (Risk 6 if Deno cannot) | +| F46 | `--allow-retired` | leaf `r13-f-rpc-retire` | +| F52 | public helper to run an env-free effect under any env | leaf `r13-f-any-env` | +| F55 | kit lacks `id_effect_process` re-export | leaf `r13-w5-kit-template` (kit) and `r13-f-relay-host` (Relay manifest) | +| F56 | `HttpTransport` cannot attach headers | leaf `r13-f-client-batching` | +| F57 | live stream stalls until a full chunk | leaf `r13-f-stream-chunking` | +| F59 | Relay: WebTransport not in the host; many features not exercised | 0.13 part in `r13-f-relay-host`; feature exercise is **0.14 E13.2** (explicit line, not 0.13 scope) | +| F53 | two Postgres idempotency stores | **0.14** (planned stores; explicit line, not 0.13 scope) | + +## Waves + +At most five agents build at once (0.10 lesson); live-test leaves use their own containers on +distinct ports. A leaf starts when every blocker has merged. Waves with more than five leaves start +the critical-path leaves (marked ★) first; the T12 carry leaves are small and fill free slots. + +| Wave | Leaves (parallel) | Why together / blockers | +|---|---|---| +| 0 head (day 0) | `r13-t0-adr0015`, `r13-t0-skeletons`, `r13-f-rcgen`, `r13-f-any-env` | Disjoint files: ADR/roadmap; manifests + moves; rcgen call sites; two core kernel files. | +| 1 (after skeletons) | ★`r13-e9-5-views`, ★`r13-e9-1-promote`, `r13-e3-3-relations`, `r13-e9-4-i18n-core`; `r13-f-client-batching` (after any-env), `r13-f-stream-chunking`, `r13-f-eventlog-head` | Each crate has one owner (axum `ui`, ai, sql + proc_macro, i18n, rpc service/http, core streaming, events). | +| 2 | ★`r13-e9-3-admin-core` (after views, skeletons), ★`r13-e9-1-tools` (after promote), `r13-e9-1-embeddings` (after promote), `r13-e3-3-pagination`, `r13-e3-3-hooks` (after relations), `r13-e9-4-i18n-web` (after i18n-core), `r13-e5-5-tenancy` (after relations: sql `lib.rs` line) | Same-crate successors on pre-split files (ai `tools/`/`embeddings/`, sql `repository/page.rs`/`hooks.rs`). | +| 3 | ★`r13-e9-3-admin-edit` (after admin-core, relations, pagination, hooks), ★`r13-e9-1-mcp` (after tools), `r13-f-h3-settings`, `r13-f-ws-credit`, `r13-f-mesh-session`, `r13-f-ws-client-tls`, `r13-e9-5-leptos` | Seven leaves; four distinct rpc files, platform quic, node. | +| 4 | ★`r13-e1-3-generate-admin` (after admin-edit), `r13-e-fuzz-security` (after mcp, tools, i18n-web, pagination, admin-edit), `r13-e-benches` (after relations, pagination, i18n-core, views, stream-chunking), `r13-f-webtransport-host` (after h3-settings), `r13-f-ext-connect` (after ws-credit), `r13-f-rpc-retire` | | +| 5 | ★`r13-w5-kit-template` (after generate-admin, mcp, embeddings, i18n-web, tenancy, hooks, views) | Kit and templates have one owner. | +| 6 | ★`r13-w6-pipeline` (after kit-template); `r13-f-relay-host` (after kit-template, webtransport-host, ws-client-tls, client-batching, mesh-session, ext-connect) | Consumers; `ci.yml` rebased pipeline → relay. | +| 7 | ★`r13-w7-pipeline-backoffice` (after pipeline) | Same app, second half. | +| 8 | `r13-w8-level-audit`, `r13-w8-kpis` (last) | Proof. | +| Exit | `r13-x-rescore`, REPORT-0.13.0, `r13-t0-migration-guide`, CHANGELOG, owner steps D16/D17 | PLAN-OF-PLANS §7.3. | + +Critical path: skeletons → views → admin-core → admin-edit → generate-admin → kit-template → +pipeline → pipeline-backoffice → level audit → re-score (9 steps). The AI branch (promote → tools → +mcp → kit-template) has one wave of slack; the T12 carry leaves have three or more. + +### File ownership (parallel leaves never share a file) + +Append-only shared files, rebased with trivial line inserts: `crates/id_effect/book/src/SUMMARY.md`, +`examples/book-snippets/src/part6.rs`, `CHANGELOG.md`, `docs/MIGRATION-0.13.md` (one section per leaf), +`Cargo.lock` (regenerated), and, after their wave-0/1 owner merged, the `lib.rs` of `id_effect_ai`, +`id_effect_sql`, `id_effect_axum`, `id_effect_authz`, `id_effect_i18n`, `id_effect_admin`, and the +module-declaration lines of `crates/id_effect_sql/src/repository/mod.rs`, +`crates/id_effect_axum/src/server/mod.rs` and `crates/id_effect_rpc/src/{client,transport}/mod.rs` +(one `pub mod`/`pub use` line per leaf). Commits go through a private git index (`GIT_INDEX_FILE` under +`.tmp/`), pathspec-limited, with a DCO `Signed-off-by` line. + +| Path | W0 | W1 | W2 | W3 | W4 | W5 | W6 | W7 | +|---|---|---|---|---|---|---|---|---| +| `docs/adrs/0015-*`, `PLAN-OF-PLANS.md` | adr0015 | — | — | — | — | — | — | exit rescore (§11) | +| `docs/adrs/0010-*`, root `Cargo.toml`, `labs/Cargo.toml`, `scripts/validate-crate-packaging.sh`, `crates/id_effect_cli/templates/admin-stub/**` | skeletons | — | — | — | — | — | — | — | +| rcgen lines of six manifests + 19 rcgen `.rs` files | rcgen | — | — | — | — | — | — | — | +| `crates/id_effect/src/kernel/{effect,local}.rs` (`any_env`) | any-env | — | — | — | — | — | — | — | +| `crates/id_effect/src/streaming/{stream,pump}.rs` | — | stream-chunking | — | — | — | — | — | — | +| `crates/id_effect_ai/**` (layout, vendors/*/mod.rs, chat core) | skeletons (move) | promote | — | — | — | — | pipeline (tier line) | — | +| `crates/id_effect_ai/src/{tools/**,structured,agent}.rs`, `vendors/{openai,anthropic}/chat.rs` | — | promote (empty) | tools | — | — | — | — | — | +| `crates/id_effect_ai/src/embeddings/**`, `vendors/{openai,ollama}/embed.rs` | — | promote (empty) | embeddings | — | — | — | — | — | +| `crates/id_effect_ai/src/mcp/**`, `examples/mcp_*.rs` | — | promote (empty) | — | mcp | — | — | — | — | +| `crates/id_effect_sql/src/repository/{mod,relations}.rs`, `crates/id_effect_proc_macro/src/repository.rs` | — | relations | — | — | — | — | — | — | +| `crates/id_effect_sql/src/repository/page.rs` | — | relations (empty) | pagination | — | — | — | — | — | +| `crates/id_effect_sql/src/repository/{hooks,outbox}.rs`, sql `Cargo.toml` (`outbox`) | — | relations (empty) | hooks | — | — | — | — | — | +| `crates/id_effect_sql/src/tenant_routing.rs`, `crates/id_effect_authz/src/tenant.rs` | — | — | tenancy | — | — | — | — | — | +| `crates/id_effect_axum/{Cargo.toml,src/ui/**,assets/**}` | rcgen (`Cargo.toml` rcgen line) | views | — | — | — | — | — | — | +| `crates/id_effect_axum/src/server/h3.rs`, `crates/id_effect_platform/src/quic/{config,router,congestion}.rs` | — | — | — | h3-settings | — | — | — | — | +| `crates/id_effect_axum/src/realtime/ws.rs` | — | — | — | ws-credit | ext-connect | — | — | — | +| `crates/id_effect_axum/src/server/{connection,tls}.rs` | rcgen | — | — | — | ext-connect | — | — | — | +| `crates/id_effect_axum/src/server/{bootstrap,lifecycle,webtransport}.rs` | — | — | — | — | webtransport-host | — | — | — | +| `crates/id_effect_rpc/src/{service.rs,client/http.rs}` | — | client-batching | — | — | — | — | — | — | +| `crates/id_effect_rpc/{Cargo.toml,src/client/ws.rs}` | — | — | — | ws-client-tls | — | — | — | — | +| `crates/id_effect_rpc/src/transport/ws.rs` | — | — | — | ws-credit | — | — | — | — | +| `crates/id_effect_rpc/src/{channels/backplane,client/mesh,transport/mesh}.rs`, `crates/id_effect_node/src/{session,session_pump}.rs` | — | — | — | mesh-session | — | — | — | — | +| `crates/id_effect_rpc/src/subscription/events.rs`, `crates/id_effect_events/src/{subscription,sql_journal,event_store}.rs` | — | eventlog-head | — | — | — | — | — | — | +| `crates/id_effect_i18n/**` (core) | skeletons | i18n-core | — | — | — | — | pipeline (tier line) | — | +| `crates/id_effect_i18n/src/{negotiate,axum,schema_messages,maud}.rs` | — | — | i18n-web | — | — | — | — | — | +| `crates/id_effect_i18n/benches/**`, `crates/id_effect_sql/benches/repository_eager.rs`, `crates/id_effect_axum/benches/ui_render.rs`, `docs/benchmarks/**`, `scripts/bench-run.sh` | — | — | — | — | benches | — | — | w8-kpis (`dx-kpis-0.13.json`) | +| `crates/id_effect_admin/**` (core) | skeletons | — | admin-core | — | — | — | — | backoffice (tier line) | +| `crates/id_effect_admin/src/{edit,forms,bulk,relations,audit}.rs` | — | — | admin-core (empty) | admin-edit | — | — | — | — | +| `crates/id_effect_tool/src/generate.rs`, `templates/generate/admin/**` | — | — | — | — | generate-admin | — | — | — | +| `crates/id_effect_tool/src/rpc/{mod,check}.rs` | — | — | — | — | rpc-retire | — | — | — | +| `crates/id_effect_tool/tests/ts/webtransport.test.ts`, `tests/rpc_gen_ts.rs` (one test) | — | — | — | — | webtransport-host | — | — | — | +| `crates/id_effect_kit/**`, `crates/id_effect_tool/{templates/**,Cargo.toml}`, `scripts/{template-smoke,check-dangerous}.sh` | rcgen (kit/tool `Cargo.toml` rcgen line) | — | — | — | — | kit-template | — | — | +| `examples/reference/pipeline/**` | skeletons | — | — | — | — | — | pipeline | backoffice (`src/{admin,ui}/**`, `locales/**`, its tests) | +| `examples/reference/relay/**` | — | — | — | — | — | — | relay-host | — | +| `examples/recipes/leptos-ssr/**` | — | — | — | leptos | — | — | — | — | +| `.github/workflows/ci.yml` | skeletons (`-p`, labs) | — | — | leptos (`recipe-leptos` job) | — | — | pipeline (`reference-pipeline` job), relay-host (relay steps; rebased after pipeline) | backoffice (`reference-pipeline` steps) | +| `fuzz/**`, `tests/security_compile_fail/**`, `THREAT-MODEL.md`, `docs/security/fuzzing.md` | — | — | — | — | fuzz-security | — | — | — | +| `docs/security/kpis-0.13.json`, `scripts/security-kpis.sh` | — | — | — | — | — | — | — | w8-kpis (wave 8) | + +Book chapters are owned by the leaf that writes them (listed in each leaf's Owns); `SUMMARY.md` is +append-only. `crates/id_effect/book/src/part6/ch30-08-admin.md` is written by admin-core and gains one +section from generate-admin (different waves). + +## Scope-cut table (for re-planning only; the owner committed the whole slot) + +Gate threshold = planned E − 1.0 = 83.3. + +| Cut, in this order | Rows held back | Planned E | Rank | Still meets §6 KPI? | +|---|---|---|---|---| +| — (committed) | — | 84.3 | #1 | yes | +| Leptos recipe (r13-e9-5-leptos) | none (#7 not counted) | 84.3 | #1 | yes | +| Deno WebTransport test (F45), `--allow-retired` (F46), RFC 8441 (F15) | none | 84.3 | #1 | yes | +| Tenant DB routing (#8 margin) | none (eco.tenancy stays 75) | 84.3 | #1 | yes (Pipeline runs both tenants on one database) | +| Lifecycle hooks (data.orm #7) | data.orm 88 → 75 | ≈ 84.15 | #1 | yes (publish via an explicit outbox write) | +| Admin (E9.3 + E1.3) | web.admin → 1 | 83.9 | #1 | partially (Pipeline back office without admin), red 4 | +| UI views after admin | web.ui → 1 | 83.2 | #1 | **no** (below gate) | + +Never cut: T0, E9.1 AI (alone below the gate), E9.4 i18n (0.8 E and a red row), the Pipeline core +(the §6 KPI), the Security table, the fuzz targets, every security-relevant carry leaf (F25 F39 F40 +F56: each closes an ADR 0013 gap). + +## Risks (ranked) + +| # | Risk | Mitigation | +|---|---|---| +| 1 | **0.12 not tagged first, or a 0.12 row short.** Thirteen leaves edit 0.12 code; the 0.12 exit is editing the same files now. | Hard prerequisite: branch from the v0.12.0 tag; kickoff reads REPORT-0.12.0 and adds each miss to `carry_over` and to the leaf that owns its files; `--diff 0.12.0 0.13.0` in the exit gate. | +| 2 | **Admin is the critical path and the largest new crate** (four waves, XL), and depends on four other leaves' APIs (views, relations metadata, pagination, hooks/audit). | Relations owns the `Entity::FIELDS` contract in wave 1; admin-core uses offset pages first; admin is fifth in the cut order with a pre-scored effect (−0.4 E). | +| 3 | **MCP own implementation** against a moving specification (D7). | Pin one revision in ADR 0015; interop tests both ways with `rmcp` as a dev-dependency; Open question 4 lets the owner switch to wrapping `rmcp`. | +| 4 | **AI evidence without live vendors** (D8): an assessor may doubt wiremock-only adapters. | Fixtures are recorded from the vendors' documented examples and cite their docs; an optional `workflow_dispatch` live smoke with repository secrets; the scripted model is labelled as such. | +| 5 | **eco.i18n has no margin** (5/8 = 63 exactly); one check judged unmet drops it to 50 (E −0.15, still above the gate) and keeps it out of red only if ≥ 40. | Each of #1 #2 #3 #5 #7 has its own named test and chapter section; Open question 5 offers extraction tooling (#6) early. | +| 6 | **Deno WebTransport (F45) may be infeasible**: Deno's client WebTransport is behind `--unstable-net` and its `serverCertificateHashes` support is unproven. | The leaf tries the pinned Deno first; if it cannot, it records why and the Chromium browser test (0.12) stays the evidence; F45 is in the cut order. | +| 7 | **Core stream change (F57)** touches every `Stream` pull in a strict-tier crate. | Pull-path-only change, three tests incl. "bounded streams unchanged", the streaming benches compared against v0.12. | +| 8 | **Prompt injection is not solvable by a library.** | The library's job is bounded authority (D9): registered tools only, Schema-decoded args, write tools need a policy, budgets; THREAT-MODEL states the residual risk; the Pipeline test injects a hostile document. | +| 9 | **Compile time** (maud, fluent, admin, ai) hurts K2/K6. | All new kit features off by default; K6 deltas per feature reported by r13-w8-kpis; the `service` template does not turn them on. | +| 10 | **htmx vendored JavaScript** is a supply-chain item outside `cargo deny`. | One file, sha384 in `assets/README.md` and a test, 0BSD license recorded, served same-origin with CSP; update is a reviewed leaf. | +| 11 | **rcgen 0.14 API change (F5)** may leak into public signatures of platform `tls`. | Wave 0, before other TLS work; semver-checks; MIGRATION entry if public. | +| 12 | **Pipeline is two waves of one app** on the critical path. | Split by directory (core vs `admin/`/`ui/`/`locales/`); the core leaf's CI job is green before back office starts. | +| 13 | **Repository split** (`repository.rs` → `repository/`) may break the Ledger's imports. | `pub use` re-exports keep every path; Ledger CI job in the relations gate. | +| 14 | **Shared checkout** with other agents. | Disjoint Owns per wave; private git index; pathspec commits; integrator-only shared files. | + +**Leaf most likely to be wrong:** `r13-e9-3-admin-core`. It is the first consumer of four APIs that land +in the same or the previous wave (axum `ui`, `Entity::FIELDS`, pagination, authz permissions), it fixes +the registration API that the generator and the Pipeline then freeze, and its effect on the index is +the second smallest (0.4 E). If `Entity::FIELDS` is the wrong shape (for example, it cannot carry +Schema refinements or relation targets), admin-edit, generate-admin and the Pipeline back office all +rework. + +**What would make the plan misconceived:** the belief that each breadth row is best served by a +first-party battery. If the owner (or the assessor) holds that web.ui and web.admin should be met by +recommending third-party crates (askama, a Leptos integration, an existing admin crate) under the +METHOD third-party rule, then D2/D3 build crates whose only purpose is a score, and the right 0.13 is +recipes plus deeper ORM/AI work. Open question 2 asks this directly. + +## Exit gate (PLAN-OF-PLANS §7.3) + +| Gate | Command / artifact | Pass condition | +|---|---|---| +| Quality bar | `moon run :ci`, `scripts/llvm-cov-nextest.sh`, `scripts/validate-crate-packaging.sh`, minimal-versions | as `docs/QUALITY_BAR.md`; coverage ≥ 96 % incl. id_effect_ai, id_effect_admin, id_effect_i18n | +| Tiers | `bash scripts/tier-conformance.sh` | all PASS; id_effect_ai, id_effect_admin, id_effect_i18n at `platform` (Pipeline consumer) | +| Semver | `cargo semver-checks` | only breaks in `docs/MIGRATION-0.13.md` | +| Bench gates | `scripts/bench-compare.sh` vs 0.12.0, `bench.yml` | no `budget`/`delta`/`pair` gate failed: eager ≤ 0.25, every 0.12 gate unchanged | +| Security KPIs | `bash scripts/security-kpis.sh 0.13.0` | open highs 0; fuzz targets ≥ 22 (≥ 5 new) with no open crash; compile-fail cases include the two new cases; every Security-table test green | +| Dependencies | `cargo tree -d --workspace --all-features`, `cargo deny check` | one rcgen, one quinn, one rustls, one tungstenite | +| ADR 0015 | `docs/adrs/0015-breadth-batteries.md` | status Accepted (owner, D16) | +| Pipeline KPI | CI job `reference-pipeline` | green on the tag: both tenants, both locales, admin, MCP, prod posture clean | +| Kept rows | CI jobs `reference-relay`, `reference-ledger` | green; `id-effect rpc check` passes | +| Blind re-score | `r13-x-rescore` (METHOD §6) | done; adjudications recorded | +| Index target | `bash scripts/scorecard.sh --compare-targets 0.13.0` | measured E ≥ planned − 1.0 (planned 84.3) or written carry-over | +| Rows | `docs/scorecard/results/v0.13.0.json` | data.orm ≥ 88, web.ui ≥ 67, web.admin ≥ 50, ai.llm ≥ 75, eco.i18n ≥ 63, eco.tenancy ≥ 75; red ≤ 3 | +| No silent regression | `bash scripts/scorecard.sh --diff 0.12.0 0.13.0` | no row −5 or worse without an ADR note | +| DX KPIs | `DX_KPIS_RELEASE=0.13 bash scripts/dx-kpis.sh` | K1 ≤ 180 s, K2 ≤ 8 s, K4 ≥ 17/20, K5 ≥ 80 % | +| Reports | `docs/scorecard/results/v0.13.0.json`, `REPORT-0.13.0.md`, CHANGELOG Scorecard + Security sections | committed | + +## Open questions (defaults taken so no leaf is blocked; the owner can overturn any) + +1. **D1 Pipeline:** the multi-tenant document-intake pipeline as defined above? Default: yes. +2. **D2/D3 first-party batteries:** build `id_effect_admin` and the axum `ui` feature, or meet web.ui / + web.admin by documented third-party recipes? Default: build them (first-party, deny-by-default, + covered by the 96 % gate). +3. **D6 Cursor agents module:** keep it behind the non-default `cursor` feature, or drop it from the + published crate? Default: keep behind the feature. +4. **D7 MCP:** own implementation with `rmcp` as a test peer, or wrap `rmcp`? Default: own + implementation. +5. **D5 i18n margin:** pull `id-effect i18n check|extract` (eco.i18n #6) forward from 0.15 to give the + row margin? Default: no (keep E13.3 order). +6. **D14 name:** `Effect::any_env::()`? Default: `any_env`. +7. **D10 tenancy:** confirm that a tenant from a request header needs a `dangerous_*` opt-out even + behind a trusted gateway. Default: yes. +8. **D15 budgets:** confirm the i18n format and UI render budgets after r13-e-benches measures them. + Default: owner sees them at exit. +9. **D13:** keep the stored targets (84.3) or raise the four rows to their margins (85.9)? Default: + keep. + +## Exit report +Filled in at exit: `docs/scorecard/REPORT-0.13.0.md`. + +## Approval +Executed under the owner's goal of 2026-10-18 ("implement and release every planned version up to +0.19.0"), after 0.12.0 is tagged. Next (coordinator): `maestro mission from-spec +.maestro/specs/releases/0.13.0.md`, one Maestro task per wave-0/1 leaf +(`.maestro/specs/releases/0.13/r13-*.md`), and the execution overlay +`.maestro/missions/release-0-13.execution.md`. ADR 0015 acceptance (D16), the crates.io setup (D17) +and the nine open questions are the owner's. diff --git a/.maestro/specs/releases/0.13/r13-e-benches.md b/.maestro/specs/releases/0.13/r13-e-benches.md new file mode 100644 index 000000000..787930e7c --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-e-benches.md @@ -0,0 +1,24 @@ +--- +title: 'Proof — Benches and gates for eager loading, i18n formatting and UI rendering: r13-e-benches' +slug: r13-e-benches +mode: heavy +work_type: spec-slice +risk_class: low +version: 1 +acceptance_criteria: + - 'Benches `orm/eager_100x10` and `orm/n_plus_one_100x10` (100 parents × 10 children, SQLite in-memory, same run) in crates/id_effect_sql/benches/repository_eager.rs; `pair` gate `orm/eager_100x10 / orm/n_plus_one_100x10 ≤ 0.25` in docs/benchmarks/gates.toml.' + - '`i18n/format_plural` (crates/id_effect_i18n/benches/format.rs) and `ui/render_list_50` (crates/id_effect_axum/benches/ui_render.rs) as `report` gates; after the first measurement on the bench runner, a `budget` gate is written for each at measured p50 × 1.5 (D15), and the owner confirms the numbers at exit.' + - 'Every 0.12 gate (secure-stack delta ≤ 5 µs, unary RPC delta ≤ 3 µs, batch pair ≤ 0.20, h3/h2 pair) unchanged and green; the streaming group compared against v0.12 for r13-f-stream-chunking; results stored as docs/benchmarks/v0.13.json; README updated.' + - 'Tier: integration (bench targets in three crates) and docs. Secure by default: not applicable (no surface).' + - 'Gate: `bash scripts/bench-run.sh && bash scripts/bench-compare.sh docs/benchmarks/v0.12.json ` (no budget/delta/pair failure)' + - 'Owns: `crates/id_effect_sql/benches/repository_eager.rs`, `crates/id_effect_i18n/benches/format.rs`, `crates/id_effect_axum/benches/ui_render.rs` (and their `[[bench]]` manifest entries), `docs/benchmarks/{gates.toml,README.md,v0.13.json}`, `scripts/bench-run.sh`' +--- + +# r13-e-benches + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "Proof". The release spec's row is binding; this +file exists so Maestro can track the leaf as its own task. + +Wave 4. Blocked by: r13-e3-3-relations, r13-e3-3-pagination, r13-e9-4-i18n-core, r13-e9-5-views, +r13-f-stream-chunking. Runs on an idle machine. The `[[bench]]` lines in the sql, i18n and axum +manifests are the only manifest edits; no other wave-4 leaf touches those manifests. diff --git a/.maestro/specs/releases/0.13/r13-e-fuzz-security.md b/.maestro/specs/releases/0.13/r13-e-fuzz-security.md new file mode 100644 index 000000000..2f4ce1294 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-e-fuzz-security.md @@ -0,0 +1,24 @@ +--- +title: 'Proof — Fuzz targets for the new decoders, compile-fail security cases, the 0.13 security surface: r13-e-fuzz-security' +slug: r13-e-fuzz-security +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - '≥ 5 new cargo-fuzz targets under fuzz/fuzz_targets/ (17 today → ≥ 22) with seed corpora: `i18n_accept_language` (`negotiate::parse_accept_language`), `sql_page_cursor` (`PageCursor::decode`), `mcp_jsonrpc_message` (`mcp::decode_message`), `ai_sse_event` (`stream::decode_sse_event`), `ai_tool_arguments` (`tools::decode_arguments`), `admin_form_body` (`forms::decode_form`); each builds and runs 5 minutes in ClusterFuzzLite PR mode with no crash.' + - 'Compile-fail security cases in tests/security_compile_fail: `r1_admin_site_without_guard` (mounting `AdminSite` without `.guard`) and `r1_mcp_endpoint_without_access_decision` (the Streamable HTTP route without an access decision), each with a `help:` line.' + - '`tests/security_compile_fail/tests/surface_013.rs` re-asserts that every test id in the release spec Security table exists in the tree (grep-based, as the 0.12 security_surface.rs) and runs the cross-crate cases; its PENDING list is empty at exit.' + - 'THREAT-MODEL.md gains a "Breadth batteries" section: admin, server-rendered UI, tenancy, AI tools and prompt injection (with the residual risk), MCP, i18n input, each threat mapped to its test; docs/security/fuzzing.md lists the new targets.' + - 'Tier: example (security_compile_fail, fuzz) and docs. Secure by default: this leaf is the mechanical check that every other leaf''s rule exists.' + - 'Gate: `cargo +nightly- fuzz build` (in fuzz/, toolchain from fuzz/rust-toolchain.toml); `cargo nextest run -p security_compile_fail`; `TMPDIR=/data/... cargo fuzz run -- -max_total_time=300` for each new target' + - 'Owns: `fuzz/**`, `tests/security_compile_fail/**`, `THREAT-MODEL.md` (breadth section), `docs/security/fuzzing.md`' +--- + +# r13-e-fuzz-security + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "Proof". The release spec's row is binding; this +file exists so Maestro can track the leaf as its own task. + +Wave 4. Blocked by: r13-e9-1-mcp, r13-e9-1-tools, r13-e9-4-i18n-web, r13-e3-3-pagination, +r13-e9-3-admin-edit (each exposes its decoder). Feeds r13-w8-kpis. diff --git a/.maestro/specs/releases/0.13/r13-e1-3-generate-admin.md b/.maestro/specs/releases/0.13/r13-e1-3-generate-admin.md new file mode 100644 index 000000000..fc1f3d299 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-e1-3-generate-admin.md @@ -0,0 +1,25 @@ +--- +title: 'E1.3 — `id-effect generate admin `: r13-e1-3-generate-admin' +slug: r13-e1-3-generate-admin +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - 'web.admin #8 (generator half): `GenerateCommand::Admin` in crates/id_effect_tool/src/generate.rs (`:100`): `id-effect generate admin [--read-only] [--permissions ]` writes `src/admin/.rs` (an `EntityAdmin` registration with list columns, search and filters from the entity''s fields) and registers it in `src/admin/mod.rs`, idempotently, with the same insertion helpers `generate job` uses; templates under crates/id_effect_tool/templates/generate/admin/.' + - 'Generated code mounts the site with `.guard(g)` and the permission prefix (never public); `--read-only` registers list/view only.' + - 'Tests `generate_admin_golden_output` (golden files under tests/golden/generate/admin/, `UPDATE_GOLDEN=1` rewrites), `generate_admin_is_idempotent`, `generate_admin_unknown_entity_is_error`, `generated_admin_compiles_in_service_template` (generates a `service` project, adds an entity, runs `generate admin`, builds and runs its test; until r13-w5-kit-template adds kit feature `admin`, the test project depends on id_effect_admin by path, and the generated import is `id_effect_admin::` behind one template variable that kit-template switches to `id_effect_kit::admin::`).' + - 'Book `part6/ch30-08-admin.md` section "Generating an admin".' + - 'Secure by default (R1): generated permissions default to deny until granted; no flag generates a public admin.' + - 'Tier: integration (id_effect_tool). Coverage: `cargo llvm-cov nextest -p id_effect_tool --all-features --summary-only --fail-under-regions 96`.' + - 'Gate: `cargo nextest run -p id_effect_tool --test generate_admin` (the template smoke with admin runs in r13-w5-kit-template, which owns scripts/template-smoke.sh)' + - 'Owns: `crates/id_effect_tool/src/generate.rs`, `crates/id_effect_tool/templates/generate/admin/**`, `crates/id_effect_tool/tests/{generate_admin.rs,golden/generate/admin/**}`, the "Generating an admin" section of `crates/id_effect/book/src/part6/ch30-08-admin.md`' +--- + +# r13-e1-3-generate-admin + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "E9.3 + E1.3 — Admin". The release spec's row is +binding; this file exists so Maestro can track the leaf as its own task. + +Wave 4, critical path. Blocked by: r13-e9-3-admin-edit. Blocks r13-w5-kit-template (template smoke) +and r13-w7-pipeline-backoffice. Runs beside r13-f-rpc-retire (different tool files: `src/rpc/**`). diff --git a/.maestro/specs/releases/0.13/r13-e3-3-hooks.md b/.maestro/specs/releases/0.13/r13-e3-3-hooks.md new file mode 100644 index 000000000..3d111a938 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-e3-3-hooks.md @@ -0,0 +1,25 @@ +--- +title: 'E3.3 part 2 — Lifecycle hooks on persist, inside the transaction, with an outbox adapter: r13-e3-3-hooks' +slug: r13-e3-3-hooks +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - 'data.orm #7 (D12): `EntityHooks` with `before_insert`, `after_insert`, `before_update`, `after_update`, `before_delete`, `after_delete`, each an Effect run inside the write''s transaction (the repository opens one when the caller has none); a failing hook rolls the write back; hooks are registered per repository, not globally.' + - 'Feature `outbox` of id_effect_sql (declared by this leaf in crates/id_effect_sql/Cargo.toml, optional dependency on id_effect_messaging): an adapter that writes an event to the transactional outbox in the same transaction.' + - 'Secure by default: hooks cannot bypass tenant scoping (they receive the bound repository); the existing audit log keeps its explicit append and can be wired as an `after_*` hook (documented).' + - 'Tests `hooks_run_in_order_inside_transaction`, `hook_failure_rolls_back_write`, `before_hook_can_reject_with_typed_error`, `after_update_writes_outbox_event_atomically` (SQLite and ``).' + - 'Book `part6/ch28-08-lifecycle-hooks.md`.' + - 'Tier: integration. Coverage: `cargo llvm-cov nextest -p id_effect_sql --features repository,sqlite,outbox --summary-only --fail-under-regions 96`.' + - 'Gate: `cargo nextest run -p id_effect_sql --features repository,sqlite,outbox --test hooks`; `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p id_effect_sql --features repository,pg,outbox --test hooks`' + - 'Owns: `crates/id_effect_sql/src/repository/{hooks.rs,outbox.rs}`, `crates/id_effect_sql/Cargo.toml` (feature `outbox` and its optional dependency only), `crates/id_effect_sql/tests/hooks.rs`, `crates/id_effect/book/src/part6/ch28-08-lifecycle-hooks.md`' +--- + +# r13-e3-3-hooks + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "E3.3 part 2". The release spec's row is binding; +this file exists so Maestro can track the leaf as its own task. + +Wave 2. Blocked by: r13-e3-3-relations. Runs beside r13-e3-3-pagination and r13-e5-5-tenancy (the only +wave-2 leaf that edits the sql manifest). Blocks r13-e9-3-admin-edit and r13-w5-kit-template. diff --git a/.maestro/specs/releases/0.13/r13-e3-3-pagination.md b/.maestro/specs/releases/0.13/r13-e3-3-pagination.md new file mode 100644 index 000000000..cd50ecba9 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-e3-3-pagination.md @@ -0,0 +1,25 @@ +--- +title: 'E3.3 part 2 — Keyset (cursor) pagination for repositories: r13-e3-3-pagination' +slug: r13-e3-3-pagination +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - 'data.orm #2 strengthened (E3.3 "pagination"): `Repository::find_after(PageCursor)` / `find_before` returning `Paged { items, next, prev }` over the primary key plus declared sort columns; stable under concurrent inserts; the offset `Page` (`find_page`) is kept.' + - 'D11 cursor format: opaque base64url of the sort-key tuple and a fingerprint of the filter set; ≤ 1 KiB; decoded through a bounded Schema; not signed because every query re-applies tenant and access scope.' + - 'Secure by default (ADR 0013 R4, R1): oversize cursor rejected; a cursor whose fingerprint differs from the request filters refused; page size default 50, max 500 (configurable, finite). Tests `r4_page_cursor_over_limit_rejected`, `cursor_with_other_filter_refused`, `r4_page_size_capped`, `tampered_cursor_stays_in_scope`.' + - 'Functional tests `keyset_stable_under_concurrent_inserts`, `keyset_prev_and_next_round_trip`, `keyset_with_eager_relations`; `PageCursor::decode` is public for the fuzz target `sql_page_cursor`.' + - 'Book `part6/ch28-07-pagination.md`.' + - 'Tier: integration. Coverage: `cargo llvm-cov nextest -p id_effect_sql --features repository,sqlite --summary-only --fail-under-regions 96`.' + - 'Gate: `cargo nextest run -p id_effect_sql --features repository,sqlite --test pagination`' + - 'Owns: `crates/id_effect_sql/src/repository/page.rs`, `crates/id_effect_sql/tests/pagination.rs`, `crates/id_effect/book/src/part6/ch28-07-pagination.md`' +--- + +# r13-e3-3-pagination + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "E3.3 part 2". The release spec's row is binding; +this file exists so Maestro can track the leaf as its own task. + +Wave 2. Blocked by: r13-e3-3-relations. Runs beside r13-e3-3-hooks (separate files; `repository/mod.rs` +module lines append-only). Blocks r13-e9-3-admin-edit and r13-e-fuzz-security. diff --git a/.maestro/specs/releases/0.13/r13-e3-3-relations.md b/.maestro/specs/releases/0.13/r13-e3-3-relations.md new file mode 100644 index 000000000..5b427978c --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-e3-3-relations.md @@ -0,0 +1,26 @@ +--- +title: 'E3.3 part 2 — Repository relations with eager loading and entity field metadata: r13-e3-3-relations' +slug: r13-e3-3-relations +mode: heavy +work_type: spec-slice +risk_class: high +version: 1 +acceptance_criteria: + - 'crates/id_effect_sql/src/repository.rs becomes the module tree `repository/{mod,relations,page,hooks}.rs` (`page.rs` and `hooks.rs` created empty for wave 2); every existing public path is kept by `pub use`, so the Ledger and `tests/repository.rs` compile unchanged.' + - 'data.orm #3: `#[entity(belongs_to(...))]`, `has_many(...)`, `many_to_many(..., through = "...")` in crates/id_effect_proc_macro/src/repository.rs (this leaf declares every 0.13 entity attribute once, including `editable` and `label` used by the admin); `Repository::with(Rel)` eager-loads in one `IN (...)` query per relation per level (no N+1), nested at most 3 levels; works on Postgres, MySQL and SQLite through sea-query.' + - '`Entity::FIELDS: &[FieldMeta]` (field name, column, Schema type name, nullable, editable, label key, relation target) and `Entity::RELATIONS`, both with defaults so hand-written `impl Entity` compiles (no semver break); the design of FieldMeta is written in the leaf evidence before admin-core starts.' + - 'Secure by default (ADR 0013 R1): eager loads and joins re-apply the child entity''s tenant and soft-delete filters; a relation can never return a row of another tenant. Tests `m19_eager_loaded_children_scoped_to_tenant`, `relation_join_cannot_cross_tenant`.' + - 'Functional tests `eager_load_issues_one_query_per_relation` (query-count probe on the executor), `many_to_many_through_join_table`, `nested_eager_load_three_levels`, `entity_fields_metadata_from_derive`, `manual_entity_impl_still_compiles`; SQLite in the default run and Postgres under ``.' + - 'Book `part6/ch28-06-relations.md`; `ch28-03-repositories.md` links it.' + - 'Tier: integration (id_effect_sql, id_effect_proc_macro). Coverage: `cargo llvm-cov nextest -p id_effect_sql --features repository,sqlite --summary-only --fail-under-regions 96` (proc-macro entry stubs stay excluded as in scripts/llvm-cov-nextest.sh).' + - 'Gate: `cargo nextest run -p id_effect_sql --features repository,sqlite`; `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p id_effect_sql --features repository,pg --test repository_relations_pg`; `cargo nextest run -p id_effect_proc_macro`; `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p ledger` (imports unchanged)' + - 'Owns: `crates/id_effect_sql/src/repository.rs` → `crates/id_effect_sql/src/repository/{mod,relations}.rs` (plus empty `page.rs`, `hooks.rs`), `crates/id_effect_proc_macro/src/repository.rs`, `crates/id_effect_sql/tests/{relations.rs,repository_relations_pg.rs}`, `crates/id_effect/book/src/part6/ch28-06-relations.md`' +--- + +# r13-e3-3-relations + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "E3.3 part 2". The release spec's row is binding; +this file exists so Maestro can track the leaf as its own task. + +Wave 1. Blocked by: r13-t0-skeletons. Blocks r13-e3-3-pagination, r13-e3-3-hooks, r13-e5-5-tenancy +(sql `lib.rs` line), r13-e9-3-admin-edit (FieldMeta and relations). diff --git a/.maestro/specs/releases/0.13/r13-e5-5-tenancy.md b/.maestro/specs/releases/0.13/r13-e5-5-tenancy.md new file mode 100644 index 000000000..b67627d81 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-e5-5-tenancy.md @@ -0,0 +1,26 @@ +--- +title: 'E5.5 — Tenant resolution for sql and authz, and per-tenant connection routing: r13-e5-5-tenancy' +slug: r13-e5-5-tenancy +mode: heavy +work_type: spec-slice +risk_class: high +version: 1 +acceptance_criteria: + - 'D10: `id_effect_authz::tenant::TenantResolver` resolves the tenant from the authenticated principal (`Tenanted`, crates/id_effect_authz/src/resource.rs:48) by default, from the Host header through a configured allowlist, or from a header only with `dangerous_tenant_from_header()`; an axum layer behind the existing authz `axum` feature provides the resolved `TenantId` to handlers and `TenantScope`.' + - 'eco.tenancy #8 (margin): `id_effect_sql::TenantRouter`, an `SqlClient` mapping tenant → pool (configured map or a resolver effect), bounded LRU of pools (default 64), composable with `RoutingSqlClient` replicas (crates/id_effect_sql/src/routing.rs).' + - 'Secure by default (ADR 0013 R1, R4, R10): unknown tenant has no connection (fails closed); no tenant means no tenant-scoped query (existing CurrentTenant rule); header resolution is a recorded opt-out; Host resolution outside the allowlist yields no tenant. Tests `tenant_resolved_from_principal_by_default`, `dangerous_tenant_from_header_is_recorded`, `host_outside_allowlist_has_no_tenant`, `unknown_tenant_has_no_connection`, `r4_tenant_pool_count_bounded`.' + - 'Functional tests `tenant_router_composes_with_replicas`, `tenant_router_routes_two_tenants_to_two_databases` (``, one container, two databases).' + - 'Book `part6/ch30-07-audit-tenancy.md` gains sections "Resolving the tenant" and "One database per tenant"; the 0.9 sentence "Per-tenant connection routing is not part of 0.9" is replaced.' + - 'The bridge from `TenantId` to `id_effect_sql::CurrentTenant` lives in kit feature `tenancy` (r13-w5-kit-template), so sql and authz gain no dependency on each other.' + - 'Tier: integration (authz, sql). Coverage: `cargo llvm-cov nextest -p id_effect_authz --all-features --summary-only --fail-under-regions 96` and the same for id_effect_sql with `--features pg,sqlite,repository`.' + - 'Gate: `cargo nextest run -p id_effect_authz --all-features --test tenant_resolver`; `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p id_effect_sql --features pg --test tenant_routing`; `bash scripts/check-dangerous.sh`' + - 'Owns: `crates/id_effect_authz/src/tenant.rs`, `crates/id_effect_authz/tests/tenant_resolver.rs`, `crates/id_effect_sql/src/tenant_routing.rs`, `crates/id_effect_sql/tests/tenant_routing.rs`, `crates/id_effect/book/src/part6/ch30-07-audit-tenancy.md` (new sections)' +--- + +# r13-e5-5-tenancy + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "E5.5 — Tenancy helpers". The release spec's row +is binding; this file exists so Maestro can track the leaf as its own task. + +Wave 2. Blocked by: r13-e3-3-relations (sql `lib.rs` is append-only after it). Blocks +r13-w5-kit-template. Fourth in the scope-cut order only for its #8 part (the resolver is never cut). diff --git a/.maestro/specs/releases/0.13/r13-e9-1-embeddings.md b/.maestro/specs/releases/0.13/r13-e9-1-embeddings.md new file mode 100644 index 000000000..8b44975cc --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-e9-1-embeddings.md @@ -0,0 +1,25 @@ +--- +title: 'E9.1 — Embeddings across providers (OpenAI, Ollama): r13-e9-1-embeddings' +slug: r13-e9-1-embeddings +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - 'ai.llm #5: `Embedder` trait with OpenAI and Ollama adapters returning the same `Embedding` shape; batch size cap with automatic splitting; dimension check (mismatch is a typed error); cosine and dot-product helpers; retries, spans and usage telemetry as for chat (#7).' + - 'Secure by default: the platform client (SSRF, timeouts, caps; R6 R4) and `Secret` keys (R2) from r13-e9-1-promote apply; input text is never logged (R3).' + - 'Tests `embeddings_openai_and_ollama_same_shape` (wiremock), `embedding_batch_over_cap_split`, `embedding_dimension_mismatch_is_typed_error`, `embedding_input_not_logged`; `ScriptedModel` gains deterministic embeddings for downstream tests.' + - 'Book `part6/ch35-02-embeddings.md` (no vector database: in-app similarity over a bounded set, the Pipeline pattern).' + - 'Tier: integration. Coverage: `cargo llvm-cov nextest -p id_effect_ai --all-features --summary-only --fail-under-regions 96`.' + - 'Gate: `cargo nextest run -p id_effect_ai --features embeddings,openai,ollama,test-util`' + - 'Owns: `crates/id_effect_ai/src/embeddings/**`, `crates/id_effect_ai/src/vendors/{openai,ollama}/embed.rs`, `crates/id_effect_ai/tests/embeddings.rs`, `crates/id_effect/book/src/part6/ch35-02-embeddings.md`' +--- + +# r13-e9-1-embeddings + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "E9.1 — AI". The release spec's row is binding; +this file exists so Maestro can track the leaf as its own task. + +Wave 2. Blocked by: r13-e9-1-promote. Runs beside r13-e9-1-tools. `ScriptedModel` lives in +`src/testing.rs` (promote); this leaf adds the embedding method there as an append-only hunk, rebased +after tools if both touch it. diff --git a/.maestro/specs/releases/0.13/r13-e9-1-mcp.md b/.maestro/specs/releases/0.13/r13-e9-1-mcp.md new file mode 100644 index 000000000..441d4f2f9 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-e9-1-mcp.md @@ -0,0 +1,26 @@ +--- +title: 'E9.1 — MCP server and client (tools, resources, prompts; stdio and Streamable HTTP): r13-e9-1-mcp' +slug: r13-e9-1-mcp +mode: heavy +work_type: spec-slice +risk_class: high +version: 1 +acceptance_criteria: + - 'ai.llm #6 (D7): an effect-native MCP server and client pinned to one specification revision (the newest stable at leaf start, recorded in ADR 0015 and the module docs; 2025-06-18 is the floor): initialize and capability negotiation, tools (served from the same `ToolSet` the agent loop uses), resources, prompts, cancellation and progress; transports stdio and Streamable HTTP (an axum route through id_effect_axum, mounted with `.guard(g)`).' + - 'Interop: `rmcp` is a dev-dependency only (exact pin); tests run the rmcp client against our server and our client against an rmcp server.' + - 'Secure by default (ADR 0013): the HTTP endpoint needs an access decision (R1; compile-fail case added by r13-e-fuzz-security), checks `Origin` against an allowlist (empty refuses browsers; DNS-rebinding defence, R9), binds 128-bit random session ids to the principal (R1), caps a message at 1 MiB and in-flight requests per session (R4), and is closed in prod unless guarded (R7, posture probe in r13-w5-kit-template); the client uses the platform client, so the SSRF policy applies (R6); tool write policy from r13-e9-1-tools applies to MCP-exposed tools, and granting writes to an MCP-exposed set records `dangerous_mcp_write_tools` (R10).' + - 'Tests `mcp_tools_round_trip_stdio`, `mcp_streamable_http_round_trip`, `mcp_resources_and_prompts`, `mcp_cancellation_interrupts_tool_fiber`, `mcp_interop_rmcp_client_against_our_server`, `mcp_interop_our_client_against_rmcp_server`, `r9_mcp_foreign_origin_refused`, `mcp_session_bound_to_principal`, `r4_mcp_message_over_cap_rejected`, `mcp_client_obeys_ssrf_policy`, `dangerous_mcp_write_tools_is_recorded`; `mcp::decode_message` public for the fuzz target.' + - 'Examples `mcp_server.rs` and `mcp_client.rs` replace the toy `examples/mcp_server_template.rs`; book `part6/ch35-03-mcp.md`.' + - 'Tier: integration. Coverage: `cargo llvm-cov nextest -p id_effect_ai --all-features --summary-only --fail-under-regions 96`.' + - 'Gate: `cargo nextest run -p id_effect_ai --features mcp,mcp-http,tools,test-util`; `cargo run -p id_effect_ai --features mcp,mcp-http --example mcp_server -- --self-test`; `bash scripts/check-dangerous.sh`' + - 'Owns: `crates/id_effect_ai/src/mcp/**`, `crates/id_effect_ai/examples/{mcp_server.rs,mcp_client.rs}` (and deletion of `examples/mcp_server_template.rs`), `crates/id_effect_ai/tests/{mcp_stdio.rs,mcp_http.rs,mcp_interop.rs}`, `crates/id_effect/book/src/part6/ch35-03-mcp.md`' +--- + +# r13-e9-1-mcp + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "E9.1 — AI". The release spec's row is binding; +this file exists so Maestro can track the leaf as its own task. + +Wave 3 (AI branch, one wave of slack). Blocked by: r13-e9-1-tools. Blocks r13-e-fuzz-security and +r13-w5-kit-template. If the owner answers Open question 4 with "wrap rmcp", this leaf keeps its tests +and security rows and swaps the protocol core for an rmcp adapter. diff --git a/.maestro/specs/releases/0.13/r13-e9-1-promote.md b/.maestro/specs/releases/0.13/r13-e9-1-promote.md new file mode 100644 index 000000000..55b61fcdc --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-e9-1-promote.md @@ -0,0 +1,25 @@ +--- +title: 'E9.1 — Promote id_effect_ai to integration: provider-neutral chat, streaming, safe defaults: r13-e9-1-promote' +slug: r13-e9-1-promote +mode: heavy +work_type: spec-slice +risk_class: high +version: 1 +acceptance_criteria: + - 'crates/id_effect_ai (moved by r13-t0-skeletons) reaches the integration bar: `publish` removed, entry removed from `PENDING_PROMOTION`, tier integration in its manifest; every 0.13 feature and optional dependency declared once in Cargo.toml (`openai`, `anthropic`, `ollama`, `cursor` (non-default, D6), `tools`, `embeddings`, `mcp`, `mcp-http`, `test-util`) so waves 2–3 never edit the manifest.' + - 'Module layout fixed for later leaves: `chat`, `stream`, `vendors/{openai,anthropic,ollama}/{mod,chat,embed}.rs`, empty `tools/`, `embeddings/`, `mcp/` modules, `testing` (`ScriptedModel`: deterministic replies, tool calls and streams for tests, D8); ai.llm #1 (OpenAI + Anthropic chat) and #2 (streaming) and #7 (retries + spans + usage) kept and re-evidenced.' + - 'Secure by default (ADR 0013): every HTTP call goes through `id_effect_platform`''s client, so the 0.11 SSRF policy, timeouts and body caps apply (R6, R4); API keys are `id_effect_config::Secret` (R2); prompts and completions are never logged or put in span attributes by default, `dangerous_log_prompts()` records itself via `posture::record_dangerous` (R3, R10); `max_tokens` has a finite default and a stream has a byte and event cap (R4).' + - 'Tests `ai_api_key_redacted_in_debug`, `ai_prompts_not_logged_by_default`, `dangerous_log_prompts_is_recorded`, `ai_base_url_obeys_ssrf_policy`, `r4_ai_max_tokens_finite_by_default`, `r4_ai_stream_over_cap_closed`, `scripted_model_replays_in_order`, plus the existing openai/anthropic wiremock, streaming and cursor fixture tests; `stream::decode_sse_event` is public for the fuzz target.' + - 'Book `part6/ch35-00-ai.md` rewritten from labs wording to the published crate (chat, streaming, testing with `ScriptedModel`, the safety defaults); snippets compiled by book-snippets.' + - 'Tier: integration (ADR 0010 Amendment 9; moves to platform in r13-w6-pipeline). Coverage: `cargo llvm-cov nextest -p id_effect_ai --all-features --summary-only --fail-under-regions 96`.' + - 'Gate: `bash scripts/tier-conformance.sh id_effect_ai`; `cargo nextest run -p id_effect_ai --all-features`; `cargo test -p id_effect_ai --doc`; `bash scripts/check-dangerous.sh`' + - 'Owns: `crates/id_effect_ai/**` (every file in wave 1), `crates/id_effect/book/src/part6/ch35-00-ai.md`' +--- + +# r13-e9-1-promote + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "E9.1 — AI". The release spec's row is binding; +this file exists so Maestro can track the leaf as its own task. + +Wave 1 (critical for the AI branch). Blocked by: r13-t0-skeletons. Blocks r13-e9-1-tools and +r13-e9-1-embeddings. diff --git a/.maestro/specs/releases/0.13/r13-e9-1-tools.md b/.maestro/specs/releases/0.13/r13-e9-1-tools.md new file mode 100644 index 000000000..5127fb9ce --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-e9-1-tools.md @@ -0,0 +1,26 @@ +--- +title: 'E9.1 — Typed tool calling, structured output and a bounded agent loop: r13-e9-1-tools' +slug: r13-e9-1-tools +mode: heavy +work_type: spec-slice +risk_class: high +version: 1 +acceptance_criteria: + - 'ai.llm #3: `Tool` with `I, O: HasSchema`; parameters exported as JSON Schema through crates/id_effect/src/schema/json_schema_export.rs; OpenAI and Anthropic tool-call formats both supported; `ToolSet` groups tools for a call.' + - 'ai.llm #4 (margin): structured output decoded through `Schema` (refinements run); a decode failure is a typed error with at most one bounded repair retry.' + - 'ai.llm #8 (margin): `ToolLoop` runs model → tool → model with the D9 budgets (8 steps, 32 tool calls, 60 s, tool output 64 KiB; each configurable, none infinite).' + - 'Secure by default (D9, ADR 0013 R1 R4): only tools registered on the call''s `ToolSet` run (an unknown name is a typed refusal returned to the model, never executed); arguments are decoded through the tool''s Schema before the handler; tools declared `effects = write` need `ToolPolicy::allow_writes`; budgets enforced. Tests `ai_unregistered_tool_call_refused`, `ai_tool_arguments_schema_decoded`, `ai_write_tool_denied_without_policy`, `ai_tool_loop_step_budget_enforced`.' + - 'Functional tests `tool_schema_matches_has_schema`, `structured_output_runs_refinements`, `structured_output_repair_retry_bounded`, `tool_loop_round_trip_openai_and_anthropic` (wiremock), `tool_loop_with_scripted_model`; `tools::decode_arguments` is public for the fuzz target.' + - 'Book `part6/ch35-01-tools-structured-output.md` (tools, structured output, the loop, the safety rule and its residual risk).' + - 'Tier: integration. Coverage: `cargo llvm-cov nextest -p id_effect_ai --all-features --summary-only --fail-under-regions 96`; new code ≥ 96 % regions.' + - 'Gate: `cargo nextest run -p id_effect_ai --features tools,openai,anthropic,test-util`' + - 'Owns: `crates/id_effect_ai/src/{tools/**,structured.rs,agent.rs}`, `crates/id_effect_ai/src/vendors/{openai,anthropic}/chat.rs` (tool-call fields), `crates/id_effect_ai/tests/{tools.rs,structured.rs,agent_loop.rs}`, `crates/id_effect/book/src/part6/ch35-01-tools-structured-output.md`' +--- + +# r13-e9-1-tools + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "E9.1 — AI". The release spec's row is binding; +this file exists so Maestro can track the leaf as its own task. + +Wave 2. Blocked by: r13-e9-1-promote. Runs beside r13-e9-1-embeddings (disjoint: `embed.rs` vs +`chat.rs`). Blocks r13-e9-1-mcp and r13-e-fuzz-security. diff --git a/.maestro/specs/releases/0.13/r13-e9-3-admin-core.md b/.maestro/specs/releases/0.13/r13-e9-3-admin-core.md new file mode 100644 index 000000000..e41ae3482 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-e9-3-admin-core.md @@ -0,0 +1,27 @@ +--- +title: 'E9.3 — id_effect_admin core: registration API, list and detail, search/filter/sort/paging, RBAC: r13-e9-3-admin-core' +slug: r13-e9-3-admin-core +mode: heavy +work_type: spec-slice +risk_class: high +version: 1 +acceptance_criteria: + - 'D2: crates/id_effect_admin (skeleton from r13-t0-skeletons) gets its full Cargo.toml (every 0.13 feature once: `i18n`, `audit`, `test-util`; deps id_effect_axum `ui`, id_effect_sql `repository`, id_effect_authz) and module layout, creating empty `edit.rs`, `forms.rs`, `bulk.rs`, `relations.rs`, `audit.rs` for wave 3.' + - 'web.admin #8: `AdminSite::new().register::(EntityAdmin::default().list(...).search(...).filter(...))` registration API; the site mounts only through `.guard(g)` (H8 typestate), never `.public()`.' + - 'web.admin #1 (read half) and #2: list and detail pages generated from `Entity::FIELDS` (r13-e3-3-relations) with text search over declared columns, typed filters, sortable columns and pagination (offset `Page` now; keyset `find_after` when r13-e3-3-pagination has merged), rendered with axum `ui` maud components and htmx partial updates; labels through id_effect_i18n when feature `i18n` is on.' + - 'web.admin #3: per-entity × action permissions `admin..` checked through id_effect_authz before any query; default deny; repositories are tenant-bound from `CurrentTenant`.' + - 'Secure by default (ADR 0013 R1, R7, R9): no public admin; every action authorized; maud escaping with no `PreEscaped` outside `ui::trusted_html` (grep test covers this crate); the posture probe "admin guarded" is added by r13-w5-kit-template. Tests `admin_denied_without_permission`, `admin_action_requires_entity_permission`, `admin_list_scoped_to_tenant`, `no_preescaped_outside_trusted_module`.' + - 'Functional tests `admin_list_search_filter_sort_page`, `admin_detail_renders_fields`, `admin_registration_is_typed`; book `part6/ch30-08-admin.md` (registration, list, permissions).' + - 'Tier: integration (new crate; moves to platform in r13-w7-pipeline-backoffice). Coverage: `cargo llvm-cov nextest -p id_effect_admin --all-features --summary-only --fail-under-regions 96`.' + - 'Gate: `cargo nextest run -p id_effect_admin --all-features`; `bash scripts/tier-conformance.sh id_effect_admin`' + - 'Owns: `crates/id_effect_admin/**` except `src/{edit,forms,bulk,relations,audit}.rs` contents (created empty here), `crates/id_effect/book/src/part6/ch30-08-admin.md`' +--- + +# r13-e9-3-admin-core + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "E9.3 + E1.3 — Admin". The release spec's row is +binding; this file exists so Maestro can track the leaf as its own task. + +Wave 2, critical path. Blocked by: r13-e9-5-views, r13-e3-3-relations (`Entity::FIELDS`), +r13-t0-skeletons. Blocks r13-e9-3-admin-edit. Named in the release spec as the leaf most likely to be +wrong: review its registration API and the use of `FieldMeta` before admin-edit starts. diff --git a/.maestro/specs/releases/0.13/r13-e9-3-admin-edit.md b/.maestro/specs/releases/0.13/r13-e9-3-admin-edit.md new file mode 100644 index 000000000..d9f34d737 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-e9-3-admin-edit.md @@ -0,0 +1,27 @@ +--- +title: 'E9.3 — Admin editing: create/update/delete, relation pickers, bulk actions, audit: r13-e9-3-admin-edit' +slug: r13-e9-3-admin-edit +mode: heavy +work_type: spec-slice +risk_class: high +version: 1 +acceptance_criteria: + - 'web.admin #1 (write half): create, edit and delete forms from `Entity::FIELDS` (editable fields only); `Schema` validation errors bound to fields through the axum `ui` form helpers, translated when feature `i18n` is on; delete asks for confirmation; writes run through the repository so lifecycle hooks (r13-e3-3-hooks) apply.' + - 'web.admin #4 (margin): belongs_to fields render a picker (search over the target entity, tenant-scoped), has_many and many_to_many render inline lists with add/remove for join-table relations.' + - 'web.admin #5 (margin): bulk actions over selected rows (delete and registered custom actions), at most 100 ids per request.' + - 'web.admin #6 (margin): every create, update, delete and bulk action is written to the 0.9 hash-chained audit log (feature `audit`) with principal, entity, id and changed fields.' + - 'Secure by default (ADR 0013 R1, R4, R9): only declared-editable fields are bound (mass-assignment guard; others ignored and logged); form body ≤ 64 KiB; masked CSRF token required on every non-GET; bulk cap; each action checks its permission. Tests `admin_form_ignores_undeclared_fields`, `r4_admin_bulk_action_over_cap_rejected`, `r4_admin_form_over_cap_413`, `r9_admin_post_without_csrf_token_refused`, `admin_change_written_to_audit_log`, `admin_fk_picker_lists_only_tenant_rows`.' + - 'Functional tests `admin_create_update_delete_round_trip`, `admin_validation_errors_rendered`, `admin_many_to_many_add_remove`, `admin_bulk_custom_action`; example `admin_site.rs` with `--self-test`; `forms::decode_form` public for the fuzz target `admin_form_body`; book `part6/ch30-08-admin.md` gains editing, relations, bulk and audit sections.' + - 'Tier: integration. Coverage: `cargo llvm-cov nextest -p id_effect_admin --all-features --summary-only --fail-under-regions 96`.' + - 'Gate: `cargo nextest run -p id_effect_admin --all-features`; `cargo run -p id_effect_admin --all-features --example admin_site -- --self-test`' + - 'Owns: `crates/id_effect_admin/src/{edit.rs,forms.rs,bulk.rs,relations.rs,audit.rs}`, `crates/id_effect_admin/tests/{edit.rs,bulk.rs,relations.rs}`, `crates/id_effect_admin/examples/admin_site.rs`, the editing sections of `crates/id_effect/book/src/part6/ch30-08-admin.md`' +--- + +# r13-e9-3-admin-edit + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "E9.3 + E1.3 — Admin". The release spec's row is +binding; this file exists so Maestro can track the leaf as its own task. + +Wave 3, critical path. Blocked by: r13-e9-3-admin-core, r13-e3-3-relations, r13-e3-3-pagination, +r13-e3-3-hooks (and r13-e9-4-i18n-web for translated errors). Blocks r13-e1-3-generate-admin and +r13-e-fuzz-security. diff --git a/.maestro/specs/releases/0.13/r13-e9-4-i18n-core.md b/.maestro/specs/releases/0.13/r13-e9-4-i18n-core.md new file mode 100644 index 000000000..96316ff69 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-e9-4-i18n-core.md @@ -0,0 +1,25 @@ +--- +title: 'E9.4 — id_effect_i18n core: Fluent catalogs, CLDR plurals, per-request locale: r13-e9-4-i18n-core' +slug: r13-e9-4-i18n-core +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - 'D5: crates/id_effect_i18n over Project Fluent (`fluent-bundle`, `fluent-langneg`, `unic-langid`; exact pins recorded); every 0.13 feature and optional dependency declared once in its Cargo.toml (`axum`, `maud`, `schema`), so r13-e9-4-i18n-web does not edit the manifest.' + - 'eco.i18n #1: `Catalog` of Fluent resources per locale, loaded from an allowlisted directory or embedded with `include_str!`; fallback chain (requested → language → default); a startup check reports every key missing from a non-default locale.' + - 'eco.i18n #2: CLDR plural categories through Fluent selectors; tests cover en, de, pl, ar, ja.' + - 'eco.i18n #7: `CurrentLocale` capability in the environment and `with_locale(locale, effect)` switching per request or fiber.' + - 'Secure by default (R4): resources are parsed once at startup from trusted paths only (no locale string from input ever reaches a file path); parse errors fail startup.' + - 'Tests `plural_categories_en_de_pl_ar_ja`, `missing_key_falls_back_to_default_locale`, `catalog_check_reports_missing_keys`, `with_locale_switches_per_fiber`, `catalog_rejects_path_outside_allowlist`; book `part6/ch30-09-i18n.md`.' + - 'Tier: integration (new crate, ADR 0010 Amendment 9; moves to platform in r13-w6-pipeline). Coverage: `cargo llvm-cov nextest -p id_effect_i18n --all-features --summary-only --fail-under-regions 96`.' + - 'Gate: `cargo nextest run -p id_effect_i18n`; `bash scripts/tier-conformance.sh id_effect_i18n`; `cargo deny check`' + - 'Owns: `crates/id_effect_i18n/**` except `src/{negotiate,axum,schema_messages,maud}.rs` and their tests, `crates/id_effect/book/src/part6/ch30-09-i18n.md`' +--- + +# r13-e9-4-i18n-core + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "E9.4 — i18n". The release spec's row is binding; +this file exists so Maestro can track the leaf as its own task. + +Wave 1. Blocked by: r13-t0-skeletons. Blocks r13-e9-4-i18n-web and r13-e-benches. diff --git a/.maestro/specs/releases/0.13/r13-e9-4-i18n-web.md b/.maestro/specs/releases/0.13/r13-e9-4-i18n-web.md new file mode 100644 index 000000000..27720fad3 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-e9-4-i18n-web.md @@ -0,0 +1,26 @@ +--- +title: 'E9.4 — i18n on the web: Accept-Language negotiation, translated validation, i18n in views: r13-e9-4-i18n-web' +slug: r13-e9-4-i18n-web +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - 'eco.i18n #3: Accept-Language negotiation (fluent-langneg) against the supported-locale allowlist, with a cookie or user-preference override; an axum layer (feature `axum`) puts `CurrentLocale` in the request environment.' + - 'eco.i18n #5: translated `Schema` validation messages: the error codes of crates/id_effect/src/schema/parse_errors.rs map to message ids; en and de catalogs shipped with the crate (feature `schema`).' + - 'web.ui #8: feature `maud` implements `maud::Render` for messages, so views call `t(&locale, "id", args)` directly.' + - 'Secure by default (ADR 0013 R4, R9): the header parse is bounded (≤ 32 ranges, ≤ 256 bytes; beyond that the header is ignored); a locale outside the allowlist falls back to the default; message arguments are HTML-escaped by maud and Fluent bidi isolation stays on. Tests `r4_accept_language_over_limit_ignored`, `locale_outside_allowlist_falls_back`, `i18n_args_escaped_in_html`, `i18n_bidi_isolation_on_by_default`.' + - 'Functional tests `negotiates_best_supported_locale`, `cookie_override_beats_header`, `validation_errors_translated_de`; example `i18n_web.rs` with `--self-test`; `negotiate::parse_accept_language` public for the fuzz target.' + - 'Book `part6/ch30-09-i18n.md` gains the web sections (append to the core chapter after r13-e9-4-i18n-core merged).' + - 'Tier: integration. Coverage: `cargo llvm-cov nextest -p id_effect_i18n --all-features --summary-only --fail-under-regions 96`.' + - 'Gate: `cargo nextest run -p id_effect_i18n --all-features`; `cargo run -p id_effect_i18n --all-features --example i18n_web -- --self-test`' + - 'Owns: `crates/id_effect_i18n/src/{negotiate.rs,axum.rs,schema_messages.rs,maud.rs}`, `crates/id_effect_i18n/locales/schema/**`, `crates/id_effect_i18n/tests/{negotiate.rs,web.rs,schema_messages.rs}`, `crates/id_effect_i18n/examples/i18n_web.rs`, the web sections of `crates/id_effect/book/src/part6/ch30-09-i18n.md`' +--- + +# r13-e9-4-i18n-web + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "E9.4 — i18n". The release spec's row is binding; +this file exists so Maestro can track the leaf as its own task. + +Wave 2. Blocked by: r13-e9-4-i18n-core (and r13-e9-5-views for the shared maud version). Blocks +r13-e9-3-admin-edit (translated form errors, optional), r13-e-fuzz-security, r13-w5-kit-template. diff --git a/.maestro/specs/releases/0.13/r13-e9-5-leptos.md b/.maestro/specs/releases/0.13/r13-e9-5-leptos.md new file mode 100644 index 000000000..d0fd610bc --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-e9-5-leptos.md @@ -0,0 +1,23 @@ +--- +title: 'E9.5 — Leptos SSR + hydration recipe on the id_effect host (not scored): r13-e9-5-leptos' +slug: r13-e9-5-leptos +mode: heavy +work_type: spec-slice +risk_class: low +version: 1 +acceptance_criteria: + - 'D4: examples/recipes/leptos-ssr is a standalone crate outside the workspace (root `exclude` from r13-t0-skeletons, own Cargo.lock): Leptos (exact pin) server-side rendering with hydration, served by the id_effect axum host, calling an id_effect RPC service through the generated Rust client; no npm, no node_modules (the wasm target and the build tool used are recorded in its README).' + - 'Secure by default: served through the host typed routes (`.public()` for pages, guarded RPC), the 0.11 security headers on; no `dangerous_*` opt-out.' + - 'Tests `leptos_recipe_ssr_renders`, `leptos_recipe_calls_rpc`; CI job `recipe-leptos` (cached, not in `ci-pass` until green twice).' + - 'Book `part6/ch29-11-leptos-recipe.md`. web.ui #7 is not claimed (the recipe is outside the published crates).' + - 'Tier: example (outside the workspace; no coverage gate, as for examples).' + - 'Gate: `cargo nextest run --manifest-path examples/recipes/leptos-ssr/Cargo.toml`; CI job `recipe-leptos`' + - 'Owns: `examples/recipes/leptos-ssr/**`, `.github/workflows/ci.yml` (job `recipe-leptos` only), `crates/id_effect/book/src/part6/ch29-11-leptos-recipe.md`' +--- + +# r13-e9-5-leptos + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "E9.5 — Server-rendered UI". The release spec's +row is binding; this file exists so Maestro can track the leaf as its own task. + +Wave 3. Blocked by: r13-t0-skeletons (root `exclude`). First in the scope-cut order; moves no row. diff --git a/.maestro/specs/releases/0.13/r13-e9-5-views.md b/.maestro/specs/releases/0.13/r13-e9-5-views.md new file mode 100644 index 000000000..934771433 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-e9-5-views.md @@ -0,0 +1,25 @@ +--- +title: 'E9.5 — Server-rendered UI in id_effect_axum: maud views, components, layouts, htmx, forms: r13-e9-5-views' +slug: r13-e9-5-views +mode: heavy +work_type: spec-slice +risk_class: high +version: 1 +acceptance_criteria: + - 'D3: id_effect_axum feature `ui` (declared once in crates/id_effect_axum/Cargo.toml with the exact-pinned maud dependency); web.ui #1 maud `Markup` is a response type on typed routes; #2 `Component` (props struct + slot children, composable); #6 `Layout` and partials, the full page or the fragment chosen by the `HX-Request` header.' + - 'htmx helpers: `HxRequest` / `HxTarget` extractors, `HxTrigger`, `HxRedirect` (same-origin only), `HxReswap`; htmx 2.0.x vendored once under crates/id_effect_axum/assets/ with its sha384 and 0BSD license in assets/README.md, served same-origin by `ui::htmx::asset_route()` with CSP `script-src ''self''`, `htmx.config.allowEval = false` and `selfRequestsOnly = true` set by the layout.' + - 'web.ui #5 (margin): form helpers bound to `Schema` decode errors (field → message), re-rendering the form with values and errors; translatable message keys (i18n renders them in r13-e9-4-i18n-web).' + - 'Secure by default (ADR 0013 R9): maud escapes all interpolation; raw HTML only through `ui::trusted_html` (a reviewed allowlist), checked by a grep test over crates/id_effect_axum/src and crates/id_effect_admin/src; htmx requests carry the 0.11 masked CSRF token header; `HxRedirect` refuses another origin. Tests `ui_escapes_untrusted_by_default`, `no_preescaped_outside_trusted_module`, `r9_htmx_requests_carry_masked_token`, `hx_redirect_refuses_foreign_origin`, `htmx_asset_hash_matches_readme`.' + - 'Functional tests `markup_route_renders_escaped`, `component_slots_render_in_order`, `layout_wraps_page_not_fragment`, `form_errors_bound_to_fields`; example `095_htmx_todo.rs` with `--self-test`; book `part6/ch29-10-server-rendered-ui.md`.' + - 'Tier: integration (id_effect_axum, no new crate). Coverage: `cargo llvm-cov nextest -p id_effect_axum --features ui --summary-only --fail-under-regions 96`.' + - 'Gate: `cargo nextest run -p id_effect_axum --features ui`; `cargo run -p id_effect_axum --features ui --example 095_htmx_todo -- --self-test`; `cargo deny check`' + - 'Owns: `crates/id_effect_axum/Cargo.toml` (feature `ui` and maud), `crates/id_effect_axum/src/ui/**`, `crates/id_effect_axum/assets/{htmx-*.min.js,README.md}`, `crates/id_effect_axum/tests/ui.rs`, `crates/id_effect_axum/examples/095_htmx_todo.rs`, `crates/id_effect/book/src/part6/ch29-10-server-rendered-ui.md`' +--- + +# r13-e9-5-views + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "E9.5 — Server-rendered UI". The release spec's +row is binding; this file exists so Maestro can track the leaf as its own task. + +Wave 1, critical path (head of the admin chain). Blocked by: r13-t0-skeletons and r13-f-rcgen (axum +`Cargo.toml`). Blocks r13-e9-3-admin-core, r13-e-benches. diff --git a/.maestro/specs/releases/0.13/r13-f-any-env.md b/.maestro/specs/releases/0.13/r13-f-any-env.md new file mode 100644 index 000000000..1122c3139 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-f-any-env.md @@ -0,0 +1,22 @@ +--- +title: 'T12 carry (F52) — Effect::any_env, run an env-free effect under any environment: r13-f-any-env' +slug: r13-f-any-env +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - 'F52 / D14: `impl Effect { pub fn any_env(self) -> Effect }` in crates/id_effect/src/kernel/effect.rs beside `widen` (`:237`), and the same on `LocalEffect` in kernel/local.rs; no extra allocation beyond the existing representation; rustdoc example lifts an env-free effect into a procedure DynEnv effect (the 0.12 Ledger pattern `Effect::from_ctx(|ctx| async move { ctx.bind(e).await })` becomes one call).' + - 'Tests `any_env_runs_under_unit_and_dyn_env`, `any_env_preserves_interruption_and_finalizers`, `local_any_env_round_trip`; one book section on effect environments (part2) compiled by book-snippets.' + - 'Tier: core (strict bar: no new dependency, no unsafe, docs on every item). Secure by default: no new surface.' + - 'Coverage: `cargo llvm-cov nextest -p id_effect --summary-only --fail-under-regions 96`; new code 100 % regions (two small methods).' + - 'Gate: `cargo nextest run -p id_effect`; `cargo test -p id_effect --doc any_env`; `cargo test -p book-snippets`' + - 'Owns: `crates/id_effect/src/kernel/{effect.rs,local.rs}` (the `any_env` methods only), `crates/id_effect/tests/any_env.rs`, one section of the part2 effect-environment chapter under `crates/id_effect/book/src/part2/`' +--- + +# r13-f-any-env + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "T12 carry" (0.12 overlay F52, prerequisite of +F27). The release spec's row is binding; this file exists so Maestro can track the leaf as its own task. + +Wave 0 head. Blocked by: the v0.12.0 tag. Blocks r13-f-client-batching. diff --git a/.maestro/specs/releases/0.13/r13-f-client-batching.md b/.maestro/specs/releases/0.13/r13-f-client-batching.md new file mode 100644 index 000000000..f6cbc3627 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-f-client-batching.md @@ -0,0 +1,25 @@ +--- +title: 'T12 carry (F27, F56) — generated Rust clients batch same-tick calls and attach headers: r13-f-client-batching' +slug: r13-f-client-batching +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - 'F27: apply `.maestro/specs/releases/0.13/f27-client-batching.patch` (written against a344bd9e; touches CHANGELOG.md, crates/id_effect_rpc/src/client/http.rs, crates/id_effect_rpc/src/service.rs, crates/id_effect_rpc/tests/service_macro.rs), then shrink it with `Effect::any_env` (r13-f-any-env): the per-caller-env batch resolver table goes away; calls a `#[rpc::service]` client makes over `HttpTransport` in one tick go out as one batch (≤ 50 calls / 1 MiB, same rules as `RpcClient::call`), each with its service version; a lone call is a plain envelope.' + - 'F56: `HttpTransport::with_header(name, value)` and `with_bearer(secret)`; names and values validated (typed error otherwise); `x-rpc` and `content-type` are reserved and cannot be overridden; bearer tokens are `Secret` and redacted in `Debug`.' + - 'Secure by default (ADR 0013 R2, R4, R9): header validation and the reserved-header rule keep the 0.12 CSRF rule (`x-rpc: 1` on every POST) intact. Tests `http_transport_rejects_invalid_header`, `http_transport_cannot_override_rpc_header`, `http_transport_bearer_redacted_in_debug`.' + - 'Functional tests `generated_client_batches_same_tick_calls`, `generated_client_lone_call_is_plain_envelope`, `http_transport_attaches_bearer`; existing batching and service_macro tests stay green.' + - 'Tier: integration (id_effect_rpc, platform tier since 0.12). Coverage: `cargo llvm-cov nextest -p id_effect_rpc --all-features --summary-only --fail-under-regions 96`; new code ≥ 96 % regions.' + - 'Gate: `cargo nextest run -p id_effect_rpc --all-features`; `cargo nextest run -p id_effect_rpc`; `cargo clippy -p id_effect_rpc --no-default-features --features client -- -D warnings`' + - 'Owns: `crates/id_effect_rpc/src/{service.rs,client/http.rs}`, `crates/id_effect_rpc/tests/service_macro.rs` (new tests only), its CHANGELOG hunk' +--- + +# r13-f-client-batching + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "T12 carry" (0.12 overlay F27 and F56, from the +blocked 0.12 leaf r12-f-client-batching). The release spec's row is binding; this file exists so +Maestro can track the leaf as its own task. + +Wave 1. Blocked by: r13-f-any-env. Blocks r13-f-relay-host. If the patch no longer applies on the +v0.12.0 tag, re-derive it from the 0.12 leaf spec `.maestro/specs/releases/0.12/r12-f-client-batching.md`. diff --git a/.maestro/specs/releases/0.13/r13-f-eventlog-head.md b/.maestro/specs/releases/0.13/r13-f-eventlog-head.md new file mode 100644 index 000000000..c3a335f1f --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-f-eventlog-head.md @@ -0,0 +1,23 @@ +--- +title: 'T12 carry (F21) — EventLog head read and subscriptions "from now": r13-f-eventlog-head' +slug: r13-f-eventlog-head +mode: heavy +work_type: spec-slice +risk_class: low +version: 1 +acceptance_criteria: + - 'F21: `EventLog::head(&self) -> Effect` in crates/id_effect_events/src/subscription.rs (trait at `:42`) as a provided method (pages `read_all` to the end, so existing implementors compile), overridden in O(1) by the SQL journal (sql_journal.rs) and the in-memory store (event_store.rs).' + - 'crates/id_effect_rpc/src/subscription/events.rs gains `Subscription::from_now()` starting at `head`; no cursor still means "from `starting_after`" (unchanged default), now documented in book ch33-08.' + - 'Tests `head_is_last_global_position` (memory and ``), `head_default_impl_matches_override`, `subscription_from_now_skips_history`.' + - 'Tier: integration (id_effect_events, id_effect_rpc). Secure by default: no new surface.' + - 'Coverage: `cargo llvm-cov nextest -p id_effect_events --all-features --summary-only --fail-under-regions 96` and the same for id_effect_rpc.' + - 'Gate: `cargo nextest run -p id_effect_events -p id_effect_rpc --features events`; `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p id_effect_events --test head`' + - 'Owns: `crates/id_effect_events/src/{subscription.rs,sql_journal.rs,event_store.rs}` (the `head` method), `crates/id_effect_rpc/src/subscription/events.rs`, `crates/id_effect_events/tests/head.rs`, one paragraph of `crates/id_effect/book/src/part6/ch33-08-subscriptions.md`' +--- + +# r13-f-eventlog-head + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "T12 carry" (0.12 overlay F21). The release spec's +row is binding; this file exists so Maestro can track the leaf as its own task. + +Wave 1. Blocked by: the v0.12.0 tag. Fills a free slot; nothing depends on it. diff --git a/.maestro/specs/releases/0.13/r13-f-ext-connect.md b/.maestro/specs/releases/0.13/r13-f-ext-connect.md new file mode 100644 index 000000000..3f42366fe --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-f-ext-connect.md @@ -0,0 +1,23 @@ +--- +title: 'T12 carry (F15) — RFC 8441 extended CONNECT: WebSockets over TLS HTTP/2: r13-f-ext-connect' +slug: r13-f-ext-connect +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - 'F15: the TLS HTTP/2 server (crates/id_effect_axum/src/server/{connection,tls}.rs) enables the extended CONNECT protocol (SETTINGS_ENABLE_CONNECT_PROTOCOL); realtime/ws.rs accepts the h2 `CONNECT :protocol websocket` upgrade alongside the HTTP/1.1 upgrade, on the same typed routes.' + - 'Secure by default (ADR 0013 R9, R4): the Origin allowlist, auth on connect and every 0.12 WebSocket limit (frame size, message and control rates, idle and heartbeat) apply to h2 WebSockets exactly as to h1; the 0.12 h2 concurrent-stream cap still bounds them. Tests `r9_h2_extended_connect_foreign_origin_refused`, `h2_websocket_limits_match_h1`.' + - 'Functional tests `websocket_over_h2_round_trip`, `h1_websocket_unchanged`; the RPC WebSocket transport works over h2 (`rpc_over_h2_websocket`).' + - 'Tier: integration. Coverage: `cargo llvm-cov nextest -p id_effect_axum --features tls,ws --summary-only --fail-under-regions 96`.' + - 'Gate: `cargo nextest run -p id_effect_axum --features tls,ws`; `cargo nextest run -p id_effect_rpc --features ws`' + - 'Owns: `crates/id_effect_axum/src/server/{connection.rs,tls.rs}` (h2 builder), `crates/id_effect_axum/src/realtime/ws.rs` (upgrade path only), `crates/id_effect_axum/tests/h2_websocket.rs`' +--- + +# r13-f-ext-connect + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "T12 carry" (0.12 overlay F15). The release spec's +row is binding; this file exists so Maestro can track the leaf as its own task. + +Wave 4. Blocked by: r13-f-ws-credit (realtime/ws.rs) and r13-f-rcgen (connection.rs, tls.rs). Runs +beside r13-f-webtransport-host (different server files). In the scope-cut order (moves no row). diff --git a/.maestro/specs/releases/0.13/r13-f-h3-settings.md b/.maestro/specs/releases/0.13/r13-f-h3-settings.md new file mode 100644 index 000000000..239abf126 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-f-h3-settings.md @@ -0,0 +1,25 @@ +--- +title: 'T12 carry (F25, F36) — real 0-RTT setting per route and a congestion-control switch for HTTP/3: r13-f-h3-settings' +slug: r13-f-h3-settings +mode: heavy +work_type: spec-slice +risk_class: high +version: 1 +acceptance_criteria: + - 'F25: the platform QUIC endpoint gains a per-route early-data setting (crates/id_effect_platform/src/quic/{config,router}.rs; early data is off at config.rs:220 today); the `h3` route can admit 0-RTT, the `idm/1` mesh route never can; `Http3Options::dangerous_allow_zero_rtt_for_queries` (crates/id_effect_axum/src/server/h3.rs:42) stops being a recorded no-op: it enables early data on the h3 route and admits only safe methods (GET/HEAD and RPC `query` procedures).' + - 'F36: `Http3Options::congestion(CongestionControl::{Bbr, Cubic})` over `Route::congestion_control` (quic/router.rs:79, congestion.rs); default stays BBR on h3 (0.12 F22), mesh stays Cubic; config key `server.http3.congestion`.' + - 'Secure by default (ADR 0013 R10, R1): 0-RTT off by default; the opt-out is recorded; a mutation in early data is refused with 425 Too Early. Tests `zero_rtt_off_by_default`, `zero_rtt_admits_only_safe_methods`, `zero_rtt_mutation_gets_425`, `mesh_route_never_accepts_early_data`; the 0.12 compile-fail case r10_zero_rtt_on_mutation still passes.' + - 'Functional test `http3_congestion_switch_to_cubic`; the 0.12 h3 tests (`h3_serves_typed_routes_unchanged`, `alt_svc_header_on_tcp_responses`) unchanged.' + - 'Tier: integration (platform, axum). Coverage: `cargo llvm-cov nextest -p id_effect_platform --features quic,http3 --summary-only --fail-under-regions 96` and the same for id_effect_axum with `--features http3`.' + - 'Gate: `cargo nextest run -p id_effect_platform -p id_effect_axum --features http3`; `cargo nextest run -p security_compile_fail`; `bash scripts/check-dangerous.sh`' + - 'Owns: `crates/id_effect_platform/src/quic/{config.rs,router.rs,congestion.rs}`, `crates/id_effect_axum/src/server/h3.rs`, `crates/id_effect_platform/tests/quic_early_data.rs`, `crates/id_effect_axum/tests/http3_settings.rs`' +--- + +# r13-f-h3-settings + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "T12 carry" (0.12 overlay F25 and F36). The release +spec's row is binding; this file exists so Maestro can track the leaf as its own task. + +Wave 3. Blocked by: r13-f-rcgen (platform/axum TLS call sites settled). Blocks r13-f-webtransport-host. +F36 is built even if BBR passed the 0.12 netem gate: quinn marks BBR experimental and operators need a +switch without code. diff --git a/.maestro/specs/releases/0.13/r13-f-mesh-session.md b/.maestro/specs/releases/0.13/r13-f-mesh-session.md new file mode 100644 index 000000000..1d316d826 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-f-mesh-session.md @@ -0,0 +1,23 @@ +--- +title: 'T12 carry (F30, F34) — node session pump; mesh RPC trace context and liveness: r13-f-mesh-session' +slug: r13-f-mesh-session +mode: heavy +work_type: spec-slice +risk_class: high +version: 1 +acceptance_criteria: + - 'F30: crates/id_effect_node gains a `SessionPump` (src/session_pump.rs, over `Session` at src/session.rs:27) that runs a session''s ingress through the mesh''s verified path, with no `Mesh::handle_envelope_from` bypass (mesh.rs:751; the 0.12 F33 failure mode); `channels::attach_session` / `attach_session_rpc` (crates/id_effect_rpc/src/channels/backplane.rs:708,722) use it instead of running a private mesh.' + - 'F34: `MeshTransport` (crates/id_effect_rpc/src/client/mesh.rs) propagates trace context like the 0.11 dist.remote#8 path; mesh streams send heartbeats and fail with a typed `PeerSilent` error when the peer is silent past a finite deadline (default 3 heartbeat intervals), not only on `peer_down`; client/mesh.rs reaches ≥ 96 % regions.' + - 'Secure by default (ADR 0013 R8, R1): only verified envelopes are delivered (unverified dropped and counted); heartbeat interval and deadline finite. Tests `session_pump_delivers_verified_envelopes_only`, `session_pump_drops_unverified_envelope`.' + - 'Functional tests `channels_over_node_session_pump`, `mesh_transport_carries_trace_context`, `mesh_stream_detects_silent_peer` (TestClock), `mesh_endpoint_restart_is_transparent_to_callers` (0.12, still green).' + - 'Tier: runtime (id_effect_node, strict bar) and integration (id_effect_rpc). Coverage: `cargo llvm-cov nextest -p id_effect_node --all-features --summary-only --fail-under-regions 96` and the same for id_effect_rpc with `--features mesh,channels`.' + - 'Gate: `cargo nextest run -p id_effect_node --all-features`; `cargo nextest run -p id_effect_rpc --features mesh,channels`' + - 'Owns: `crates/id_effect_node/src/{session.rs,session_pump.rs}`, `crates/id_effect_node/tests/session_pump.rs`, `crates/id_effect_rpc/src/{channels/backplane.rs,client/mesh.rs,transport/mesh.rs}`, `crates/id_effect_rpc/tests/mesh_liveness.rs`' +--- + +# r13-f-mesh-session + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "T12 carry" (0.12 overlay F30 and F34). The release +spec's row is binding; this file exists so Maestro can track the leaf as its own task. + +Wave 3. Blocked by: the v0.12.0 tag (node lib.rs line append-only). Blocks r13-f-relay-host. diff --git a/.maestro/specs/releases/0.13/r13-f-rcgen.md b/.maestro/specs/releases/0.13/r13-f-rcgen.md new file mode 100644 index 000000000..1de848a53 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-f-rcgen.md @@ -0,0 +1,25 @@ +--- +title: 'T12 carry (F5) — one rcgen across platform, node, axum, sql, tool and kit: r13-f-rcgen' +slug: r13-f-rcgen +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - 'F5: every manifest uses rcgen 0.14.x (one version, pinned in each manifest as today): crates/id_effect_{tool,kit,axum}/Cargo.toml (`=0.13.2` today), crates/id_effect_{sql,node,platform}/Cargo.toml (`0.13` today); `cargo tree -d --workspace --all-features` shows no second rcgen (nor the untrusted/synstructure duplicates it pulled).' + - 'The 19 files that use rcgen are ported to the 0.14 API: crates/id_effect_platform/src/tls/{acme,certs}.rs, crates/id_effect_platform/src/http/h3.rs, crates/id_effect_axum/src/server/{connection,tls}.rs, crates/id_effect_tool/src/dev.rs, crates/id_effect_axum/examples/092_http3.rs, crates/id_effect_node/examples/134_mtls_cluster.rs, and the tests crates/id_effect_sql/tests/tls.rs, crates/id_effect_node/tests/{shared_endpoint,transport_tls,transport_tcp_tls,discovery_kubernetes}.rs, crates/id_effect_axum/tests/{http3,tls_h2}.rs, crates/id_effect_platform/tests/{http3_client,tls_reload,quic_endpoint}.rs, crates/id_effect_kit/tests/posture.rs.' + - 'No public signature changes, or each change is in docs/MIGRATION-0.13.md; the 0.11 and 0.12 TLS tests (mtls_rejects_peer_without_client_cert, mtls_rejects_cert_from_other_ca, rotated_cert_used_for_new_connections, reload_swaps_identity_on_endpoint_and_tcp, dev_cert_valid_for_webtransport_hashes) pass unchanged.' + - 'Tier: integration (platform, axum, sql, tool, kit) and runtime (node, strict bar). Secure by default: certificate generation keeps ECDSA P-256 and the 13-day dev lifetime; no new opt-out.' + - 'Coverage: `cargo llvm-cov nextest -p --all-features --summary-only --fail-under-regions 96` for each of the six crates.' + - 'Gate: `cargo tree -d --workspace --all-features` lists rcgen at most once; `cargo nextest run -p id_effect_platform -p id_effect_node -p id_effect_axum -p id_effect_sql -p id_effect_tool -p id_effect_kit --all-features`; `cargo deny check`' + - 'Owns: the rcgen dependency lines of the six manifests above, the 19 listed `.rs` files (rcgen call sites only), `Cargo.lock`, `deny.toml` (a skip entry for rcgen, if any)' +--- + +# r13-f-rcgen + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "T12 carry" (0.12 overlay F5). The release spec's +row is binding; this file exists so Maestro can track the leaf as its own task. + +Wave 0 head. Blocked by: the v0.12.0 tag. Must merge before any later leaf edits +`crates/id_effect_axum/src/server/{connection,tls}.rs` (r13-f-ext-connect) or the six manifests +(r13-e9-5-views takes axum `Cargo.toml` in wave 1). diff --git a/.maestro/specs/releases/0.13/r13-f-relay-host.md b/.maestro/specs/releases/0.13/r13-f-relay-host.md new file mode 100644 index 000000000..e4d46eab2 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-f-relay-host.md @@ -0,0 +1,26 @@ +--- +title: 'T12 carry (F59, F55, F56) — the Relay on the host HTTP/3 + WebTransport, kit only: r13-f-relay-host' +slug: r13-f-relay-host +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - 'F59 (0.13 part): examples/reference/relay serves HTTP/3 and WebTransport from the host entry points (r13-f-webtransport-host), with no private `serve_h3` endpoint and host h3 on.' + - 'F55: the Relay depends on `id_effect_kit` only (uses the kit `id_effect_process` re-export from r13-w5-kit-template; the direct id_effect_process dependency is removed from examples/reference/relay/Cargo.toml).' + - 'F56 / F40 / F30 adoption: tests use `HttpTransport::with_bearer` instead of the wrapped HTTP client, the Rust WebSocket client dials `wss://` (r13-f-ws-client-tls), and channels run over the node session pump (r13-f-mesh-session).' + - 'The remaining F59 items (exercise server streams, client streams, SSE, postcard, batching and WebSocket session resume in the Relay) are recorded as 0.14 E13.2 work in the leaf evidence, not done here.' + - 'Tests: every 0.12 `relay_*` test green; new `relay_uses_host_webtransport`, `relay_depends_on_kit_only` (cargo metadata check); prod posture clean with zero accepted risks.' + - 'Tier: example. Secure by default: prod posture probe set unchanged and clean; no new opt-out.' + - 'Gate: `ID_EFFECT_REQUIRE_DOCKER=1 cargo nextest run -p relay`; CI job `reference-relay`; `bash scripts/tier-conformance.sh`' + - 'Owns: `examples/reference/relay/**`, the `reference-relay` steps of `.github/workflows/ci.yml` (rebased after r13-w6-pipeline adds its job)' +--- + +# r13-f-relay-host + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "T12 carry" (0.12 overlay F59 0.13 part, F55 +Relay manifest, F56 adoption). The release spec's row is binding; this file exists so Maestro can +track the leaf as its own task. + +Wave 6. Blocked by: r13-w5-kit-template, r13-f-webtransport-host, r13-f-ws-client-tls, +r13-f-client-batching, r13-f-mesh-session, r13-f-ext-connect. Runs beside r13-w6-pipeline. diff --git a/.maestro/specs/releases/0.13/r13-f-rpc-retire.md b/.maestro/specs/releases/0.13/r13-f-rpc-retire.md new file mode 100644 index 000000000..42c46f110 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-f-rpc-retire.md @@ -0,0 +1,24 @@ +--- +title: 'T12 carry (F46) — `id-effect rpc check --allow-retired Service:N`: r13-f-rpc-retire' +slug: r13-f-rpc-retire +mode: heavy +work_type: spec-slice +risk_class: low +version: 1 +acceptance_criteria: + - 'F46: `id-effect rpc check --contract --against --allow-retired Service:N` (repeatable) lets the named removed service version pass, printing it as "retired"; every other breaking change still exits 1; an `--allow-retired` naming a version that was not removed is an error (typo guard).' + - 'The argument is parsed in crates/id_effect_tool/src/rpc/mod.rs (the rpc subcommand args) and applied in src/rpc/check.rs; cli.rs is not edited.' + - 'Tests `retired_version_allowed_with_flag`, `retired_flag_does_not_hide_other_breaks`, `retired_flag_for_unknown_version_is_error`; book ch33-12 gains one paragraph.' + - 'Tier: integration (id_effect_tool). Secure by default: no new surface (a stricter check, not a weaker one).' + - 'Coverage: `cargo llvm-cov nextest -p id_effect_tool --all-features --summary-only --fail-under-regions 96`.' + - 'Gate: `cargo nextest run -p id_effect_tool --test rpc_check`' + - 'Owns: `crates/id_effect_tool/src/rpc/{mod.rs,check.rs}`, `crates/id_effect_tool/tests/rpc_check.rs` (new cases), one paragraph of `crates/id_effect/book/src/part6/ch33-12-contract-check.md`' +--- + +# r13-f-rpc-retire + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "T12 carry" (0.12 overlay F46). The release spec's +row is binding; this file exists so Maestro can track the leaf as its own task. + +Wave 4. Blocked by: the v0.12.0 tag. Runs beside r13-e1-3-generate-admin (tool `generate.rs`, a +different file). In the scope-cut order (moves no row). diff --git a/.maestro/specs/releases/0.13/r13-f-stream-chunking.md b/.maestro/specs/releases/0.13/r13-f-stream-chunking.md new file mode 100644 index 000000000..7ffbd84c3 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-f-stream-chunking.md @@ -0,0 +1,25 @@ +--- +title: 'T12 carry (F57) — live streams deliver partial chunks instead of stalling: r13-f-stream-chunking' +slug: r13-f-stream-chunking +mode: heavy +work_type: spec-slice +risk_class: high +version: 1 +acceptance_criteria: + - 'F57: a pull on a stream whose upstream would wait returns the items already available (between 1 and the requested size, `DEFAULT_CHUNK_SIZE = 64` at crates/id_effect/src/streaming/stream.rs:28) instead of waiting for a full chunk; `run_for_each` / `run_for_each_effect` (`:728`) on a live client stream sees each item as it arrives; bounded and pure streams keep full chunks.' + - 'Change limited to the pull path (stream.rs pull and pump.rs feed); no public signature change.' + - 'Tests `live_stream_delivers_partial_chunk`, `run_for_each_live_no_stall` (TestClock), `bounded_stream_chunking_unchanged`, `unfold_effect_partial_chunks`; the 0.12 RPC client-stream test from the Relay finding passes without the workaround.' + - 'Performance: the streaming benches are within noise of docs/benchmarks/v0.12.json (no throughput regression on bounded streams).' + - 'Tier: core (strict bar). Secure by default: no new surface; backpressure semantics unchanged (a slow consumer still slows its producer).' + - 'Coverage: `cargo llvm-cov nextest -p id_effect --summary-only --fail-under-regions 96`.' + - 'Gate: `cargo nextest run -p id_effect`; `cargo nextest run -p id_effect_rpc --all-features`; `bash scripts/bench-run.sh` then `bash scripts/bench-compare.sh docs/benchmarks/v0.12.json ` (streaming group)' + - 'Owns: `crates/id_effect/src/streaming/{stream.rs,pump.rs}` (pull path), `crates/id_effect/tests/stream_live_chunks.rs`' +--- + +# r13-f-stream-chunking + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "T12 carry" (0.12 overlay F57). The release spec's +row is binding; this file exists so Maestro can track the leaf as its own task. + +Wave 1. Blocked by: the v0.12.0 tag. Blocks r13-e-benches. Core-tier change: the reviewer checks the +bench comparison before merge. diff --git a/.maestro/specs/releases/0.13/r13-f-webtransport-host.md b/.maestro/specs/releases/0.13/r13-f-webtransport-host.md new file mode 100644 index 000000000..acd0670d4 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-f-webtransport-host.md @@ -0,0 +1,24 @@ +--- +title: 'T12 carry (F43, F45) — WebTransport in the host entry points; Deno test of the TS client over WebTransport: r13-f-webtransport-host' +slug: r13-f-webtransport-host +mode: heavy +work_type: spec-slice +risk_class: high +version: 1 +acceptance_criteria: + - 'F43: `ServerHandle` / `serve_router` (crates/id_effect_axum/src/server/{bootstrap,lifecycle}.rs) serve WebTransport on the host HTTP/3 endpoint when `server.http3.webtransport = true` (default false), using the 0.12 `webtransport::quic_limits` and `Http3Options::webtransport()` path from server/webtransport.rs; apps no longer need a private `serve_h3` endpoint.' + - 'F45: crates/id_effect_tool/tests/ts/webtransport.test.ts runs the generated TypeScript client over WebTransport under the pinned Deno (flags such as `--unstable-net` recorded) against a test server with a 13-day ECDSA certificate and `serverCertificateHashes`; driven from crates/id_effect_tool/tests/rpc_gen_ts.rs. If the pinned Deno cannot open a WebTransport session, the leaf records the reason in its evidence, keeps the test `#[ignore]` with that reason, and the 0.12 Chromium browser test stays the evidence (release spec Risk 6).' + - 'Secure by default (ADR 0013 R9, R4): WebTransport off unless configured; the CONNECT Origin check and the 0.12 limits apply on the host path. Tests `host_webtransport_off_by_default`, `r9_host_webtransport_foreign_origin_refused`.' + - 'Functional tests `host_serves_webtransport_when_enabled`, `host_webtransport_resume_after_session_loss`, `generated_client_webtransport_round_trip_deno`.' + - 'Tier: integration (axum, tool tests). Coverage: `cargo llvm-cov nextest -p id_effect_axum --features webtransport --summary-only --fail-under-regions 96` (lifecycle.rs stays in the documented coverage exclusion).' + - 'Gate: `cargo nextest run -p id_effect_axum --features webtransport`; `cargo nextest run -p id_effect_tool --test rpc_gen_ts`' + - 'Owns: `crates/id_effect_axum/src/server/{bootstrap.rs,lifecycle.rs,webtransport.rs}`, `crates/id_effect_axum/tests/host_webtransport.rs`, `crates/id_effect_tool/tests/ts/webtransport.test.ts`, `crates/id_effect_tool/tests/rpc_gen_ts.rs` (one new test)' +--- + +# r13-f-webtransport-host + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "T12 carry" (0.12 overlay F43 and F45). The release +spec's row is binding; this file exists so Maestro can track the leaf as its own task. + +Wave 4. Blocked by: r13-f-h3-settings (server/h3.rs settled). Blocks r13-f-relay-host. The F45 part is +in the scope-cut order; F43 is not (the Relay depends on it). diff --git a/.maestro/specs/releases/0.13/r13-f-ws-client-tls.md b/.maestro/specs/releases/0.13/r13-f-ws-client-tls.md new file mode 100644 index 000000000..6124fe092 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-f-ws-client-tls.md @@ -0,0 +1,24 @@ +--- +title: 'T12 carry (F40) — Rust WsClient over wss:// with postcard: r13-f-ws-client-tls' +slug: r13-f-ws-client-tls +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - 'F40: `WsClient` (crates/id_effect_rpc/src/client/ws.rs:85) dials `wss://` itself with a rustls connector on the platform roots (the same root source as the platform HTTP client), and negotiates the `idrpc.postcard.v1` subprotocol when feature `postcard` is on; `WsClient::over` stays for custom streams.' + - 'crates/id_effect_rpc/Cargo.toml: the tungstenite rustls feature added without a second rustls or tungstenite (`cargo tree -d`).' + - 'Secure by default (ADR 0013 R8): server certificates verified against the platform roots; no "accept invalid certificates" API exists. Test `ws_client_rejects_untrusted_server_cert`.' + - 'Functional tests `ws_client_wss_round_trip`, `ws_client_postcard_round_trip`, `ws_client_json_still_default`.' + - 'Tier: integration. Coverage: `cargo llvm-cov nextest -p id_effect_rpc --features ws,postcard --summary-only --fail-under-regions 96`.' + - 'Gate: `cargo nextest run -p id_effect_rpc --features ws,postcard`; `cargo tree -d -p id_effect_rpc --all-features` shows one rustls and one tungstenite' + - 'Owns: `crates/id_effect_rpc/{Cargo.toml,src/client/ws.rs}`, `crates/id_effect_rpc/tests/ws_client_tls.rs`' +--- + +# r13-f-ws-client-tls + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "T12 carry" (0.12 overlay F40). The release spec's +row is binding; this file exists so Maestro can track the leaf as its own task. + +Wave 3. Blocked by: the v0.12.0 tag; the only 0.13 leaf that edits crates/id_effect_rpc/Cargo.toml. +Blocks r13-f-relay-host. diff --git a/.maestro/specs/releases/0.13/r13-f-ws-credit.md b/.maestro/specs/releases/0.13/r13-f-ws-credit.md new file mode 100644 index 000000000..d3c33a9f4 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-f-ws-credit.md @@ -0,0 +1,22 @@ +--- +title: 'T12 carry (F39) — control frames leave the WebSocket message rate and get their own cap: r13-f-ws-credit' +slug: r13-f-ws-credit +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - 'F39: crates/id_effect_axum/src/realtime/ws.rs gains a frame classification hook on `WsConfig` (data vs control) used by the per-socket limiter (`:861`); crates/id_effect_rpc/src/transport/ws.rs classifies `credit` and `ping` frames as control, so fast client streams are no longer throttled by their own credit frames.' + - 'Secure by default (ADR 0013 R4): control frames have their own finite rate cap (default 4× `messages_per_second`), and exceeding it closes with 1008 as data does. Tests `r4_control_frames_have_own_cap`, existing `r4_ws_message_rate_closes_1008`.' + - 'Functional tests `credit_frames_do_not_throttle_data`, `fast_client_stream_at_rate_limit_completes`.' + - 'Tier: integration (axum, rpc). Coverage: `cargo llvm-cov nextest -p id_effect_axum --features ws --summary-only --fail-under-regions 96` and the same for id_effect_rpc with `--features ws`.' + - 'Gate: `cargo nextest run -p id_effect_axum --features ws`; `cargo nextest run -p id_effect_rpc --features ws`' + - 'Owns: `crates/id_effect_axum/src/realtime/ws.rs` (limiter and classification hook), `crates/id_effect_rpc/src/transport/ws.rs` (frame classification), `crates/id_effect_rpc/tests/ws_credit_rate.rs`' +--- + +# r13-f-ws-credit + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "T12 carry" (0.12 overlay F39). The release spec's +row is binding; this file exists so Maestro can track the leaf as its own task. + +Wave 3. Blocked by: the v0.12.0 tag. Blocks r13-f-ext-connect (same axum file, next wave). diff --git a/.maestro/specs/releases/0.13/r13-t0-adr0015.md b/.maestro/specs/releases/0.13/r13-t0-adr0015.md new file mode 100644 index 000000000..7b48feb83 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-t0-adr0015.md @@ -0,0 +1,24 @@ +--- +title: 'T0 — ADR 0015 "Breadth batteries", Pipeline definition and the 0.13 roadmap restatement: r13-t0-adr0015' +slug: r13-t0-adr0015 +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - 'docs/adrs/0015-breadth-batteries.md (status Proposed) records the release spec decisions D1–D15: the Pipeline reference app definition (D1), id_effect_admin as a crate (D2), the id_effect_axum `ui` feature on maud + vendored htmx (D3), Leptos as a recipe (D4), id_effect_i18n over Project Fluent and its 0.13 scope (D5), the id_effect_ai scope and vendors (D6), the own MCP implementation pinned to one specification revision with rmcp as a test peer only (D7), deterministic AI in CI (D8), the tool-safety rule with its budgets (D9), tenant resolution (D10), the keyset cursor format (D11), lifecycle hooks inside the transaction (D12), stored targets unchanged (D13), the `any_env` helper (D14), measure-first bench budgets (D15); and every row of the release spec Security table as a rule with its enforcement point.' + - 'PLAN-OF-PLANS: §5 T9 gains the Pipeline definition (one paragraph, linking ADR 0015); §6 row 0.13.0 restated from `bash scripts/scorecard.sh --targets 0.13.0` on the measured 0.12.0 baseline; a §11 change-log row names D1, D13 and this leaf. `docs/scorecard/scorecard.json` is not edited (D13).' + - 'The release spec `scorecard:` block is restated on the measured 0.12.0 numbers (`provisional` stays true until the owner accepts ADR 0015); every 0.12 row that missed its target in REPORT-0.12.0 is added to `carry_over` and to the 0.13 leaf that owns its files (written in the leaf evidence).' + - 'Tier: documentation only (no crate). Secure by default: the ADR states each ADR 0013 rule (R1 R2 R3 R4 R6 R7 R9 R10) that a 0.13 surface touches, with the named test or posture probe that enforces it.' + - 'Gate: `bash scripts/scorecard.sh --validate --self-test`; `bash scripts/scorecard.sh --targets 0.13.0` prints the E, RT and <40 values written in the frontmatter (84.3 / 86.6 / 3 before the baseline restatement); `maestro spec validate .maestro/specs/releases/0.13.0.md`' + - 'Owns: `docs/adrs/0015-breadth-batteries.md`, `.maestro/specs/roadmap/PLAN-OF-PLANS.md` (§5 T9, §6 row 0.13.0, §11), the `scorecard:` block of `.maestro/specs/releases/0.13.0.md`' +--- + +# r13-t0-adr0015 + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "T0". The release spec's row is binding; this +file exists so Maestro can track the leaf as its own task. + +Wave 0 head (day 0, alongside r13-t0-skeletons, r13-f-rcgen, r13-f-any-env). Blocked by: the v0.12.0 +tag merged into `release/0.13.0` and `docs/scorecard/results/v0.12.0.json` stored (the baseline). +ADR acceptance is owner step D16; every other leaf cites the ADR as Proposed until then. diff --git a/.maestro/specs/releases/0.13/r13-t0-migration-guide.md b/.maestro/specs/releases/0.13/r13-t0-migration-guide.md new file mode 100644 index 000000000..6fea9827a --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-t0-migration-guide.md @@ -0,0 +1,22 @@ +--- +title: 'T0 (exit) — MIGRATION-0.13 and CHANGELOG: r13-t0-migration-guide' +slug: r13-t0-migration-guide +mode: heavy +work_type: spec-slice +risk_class: low +version: 1 +acceptance_criteria: + - 'docs/MIGRATION-0.13.md has one section per `cargo semver-checks` break, each with its shim or rewrite: `id_effect_sql::repository` split into a module tree (paths kept by `pub use`), `Entity` new associated items (all defaulted, so manual impls compile), `EventLog::head` (provided method), `WsConfig` control-frame cap, `Http3Options` early-data and congestion settings, `HttpTransport::with_header`/`with_bearer`, rcgen 0.14 in any public signature (if r13-f-rcgen found one), crates/id_effect_cli/templates/admin-stub removed, id_effect_ai first published from crates/ (was labs).' + - 'CHANGELOG.md has the 0.13.0 section with Scorecard (measured vs planned per row) and Security (every Security-table rule, new dangerous_* opt-outs, fuzz and compile-fail counts) subsections.' + - 'Every leaf CHANGELOG line is present; every follow-up in the release spec "0.12 follow-ups" table is either closed (leaf merged) or named as carried in the CHANGELOG.' + - 'Tier: documentation. Secure by default: every new dangerous_* opt-out is listed with the finding it reopens (from `scripts/check-dangerous.sh`).' + - 'Gate: `cargo semver-checks` output is a subset of the MIGRATION-0.13 list; `bash scripts/check-dangerous.sh`' + - 'Owns: `docs/MIGRATION-0.13.md`, `CHANGELOG.md`' +--- + +# r13-t0-migration-guide + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "T0" (exit). The release spec's row is binding; +this file exists so Maestro can track the leaf as its own task. + +Exit. Blocked by: every other leaf. Runs beside r13-x-rescore. diff --git a/.maestro/specs/releases/0.13/r13-t0-skeletons.md b/.maestro/specs/releases/0.13/r13-t0-skeletons.md new file mode 100644 index 000000000..2d53aa301 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-t0-skeletons.md @@ -0,0 +1,25 @@ +--- +title: 'T0 — ADR 0010 Amendment 9, crate moves and skeletons (ai, admin, i18n, Pipeline): r13-t0-skeletons' +slug: r13-t0-skeletons +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - 'docs/adrs/0010-crate-tiers.md Amendment 9 (for 0.13): id_effect_ai leaves the labs row and enters tier integration (moved with history: `git mv labs/id_effect_ai crates/id_effect_ai`; stays `publish = false` in the transitional merge tier, listed in `PENDING_PROMOTION` of scripts/validate-crate-packaging.sh, until r13-e9-1-promote); id_effect_admin and id_effect_i18n are new tier-integration crates; the id_effect_axum `ui` feature adds no crate; examples/reference/pipeline is tier example, publish = false, depending on id_effect_kit only; each crate moves to platform when the Pipeline depends on it (check P of scripts/tier-conformance.sh).' + - 'Root Cargo.toml members gain crates/id_effect_ai, crates/id_effect_admin, crates/id_effect_i18n and examples/reference/pipeline; labs/Cargo.toml drops id_effect_ai; `examples/recipes` is added to the root `exclude`; crates/id_effect_cli/templates/admin-stub/** is deleted (its only references are scorecard evidence text, re-scored at exit).' + - '`bash scripts/validate-crate-packaging.sh --print-order` lists id_effect_i18n after id_effect and id_effect_axum, id_effect_ai after id_effect_platform and id_effect_config, id_effect_admin after id_effect_sql, id_effect_axum, id_effect_authz and id_effect_i18n, all three before id_effect_kit and id_effect_tool; crates.io availability of the three names is recorded in the amendment (generic User-Agent, D17).' + - 'The moved ai crate builds and its existing tests pass unchanged (manifest path fixes only); admin, i18n and pipeline skeletons each have a README with a `rust` fence, one example, one test and `#![deny(missing_docs)]`; .github/workflows/ci.yml gains the `-p` lines and the labs job no longer builds ai; docs/MIGRATION-0.13.md exists with one heading per planned break.' + - 'Tier: governance (ADR and manifests). Secure by default: skeleton crates set `#![forbid(unsafe_code)]` and declare no `dangerous_*` API.' + - 'Gate: `bash scripts/validate-crate-packaging.sh`; `cargo nextest run -p id_effect_ai -p id_effect_admin -p id_effect_i18n -p pipeline`; `cargo nextest run --manifest-path labs/Cargo.toml --workspace`; `bash scripts/tier-conformance.sh` (no new FAIL)' + - 'Owns: `docs/adrs/0010-crate-tiers.md` (Amendment 9), root `Cargo.toml` (members, exclude), `labs/Cargo.toml`, `crates/id_effect_ai/**` (move and manifest paths only), `crates/id_effect_admin/` (skeleton), `crates/id_effect_i18n/` (skeleton), `examples/reference/pipeline/` (skeleton), `crates/id_effect_cli/templates/admin-stub/**` (deleted), `scripts/validate-crate-packaging.sh`, `.github/workflows/ci.yml` (`-p` lines, labs job), `docs/MIGRATION-0.13.md` (headings)' +--- + +# r13-t0-skeletons + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "T0". The release spec's row is binding; this +file exists so Maestro can track the leaf as its own task. + +Wave 0 head. Blocked by: the v0.12.0 tag merged into `release/0.13.0`. Blocks every wave-1 breadth +leaf. Runs beside r13-t0-adr0015, r13-f-rcgen (which edits other manifests' rcgen lines only) and +r13-f-any-env. diff --git a/.maestro/specs/releases/0.13/r13-w5-kit-template.md b/.maestro/specs/releases/0.13/r13-w5-kit-template.md new file mode 100644 index 000000000..b3b005fa9 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-w5-kit-template.md @@ -0,0 +1,27 @@ +--- +title: 'Wave 5 — Kit features and posture probes for the breadth batteries; templates; F55: r13-w5-kit-template' +slug: r13-w5-kit-template +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - 'Kit features `ai`, `mcp`, `embeddings`, `admin`, `ui`, `i18n`, `tenancy` in crates/id_effect_kit/Cargo.toml (all off by default, all in `full`), prelude items for each, and re-exports `id_effect_kit::{ai, admin, i18n, ui}`.' + - '`tenancy` bridges `id_effect_authz::tenant::TenantResolver` to both `TenantScope` and `id_effect_sql::CurrentTenant` in one layer (D10), so sql and authz stay independent.' + - 'F55: `id_effect_kit::process` re-exports id_effect_process; the `cluster` template uses it (no direct id_effect_process dependency in templates).' + - 'The admin generator template import switches to `id_effect_kit::admin::` (the template variable left by r13-e1-3-generate-admin); scripts/template-smoke.sh runs `id-effect generate admin` on a generated `service` project and builds and tests it.' + - 'Posture probes in crates/id_effect_kit/src/posture.rs: "admin guarded", "mcp guarded", "prompts not logged", "tenant not from header"; each probe fails the prod profile on violation unless its opt-out is accepted (ADR 0013 R7 R10); scripts/check-dangerous.sh lists `dangerous_log_prompts`, `dangerous_mcp_write_tools`, `dangerous_tenant_from_header`.' + - 'Tests `kit_tenancy_bridge_scopes_sql_and_authz`, `posture_reports_breadth_probes`, `posture_prod_refuses_public_admin`, `posture_prod_refuses_unguarded_mcp`, `template_service_generate_admin_round_trip`, `kit_reexports_id_effect_process`.' + - 'Tier: integration (kit, tool). Coverage: `cargo llvm-cov nextest -p id_effect_kit --all-features --summary-only --fail-under-regions 96` and the same for id_effect_tool.' + - 'Gate: `cargo nextest run -p id_effect_kit -p id_effect_tool --all-features`; `bash scripts/template-smoke.sh`; `bash scripts/check-dangerous.sh` (K1 with default features is measured by r13-w8-kpis)' + - 'Owns: `crates/id_effect_kit/**`, `crates/id_effect_tool/{templates/**,Cargo.toml}`, `scripts/{template-smoke.sh,check-dangerous.sh}`' +--- + +# r13-w5-kit-template + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "Proof". The release spec's row is binding; this +file exists so Maestro can track the leaf as its own task. + +Wave 5, critical path. Blocked by: r13-e1-3-generate-admin, r13-e9-1-mcp, r13-e9-1-embeddings, +r13-e9-4-i18n-web, r13-e5-5-tenancy, r13-e3-3-hooks, r13-e9-5-views. Blocks r13-w6-pipeline and +r13-f-relay-host. diff --git a/.maestro/specs/releases/0.13/r13-w6-pipeline.md b/.maestro/specs/releases/0.13/r13-w6-pipeline.md new file mode 100644 index 000000000..48b01b41a --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-w6-pipeline.md @@ -0,0 +1,25 @@ +--- +title: 'Wave 6 — Pipeline reference app, core: multi-tenant intake, AI stages, MCP, publish hook: r13-w6-pipeline' +slug: r13-w6-pipeline +mode: heavy +work_type: spec-slice +risk_class: high +version: 1 +acceptance_criteria: + - 'D1 stages 1–4, 6 and 7 in examples/reference/pipeline, built only through id_effect_kit (features sql-pg, repository, audit, ai, embeddings, mcp, tenancy, rpc, jobs as needed): ingest by RPC mutation or upload; extraction by structured output into a typed `Extraction`; classification by a bounded tool loop (`lookup_source`, `list_labels`); embeddings flag near-duplicates within the tenant (bounded candidate set); an `after_update` hook writes `DocumentPublished` to the outbox; an RPC subscription shows live status; an MCP server exposes `search_documents` and `get_document`, an MCP client enriches from a fixture MCP server.' + - 'Data model `Source` 1:N `Document` N:M `Label` (through `document_labels`), `Document` 1:N `Extraction`, all tenant-scoped, read with eager loading and keyset pagination; tenants `acme` (main database) and `globex` (its own database through `TenantRouter`).' + - 'D8: deterministic AI only: `ScriptedModel` plus the wiremock OpenAI/Anthropic fixtures; no API key, no network to vendors.' + - 'CI job `reference-pipeline` in .github/workflows/ci.yml (Postgres via id_effect_test containers, two databases); id_effect_ai and id_effect_i18n tier lines move to `platform` (check P; i18n is used for the extraction labels and the MCP tool descriptions in en/de).' + - 'Tests `pipeline_ingest_to_publish_end_to_end`, `pipeline_tenant_isolation_across_relations`, `pipeline_tenant_db_routing`, `pipeline_eager_loading_no_n_plus_one`, `pipeline_keyset_pagination_stable_under_inserts`, `pipeline_ai_tool_loop_bounded`, `pipeline_prompt_injection_cannot_call_unlisted_tool` (a hostile document asks the model to call an unregistered tool), `pipeline_mcp_tools_round_trip`, `pipeline_mcp_refuses_foreign_origin`, `pipeline_publish_hook_writes_outbox`.' + - 'Tier: example (publish = false). Secure by default: every route declared (`.public()` only for health), MCP guarded and Origin-checked, prompts not logged, tenant from the principal; prod posture checked in r13-w7-pipeline-backoffice once the back office exists.' + - 'Gate: `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p pipeline`; CI job `reference-pipeline`; `bash scripts/tier-conformance.sh`' + - 'Owns: `examples/reference/pipeline/{Cargo.toml,README.md,moon.yml,compose.yaml}`, `examples/reference/pipeline/migrations/**`, `examples/reference/pipeline/src/{main.rs,lib.rs,domain.rs,stages.rs,ai.rs,tenancy.rs,mcp.rs,rpc.rs}`, `examples/reference/pipeline/src/stages/**`, `examples/reference/pipeline/tests/{core.rs,tenancy.rs,ai.rs,mcp.rs}`, `examples/reference/pipeline/fixtures/**`, `.github/workflows/ci.yml` (job `reference-pipeline`), the tier lines of `crates/id_effect_ai/Cargo.toml` and `crates/id_effect_i18n/Cargo.toml`' +--- + +# r13-w6-pipeline + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "Proof" (the §6 exit KPI, first half). The release +spec's row is binding; this file exists so Maestro can track the leaf as its own task. + +Wave 6, critical path. Blocked by: r13-w5-kit-template. Runs beside r13-f-relay-host (which rebases its +ci.yml steps after this leaf). Blocks r13-w7-pipeline-backoffice. Never cut. diff --git a/.maestro/specs/releases/0.13/r13-w7-pipeline-backoffice.md b/.maestro/specs/releases/0.13/r13-w7-pipeline-backoffice.md new file mode 100644 index 000000000..c5b765cfc --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-w7-pipeline-backoffice.md @@ -0,0 +1,23 @@ +--- +title: 'Wave 7 — Pipeline reference app, back office: generated admin, htmx review queue, en/de, prod posture: r13-w7-pipeline-backoffice' +slug: r13-w7-pipeline-backoffice +mode: heavy +work_type: spec-slice +risk_class: high +version: 1 +acceptance_criteria: + - 'D1 stage 5: the admin for `Source`, `Document` and `Label` is produced with `id-effect generate admin ` (the exact commands and every manual edit after generation are listed in the Pipeline README), with roles `reviewer` (list, view, update labels, bulk re-run extraction) and `admin` (all); label picker over the N:M relation; every change audited.' + - 'An htmx + maud review queue (layout, components, partial updates, form errors bound to fields) in English and German: Accept-Language negotiation plus a per-user override; validation errors translated; locales under examples/reference/pipeline/locales/.' + - 'Prod posture clean with zero accepted risks (admin guarded, MCP guarded, prompts not logged, tenant not from header, plus the 0.11/0.12 probes); id_effect_admin tier line moves to `platform` (check P).' + - 'Tests `pipeline_admin_crud_and_audit`, `pipeline_admin_denied_without_role`, `pipeline_admin_bulk_rerun_extraction`, `pipeline_admin_label_picker_tenant_scoped`, `pipeline_ui_renders_de_and_en`, `pipeline_validation_errors_translated`, `pipeline_htmx_post_requires_csrf`, `pipeline_prod_posture_is_clean` (HTML asserted through the id_effect_test in-process HTTP client; no browser).' + - 'Tier: example. Secure by default: as the posture test above; no `dangerous_*` opt-out taken.' + - 'Gate: `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p pipeline`; CI job `reference-pipeline`; `bash scripts/tier-conformance.sh` (all PASS)' + - 'Owns: `examples/reference/pipeline/src/{admin,ui}/**`, `examples/reference/pipeline/locales/**`, `examples/reference/pipeline/tests/{admin.rs,ui.rs,i18n.rs,posture.rs}`, the `reference-pipeline` job steps of `.github/workflows/ci.yml`, the tier line of `crates/id_effect_admin/Cargo.toml`; one `mod` line each in `src/lib.rs` and the README sections "Back office" and "Locales"' +--- + +# r13-w7-pipeline-backoffice + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "Proof" (the §6 exit KPI, second half). The +release spec's row is binding; this file exists so Maestro can track the leaf as its own task. + +Wave 7, critical path. Blocked by: r13-w6-pipeline. Blocks r13-w8-level-audit and r13-w8-kpis. diff --git a/.maestro/specs/releases/0.13/r13-w8-kpis.md b/.maestro/specs/releases/0.13/r13-w8-kpis.md new file mode 100644 index 000000000..ccc989559 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-w8-kpis.md @@ -0,0 +1,23 @@ +--- +title: 'Wave 8 — Security and DX KPIs for 0.13: r13-w8-kpis' +slug: r13-w8-kpis +mode: heavy +work_type: spec-slice +risk_class: low +version: 1 +acceptance_criteria: + - 'docs/security/kpis-0.13.json via scripts/security-kpis.sh (0.13 fields added): open highs 0; fuzz targets ≥ 22 with ≥ 5 new and no open crash; compile-fail cases include r1_admin_site_without_guard and r1_mcp_endpoint_without_access_decision; secure-stack delta ≤ 5 µs and unary RPC delta ≤ 3 µs from docs/benchmarks/v0.13.json; every Security-table test green (from surface_013.rs).' + - 'docs/benchmarks/dx-kpis-0.13.json: K1 ≤ 180 s, K2 ≤ 8 s, K4 ≥ 17/20, K5 ≥ 80 %, K6 within the 0.10 budget, plus the cold-build delta of kit features ai, admin, ui, i18n each measured separately on the `service` template.' + - 'Workspace coverage gate `scripts/llvm-cov-nextest.sh` ≥ 96 % regions with id_effect_ai, id_effect_admin, id_effect_i18n included; any new exclusion has a justification line in the script.' + - 'Tier: docs and scripts. Secure by default: the KPI file is the release''s security evidence.' + - 'Gate: `bash scripts/security-kpis.sh --self-test && bash scripts/security-kpis.sh 0.13.0`; `DX_KPIS_RELEASE=0.13 bash scripts/dx-kpis.sh`; `bash scripts/llvm-cov-nextest.sh`' + - 'Owns: `docs/security/kpis-0.13.json`, `scripts/security-kpis.sh` (0.13 fields), `docs/benchmarks/dx-kpis-0.13.json`' +--- + +# r13-w8-kpis + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "Proof". The release spec's row is binding; this +file exists so Maestro can track the leaf as its own task. + +Wave 8 (last). Blocked by: r13-w7-pipeline-backoffice, r13-e-benches, r13-e-fuzz-security. Idle machine; +`DX_KPIS_DIR` and `TMPDIR` under `.tmp/` or `/data` (0.12 F61). diff --git a/.maestro/specs/releases/0.13/r13-w8-level-audit.md b/.maestro/specs/releases/0.13/r13-w8-level-audit.md new file mode 100644 index 000000000..eab30e75a --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-w8-level-audit.md @@ -0,0 +1,23 @@ +--- +title: 'Wave 8 — Level audit for the six breadth rows and the three kept T12 rows: r13-w8-level-audit' +slug: r13-w8-level-audit +mode: heavy +work_type: spec-slice +risk_class: low +version: 1 +acceptance_criteria: + - 'For data.orm (#3 #7 plus the kept #1 #2 #5 #6 #8), web.ui (#1 #2 #6 #8, margin #5), web.admin (#1 #2 #3 #8, margin #4 #5 #6), ai.llm (#1–#7, margin #8), eco.i18n (#1 #2 #3 #5 #7) and eco.tenancy (#1 #2 #3 #5 #6 #7, margin #8): every planned check id maps to an existing path at HEAD, a named test that passes, a book chapter and a runnable example (METHOD §7.2); level production is evidenced by the reference-pipeline CI job; the evidence map is attached to the leaf evidence.' + - 'web.rpc, web.realtime and dist.tls are re-checked after the T12 carry leaves (h3 settings, WebSocket changes, mesh session pump, Relay on the host): no check lost; level production still evidenced by reference-relay and reference-ledger.' + - 'Every gap becomes a Maestro task before exit; no file is edited by this leaf.' + - 'Tier: read-only audit.' + - 'Gate: `jq -r ''.rows[] | select(.id=="data.orm" or .id=="web.ui" or .id=="web.admin" or .id=="ai.llm" or .id=="eco.i18n" or .id=="eco.tenancy" or .id=="web.rpc" or .id=="web.realtime" or .id=="dist.tls") | .checks[].id'' docs/scorecard/scorecard.json` → each id present in the evidence map with a path that exists (`test -e`) and a test that passes (`cargo nextest run -E ''test()''`)' + - 'Owns: none (read-only)' +--- + +# r13-w8-level-audit + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "Proof". The release spec's row is binding; this +file exists so Maestro can track the leaf as its own task. + +Wave 8 (last, with r13-w8-kpis). Blocked by: r13-w7-pipeline-backoffice and r13-f-relay-host. Feeds +r13-x-rescore. diff --git a/.maestro/specs/releases/0.13/r13-x-rescore.md b/.maestro/specs/releases/0.13/r13-x-rescore.md new file mode 100644 index 000000000..33dfc5e68 --- /dev/null +++ b/.maestro/specs/releases/0.13/r13-x-rescore.md @@ -0,0 +1,23 @@ +--- +title: 'Exit — Blind re-score of 0.13.0, results, report and index check: r13-x-rescore' +slug: r13-x-rescore +mode: heavy +work_type: spec-slice +risk_class: medium +version: 1 +acceptance_criteria: + - 'Blind re-score per docs/scorecard/METHOD.md §6 by an assessor (agent or person) who built none of the 0.13 leaves, working from the repository at the release candidate commit and the r13-w8-level-audit evidence map; per row the assessor records level and met checks (`rows[].v2.level` / `met` for 0.13.0); adjudications between assessor and evidence map are written in REPORT-0.13.0.md.' + - 'docs/scorecard/scorecard.json gains the measured 0.13.0 column only (no target edits, D13); `bash scripts/scorecard.sh --json 0.13.0 --write` stores docs/scorecard/results/v0.13.0.json; `--report 0.13.0` writes REPORT-0.13.0.md; `--html` refreshes index.html.' + - '`bash scripts/scorecard.sh --compare-targets 0.13.0` passes (measured E ≥ planned E − 1.0 = 83.3 on the stored plan, or the plan restated by r13-t0-adr0015) or each missed row has a written carry-over to 0.14 in the report and in PLAN-OF-PLANS §11; `--diff 0.12.0 0.13.0` passes (no drop of 5 or more without an ADR note).' + - 'PLAN-OF-PLANS §11 gains the 0.13 exit row (measured E/RT/red, misses, carry-overs).' + - 'Tier: docs. Secure by default: the report has a Security section (from docs/security/kpis-0.13.json).' + - 'Gate: `bash scripts/scorecard.sh --validate`; `bash scripts/scorecard.sh --compare-targets 0.13.0`; `bash scripts/scorecard.sh --diff 0.12.0 0.13.0`; `bash scripts/scorecard.sh --validate --self-test`' + - 'Owns: `docs/scorecard/scorecard.json` (0.13.0 measured column only), `docs/scorecard/results/v0.13.0.json`, `docs/scorecard/REPORT-0.13.0.md`, `docs/scorecard/index.html`, `.maestro/specs/roadmap/PLAN-OF-PLANS.md` (§11 exit row)' +--- + +# r13-x-rescore + +Leaf of `.maestro/specs/releases/0.13.0.md`, section "Proof" (exit). The release spec's row is +binding; this file exists so Maestro can track the leaf as its own task. + +Exit. Blocked by: r13-w8-level-audit and r13-w8-kpis. Runs beside r13-t0-migration-guide. From ea3fe1d91c4ba18eb476ff089defe592c1aff2f8 Mon Sep 17 00:00:00 2001 From: Tom Wieland Date: Wed, 21 Oct 2026 03:17:15 +0200 Subject: [PATCH 03/87] chore(maestro): release 0.13 mission, 39 leaf tasks, execution overlay (r13-kickoff) Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Wieland --- .maestro/missions/release-0-13.execution.md | 142 ++++++++++++++++++++ .maestro/tasks/NOW.md | 27 +++- .maestro/tasks/tasks.jsonl | 39 ++++++ 3 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 .maestro/missions/release-0-13.execution.md diff --git a/.maestro/missions/release-0-13.execution.md b/.maestro/missions/release-0-13.execution.md new file mode 100644 index 000000000..7092f719d --- /dev/null +++ b/.maestro/missions/release-0-13.execution.md @@ -0,0 +1,142 @@ +# release-0-13 — execution overlay + +Mission: `pln-mvhf4op9-yl99n0` (`maestro mission from-spec .maestro/specs/releases/0.13.0.md`, decomposed +into 39 tasks, one per `.maestro/specs/releases/0.13/r13-*.md` leaf) · spec +`.maestro/specs/releases/0.13.0.md` (approved under the owner's L15 goal of 2026-10-18; every "Taken +(default)" row stands, owner steps D16/D17 open) · branch `release/0.13.0` (off main after `v0.12.0` was +tagged; the owner confirmed the prerequisite on 2026-10-21). + +## Preconditions (day 0) + +- `v0.12.0` tagged and on main (confirmed 2026-10-21). `docs/scorecard/results/v0.12.0.json` and + `REPORT-0.12.0.md`: r13-t0-adr0015 re-checks them and fills `carry_over`. +- Owner has seen the nine open questions in the release spec (defaults apply if unanswered). +- Live-test leaves start their own containers through `id_effect_test` on distinct ports. + +## Wave 0 — head (dispatched at kickoff) + +| Agent | Leaf (task id) | Files (owned) | +|---|---|---| +| A0 roadmap | `r13-t0-adr0015` (`tsk-mvhf59ri-gcmaue`) | docs/adrs/0015-breadth-batteries.md, PLAN-OF-PLANS.md §5 T9 / §6 / §11, the release spec `scorecard:` block | +| A1 governance | `r13-t0-skeletons` (`tsk-mvhf59ri-8vwj0w`) | docs/adrs/0010-crate-tiers.md (Amendment 9), root Cargo.toml, labs/Cargo.toml, `git mv labs/id_effect_ai crates/id_effect_ai`, crates/id_effect_{admin,i18n}/ skeletons, examples/reference/pipeline/ skeleton, crates/id_effect_cli/templates/admin-stub/** (deleted), scripts/validate-crate-packaging.sh, ci.yml (`-p` lines, labs job), docs/MIGRATION-0.13.md (headings) | +| A2 deps | `r13-f-rcgen` (`tsk-mvhf59ri-2mysjl`) | rcgen lines of six manifests, the 19 rcgen `.rs` files, deny.toml | +| A3 core | `r13-f-any-env` (`tsk-mvhf59ri-0jkcwz`) | crates/id_effect/src/kernel/{effect,local}.rs (`any_env` only), tests/any_env.rs, one book section | + +## Wave 1 (after skeletons) + +| Leaf (task id) | Files (owned) | Depends on / note | +|---|---|---| +| ★`r13-e9-5-views` (`tsk-mvhf59ri-2uwyi9`) | crates/id_effect_axum/{Cargo.toml (ui, maud), src/ui/**, assets/**}, tests/ui.rs, example 095, book ch29-10 | skeletons — **critical path** | +| ★`r13-e9-1-promote` (`tsk-mvhf59ri-55inb3`) | crates/id_effect_ai/** (declares every 0.13 feature once), book ch35-00 | skeletons (ai move) | +| `r13-e3-3-relations` (`tsk-mvhf59ri-o3mnz0`) | sql `repository/{mod,relations}.rs` (+ empty page/hooks), proc_macro repository.rs, tests, book ch28-06 | owns the `Entity::FIELDS` contract admin depends on | +| `r13-e9-4-i18n-core` (`tsk-mvhf59ri-d7qwhu`) | crates/id_effect_i18n/** (core), book ch30-09 | skeletons | +| `r13-f-client-batching` (`tsk-mvhf59ri-9x4e24`) | rpc `src/{service.rs,client/http.rs}`, tests/service_macro.rs (new tests) | any-env; applies `0.13/f27-client-batching.patch` | +| `r13-f-stream-chunking` (`tsk-mvhf59ri-jpxbhs`) | crates/id_effect/src/streaming/{stream,pump}.rs (pull path) | strict tier; bench compare vs v0.12 | +| `r13-f-eventlog-head` (`tsk-mvhf59ri-64yymd`) | events `{subscription,sql_journal,event_store}.rs`, rpc `subscription/events.rs`, tests/head.rs | — | + +## Wave 2 + +| Leaf (task id) | Depends on | +|---|---| +| ★`r13-e9-3-admin-core` (`tsk-mvhf59ri-hqb6sh`) | views, skeletons, relations (`Entity::FIELDS`) — **critical path**; leaf most likely to be wrong | +| ★`r13-e9-1-tools` (`tsk-mvhf59ri-7mq6sz`) | promote | +| `r13-e9-1-embeddings` (`tsk-mvhf59ri-i60zro`) | promote | +| `r13-e3-3-pagination` (`tsk-mvhf59ri-5gimx6`) | relations | +| `r13-e3-3-hooks` (`tsk-mvhf59ri-d82b9b`) | relations; own Postgres container | +| `r13-e9-4-i18n-web` (`tsk-mvhf59ri-1ru5me`) | i18n-core | +| `r13-e5-5-tenancy` (`tsk-mvhf59ri-ua2lw4`) | relations (sql `lib.rs` line); own Postgres container | + +## Wave 3 (seven leaves; at most five at once — start the ★ leaves first) + +| Leaf (task id) | Depends on | +|---|---| +| ★`r13-e9-3-admin-edit` (`tsk-mvhf59ri-47pqck`) | admin-core, relations, pagination, hooks — **critical path** | +| ★`r13-e9-1-mcp` (`tsk-mvhf59ri-07dfve`) | tools | +| `r13-f-h3-settings` (`tsk-mvhf59ri-xpedhx`) | rcgen | +| `r13-f-ws-credit` (`tsk-mvhf59ri-bvwb9u`) | — | +| `r13-f-mesh-session` (`tsk-mvhf59ri-pdbe88`) | — | +| `r13-f-ws-client-tls` (`tsk-mvhf59ri-saufjy`) | rcgen | +| `r13-e9-5-leptos` (`tsk-mvhf59ri-c8i4zl`) | views; first in the scope-cut order | + +## Wave 4 (six leaves; at most five at once) + +| Leaf (task id) | Depends on | +|---|---| +| ★`r13-e1-3-generate-admin` (`tsk-mvhf59ri-x2qmeh`) | admin-edit — **critical path** | +| `r13-e-fuzz-security` (`tsk-mvhf59ri-a8w9hx`) | mcp, tools, i18n-web, pagination, admin-edit; TMPDIR under /data | +| `r13-e-benches` (`tsk-mvhf59ri-j3gf9e`) | relations, pagination, i18n-core, views, stream-chunking; idle machine; budgets after measuring (D15) | +| `r13-f-webtransport-host` (`tsk-mvhf59ri-ye7a0y`) | h3-settings (Risk 6: Deno may be infeasible) | +| `r13-f-ext-connect` (`tsk-mvhf59ri-l9ih4g`) | ws-credit (shares `realtime/ws.rs`) | +| `r13-f-rpc-retire` (`tsk-mvhf59ri-5gvyft`) | — | + +## Wave 5 + +| Leaf (task id) | Depends on | +|---|---| +| ★`r13-w5-kit-template` (`tsk-mvhf59ri-tdjahc`) | generate-admin, mcp, embeddings, i18n-web, tenancy, hooks, views — **critical path** | + +## Wave 6 + +| Leaf (task id) | Depends on | +|---|---| +| ★`r13-w6-pipeline` (`tsk-mvhf59ri-xlpdlx`) | kit-template — §6 KPI, never cut; owns the `reference-pipeline` CI job | +| `r13-f-relay-host` (`tsk-mvhf59ri-v0qnuc`) | kit-template, webtransport-host, ws-client-tls, client-batching, mesh-session, ext-connect; relay steps in ci.yml rebased after pipeline | + +## Wave 7 + +| Leaf (task id) | Depends on | +|---|---| +| ★`r13-w7-pipeline-backoffice` (`tsk-mvhf59ri-6qnxm6`) | pipeline (its CI job green first) | + +## Wave 8 and exit + +| Leaf (task id) | Depends on | +|---|---| +| `r13-w8-level-audit` (`tsk-mvhf59ri-fjrbf4`) | pipeline-backoffice, relay-host (read-only) | +| `r13-w8-kpis` (`tsk-mvhf59ri-pwqw3c`) | pipeline-backoffice, benches, fuzz-security; idle machine; last | +| `r13-x-rescore` (`tsk-mvhf59ri-bpu8pp`) | every leaf; blind assessor who did not build the leaves (METHOD §6) | +| `r13-t0-migration-guide` (`tsk-mvhf59ri-pqqwbl`) | every leaf; then REPORT-0.13.0, CHANGELOG Scorecard + Security, owner steps D16 (ADR 0015 accepted) / D17 (crates.io) | + +Critical path: skeletons → views → admin-core → admin-edit → generate-admin → kit-template → pipeline → +pipeline-backoffice → level audit → re-score. The AI branch (promote → tools → mcp → kit-template) has +one wave of slack; the T12 carry leaves have three or more and fill free slots. + +## Rules (as 0.12, plus the 0.12 lessons) + +- One shared checkout: every leaf's Owns are disjoint within a wave (release spec "File ownership"). + Append-only shared files: `crates/id_effect/book/src/SUMMARY.md`, `examples/book-snippets/src/part6.rs`, + CHANGELOG.md, docs/MIGRATION-0.13.md, Cargo.lock (regenerated), the `lib.rs` of id_effect_{ai,sql, + axum,authz,i18n,admin} after their wave-0/1 owner, and the module-declaration lines of sql + `repository/mod.rs`, axum `server/mod.rs`, rpc `{client,transport}/mod.rs`. Re-read before editing; + insert, never rewrite. +- Commits go through a private index only: `GIT_INDEX_FILE=.tmp/idx-`, `git read-tree HEAD`, + pathspec-limited `git add`, `git commit --no-verify -s` (DCO), then `git reset -q -- ` on the + shared index. Never stash, reset --hard, clean, checkout others' files, push or tag. +- Gates set `TMPDIR` under `/data/Code/rust/id_effect/.tmp//` (the `/tmp` tmpfs fills). Live tests + use `ID_EFFECT_REQUIRE_PG=1` / `ID_EFFECT_REQUIRE_DOCKER=1`, fail rather than skip, never read the + ambient `DATABASE_URL`. +- Every leaf: its gate, `cargo clippy -p --all-targets --all-features -- -D warnings`, coverage + ≥ 96 % regions per touched crate (examples outside the gate, 0.12 F54), `missing_docs` denied, + CHANGELOG line, MIGRATION-0.13 section per semver break, `dangerous_*` APIs recorded and listed by + `scripts/check-dangerous.sh`. +- New pre-1.0 dependencies exact-pinned (maud, fluent-*, htmx file sha384) and `cargo deny check` clean; + `cargo tree -d` shows one rcgen, one quinn, one rustls, one tungstenite. +- A build failure in a file another leaf owns: wait and retry (up to ~30 min) before reporting a blocker. +- Never send the owner's email or other personal data to external services. + +## Follow-ups + +Carried from the 0.12 overlay (rows the release spec does not give to a 0.13 leaf): + +| # | From | Follow-up | Owner (default) | +|---|---|---|---| +| F63 | 0.12 CI (PR #112) | flaky: `id_effect_sql::conformance mysql::dropped_streams_release_their_connections` failed once in Container integration (2627692d: `SELECT 1 AS n: sql client not connected` after 88 s, pool acquire timed out); passed on aa84c378 and #111 with no sql source change. Make it deterministic (wait for the pool's idle count instead of a fixed timeout; check the MySQL container's `max_connections`) | 0.13 coordinator follow-up (sql conformance test, in no leaf's Owns; schedule in a free slot once the wave-2 sql leaves have merged) | +| F62 | 0.12 CI (PR #112) | h3spec failures in the `server/h3.rs` adapter (connection-specific headers, pseudo-header placement, field-section size) plus upstream h3 / quinn items; `http3-conformance` not in `ci-pass` | coordinator: adapter part after r13-f-h3-settings merges (same file), else 0.14 | +| F53 | ledger/relay | two Postgres idempotency stores | 0.14 (explicit, not 0.13 scope) | +| F59 | relay | feature exercise (streams, SSE, postcard, batching, WS resume) | 0.14 E13.2 (0.13 part in r13-f-relay-host) | + +Recorded during 0.13: + +| # | From | Follow-up | Owner (default) | +|---|---|---|---| +| — | — | — | — | diff --git a/.maestro/tasks/NOW.md b/.maestro/tasks/NOW.md index 1aad20a2e..f2da9c588 100644 --- a/.maestro/tasks/NOW.md +++ b/.maestro/tasks/NOW.md @@ -1,11 +1,32 @@ # NOW -Updated: 2026-10-20T05:21:34.609Z +Updated: 2026-10-21T01:13:47.887Z ## In flight (0) None. -## Ready to pick up (0) -None. +## Ready to pick up (39) +### tsk-mvhf59ri-gcmaue . W0: ADR 0015 Breadth batteries + PLAN-OF-PLANS restatement +Mission: pln-mvhf4op9-yl99n0 +Spec: .maestro/specs/releases/0.13/r13-t0-adr0015.md + +### tsk-mvhf59ri-8vwj0w . W0: Amendment 9, ai move, admin/i18n/pipeline skeletons +Mission: pln-mvhf4op9-yl99n0 +Spec: .maestro/specs/releases/0.13/r13-t0-skeletons.md + +### tsk-mvhf59ri-2mysjl . W0: F5 one rcgen 0.14 across the workspace +Mission: pln-mvhf4op9-yl99n0 +Spec: .maestro/specs/releases/0.13/r13-f-rcgen.md + +### tsk-mvhf59ri-0jkcwz . W0: F52 Effect::any_env +Mission: pln-mvhf4op9-yl99n0 +Spec: .maestro/specs/releases/0.13/r13-f-any-env.md + +### tsk-mvhf59ri-2uwyi9 . W1: axum ui feature (maud views, htmx) +Mission: pln-mvhf4op9-yl99n0 +Spec: .maestro/specs/releases/0.13/r13-e9-5-views.md + +(and 34 more) + ## Ready to ship (0) None. diff --git a/.maestro/tasks/tasks.jsonl b/.maestro/tasks/tasks.jsonl index ff9a1b224..4a18b330b 100644 --- a/.maestro/tasks/tasks.jsonl +++ b/.maestro/tasks/tasks.jsonl @@ -467,3 +467,42 @@ {"id":"tsk-mvfuszbz-xpikgu","slug":"r12-f-ts-cursor","title":"r12-f-ts-cursor","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-f-ts-cursor.md","blocked_by":[],"created_at":"2026-10-19T22:56:35.807Z","updated_at":"2026-10-20T00:09:24.432Z","assignee":"j3-ts-cursor","claimed_at":"2026-10-19T22:56:46.694Z","merged_at":"2026-10-20T00:09:24.431Z"} {"id":"tsk-mvfypvdw-vae18k","slug":"r12-f-rpc-spans","title":"r12-f-rpc-spans","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-f-rpc-spans.md","blocked_by":[],"created_at":"2026-10-20T00:46:09.188Z","updated_at":"2026-10-20T03:41:30.033Z","assignee":"k1-rpc-spans","claimed_at":"2026-10-20T00:46:18.271Z","merged_at":"2026-10-20T03:41:30.032Z"} {"id":"tsk-mvg7dsl7-vj0q93","slug":"r12-t0-migration-guide","title":"r12-t0-migration-guide","state":"shipped","spec_path":"/data/Code/rust/id_effect/.tmp/wt-012/.maestro/specs/releases/0.12/r12-t0-migration-guide.md","blocked_by":[],"created_at":"2026-10-20T04:48:42.235Z","updated_at":"2026-10-20T05:21:34.605Z","assignee":"l1-exit","claimed_at":"2026-10-20T04:48:47.763Z","merged_at":"2026-10-20T05:21:34.604Z"} +{"id":"tsk-mvhf59ri-gcmaue","slug":"r13-t0-adr0015","title":"W0: ADR 0015 Breadth batteries + PLAN-OF-PLANS restatement","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-t0-adr0015.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-8vwj0w","slug":"r13-t0-skeletons","title":"W0: Amendment 9, ai move, admin/i18n/pipeline skeletons","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-t0-skeletons.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-2mysjl","slug":"r13-f-rcgen","title":"W0: F5 one rcgen 0.14 across the workspace","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-f-rcgen.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-0jkcwz","slug":"r13-f-any-env","title":"W0: F52 Effect::any_env","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-f-any-env.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-2uwyi9","slug":"r13-e9-5-views","title":"W1: axum ui feature (maud views, htmx)","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-e9-5-views.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-55inb3","slug":"r13-e9-1-promote","title":"W1: promote id_effect_ai to integration","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-e9-1-promote.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-o3mnz0","slug":"r13-e3-3-relations","title":"W1: ORM relations + eager loading + Entity::FIELDS","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-e3-3-relations.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-d7qwhu","slug":"r13-e9-4-i18n-core","title":"W1: id_effect_i18n core (Fluent catalog, plurals, Locale)","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-e9-4-i18n-core.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-9x4e24","slug":"r13-f-client-batching","title":"W1: F27/F56 generated-client batching + HttpTransport headers","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-f-client-batching.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-jpxbhs","slug":"r13-f-stream-chunking","title":"W1: F57 live stream partial chunks","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-f-stream-chunking.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-64yymd","slug":"r13-f-eventlog-head","title":"W1: F21 EventLog::head + Subscription::from_now","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-f-eventlog-head.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-hqb6sh","slug":"r13-e9-3-admin-core","title":"W2: id_effect_admin core (AdminSite, list/detail, permissions)","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-e9-3-admin-core.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-7mq6sz","slug":"r13-e9-1-tools","title":"W2: AI tools, structured output, ToolLoop","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-e9-1-tools.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-i60zro","slug":"r13-e9-1-embeddings","title":"W2: AI embeddings (OpenAI, Ollama)","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-e9-1-embeddings.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-5gimx6","slug":"r13-e3-3-pagination","title":"W2: keyset pagination","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-e3-3-pagination.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-d82b9b","slug":"r13-e3-3-hooks","title":"W2: entity lifecycle hooks + outbox","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-e3-3-hooks.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-1ru5me","slug":"r13-e9-4-i18n-web","title":"W2: i18n negotiation, axum layer, Schema messages, maud","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-e9-4-i18n-web.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-ua2lw4","slug":"r13-e5-5-tenancy","title":"W2: TenantResolver + TenantRouter","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-e5-5-tenancy.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-47pqck","slug":"r13-e9-3-admin-edit","title":"W3: admin create/edit/delete, relations, bulk, audit","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-e9-3-admin-edit.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-07dfve","slug":"r13-e9-1-mcp","title":"W3: MCP server and client","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-e9-1-mcp.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-xpedhx","slug":"r13-f-h3-settings","title":"W3: F25/F36 h3 early data + congestion switch","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-f-h3-settings.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-bvwb9u","slug":"r13-f-ws-credit","title":"W3: F39 ws control-frame cap","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-f-ws-credit.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-pdbe88","slug":"r13-f-mesh-session","title":"W3: F30/F34 node session pump, mesh trace + liveness","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-f-mesh-session.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-saufjy","slug":"r13-f-ws-client-tls","title":"W3: F40 WsClient wss:// + postcard","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-f-ws-client-tls.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-c8i4zl","slug":"r13-e9-5-leptos","title":"W3: Leptos SSR recipe","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-e9-5-leptos.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-x2qmeh","slug":"r13-e1-3-generate-admin","title":"W4: id-effect generate admin","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-e1-3-generate-admin.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-a8w9hx","slug":"r13-e-fuzz-security","title":"W4: breadth fuzz targets + compile-fail security cases","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-e-fuzz-security.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-j3gf9e","slug":"r13-e-benches","title":"W4: eager/i18n/ui benches and gates","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-e-benches.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-ye7a0y","slug":"r13-f-webtransport-host","title":"W4: F43/F45 host WebTransport + Deno test","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-f-webtransport-host.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-l9ih4g","slug":"r13-f-ext-connect","title":"W4: F15 RFC 8441 WebSockets over h2","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-f-ext-connect.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-5gvyft","slug":"r13-f-rpc-retire","title":"W4: F46 rpc check --allow-retired","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-f-rpc-retire.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-tdjahc","slug":"r13-w5-kit-template","title":"W5: kit breadth features + templates","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-w5-kit-template.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-xlpdlx","slug":"r13-w6-pipeline","title":"W6: Pipeline reference app core","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-w6-pipeline.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-v0qnuc","slug":"r13-f-relay-host","title":"W6: F59 Relay on host h3/WebTransport, kit only","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-f-relay-host.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-6qnxm6","slug":"r13-w7-pipeline-backoffice","title":"W7: Pipeline back office (admin, ui, i18n)","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-w7-pipeline-backoffice.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-fjrbf4","slug":"r13-w8-level-audit","title":"W8: level audit of the nine rows","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-w8-level-audit.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-pwqw3c","slug":"r13-w8-kpis","title":"W8: security + DX KPIs 0.13","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-w8-kpis.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-bpu8pp","slug":"r13-x-rescore","title":"Exit: blind re-score + REPORT-0.13.0","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-x-rescore.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} +{"id":"tsk-mvhf59ri-pqqwbl","slug":"r13-t0-migration-guide","title":"Exit: MIGRATION-0.13 + CHANGELOG","state":"draft","spec_path":".maestro/specs/releases/0.13/r13-t0-migration-guide.md","mission_id":"pln-mvhf4op9-yl99n0","blocked_by":[],"created_at":"2026-10-21T01:13:47.694Z","updated_at":"2026-10-21T01:13:47.694Z"} From 49c577dedcfb6828996922c10fb7292e0107d4a7 Mon Sep 17 00:00:00 2001 From: Tom Wieland Date: Wed, 21 Oct 2026 03:30:03 +0200 Subject: [PATCH 04/87] docs(adr): ADR 0015 breadth batteries, Pipeline definition and 0.13 restatement (r13-t0-adr0015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0015 (Proposed) records 0.13 D1-D15 and the Security table as rules B1-B23 with their enforcement points, and pins MCP to specification revision 2026-07-28. PLAN-OF-PLANS gains the Pipeline definition (§5 T9), restates §6 0.13.0 on the measured 0.12.0 baseline (E 85.2, RT 87.8, red <= 3) and a §11 row. The release spec scorecard block is restated and carries sec.supply over from 0.12.0. scorecard.json is not edited (D13). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Wieland --- .maestro/specs/releases/0.13.0.md | 13 +- .maestro/specs/roadmap/PLAN-OF-PLANS.md | 36 +- CHANGELOG.md | 11 + docs/adrs/0015-breadth-batteries.md | 473 ++++++++++++++++++++++++ 4 files changed, 522 insertions(+), 11 deletions(-) create mode 100644 docs/adrs/0015-breadth-batteries.md diff --git a/.maestro/specs/releases/0.13.0.md b/.maestro/specs/releases/0.13.0.md index 01088bf71..41b32728d 100644 --- a/.maestro/specs/releases/0.13.0.md +++ b/.maestro/specs/releases/0.13.0.md @@ -11,15 +11,16 @@ prerequisites: - '0.12 exit facts this plan builds on, re-checked at kickoff: `docs/scorecard/results/v0.12.0.json` and `REPORT-0.12.0.md` stored (the baseline; absent at writing); web.rpc 100, web.realtime >= 88, dist.tls >= 88 measured; ADR 0014 Accepted (0.12 D15); the 0.12 overlay follow-ups F51, F58, F60 closed by the 0.12 exit; `security_surface.rs` PENDING list empty (F41).' scorecard: method: "2.2 (PLAN-OF-PLANS §7.0b)" - provisional: true # results/v0.12.0.json is not stored yet; r13-t0-adr0015 restates every number below on the measured 0.12.0 baseline - baseline_version: "0.12.0" # planned 0.12.0 = E 80.9 (#1), RT 84.8, red 7 (scorecard.sh --targets 0.12.0) - target_E: 84.3 # bash scripts/scorecard.sh --targets 0.13.0 (method 2.2), rank #1; .NET 78.8; gate = 83.3 - target_RT: 86.6 # same command, rank #1 + provisional: true # restated on the measured 0.12.0 baseline by r13-t0-adr0015 (2026-10-21); stays true until the owner accepts ADR 0015 (D16) + baseline_version: "0.12.0" # measured 0.12.0 = E 81.5 (#1), RT 85.9, red 7 (scorecard.sh --version 0.12.0; results/v0.12.0.json); planned was E 80.9, RT 84.8 + target_E: 85.2 # bash scripts/scorecard.sh --targets 0.13.0 (method 2.2) on the measured 0.12.0 baseline, rank #1; .NET 78.8; gate = 84.2 (was 84.3 on the planned 0.12.0) + target_RT: 87.8 # same command, rank #1 (was 86.6) max_red_rows: 3 # same command (eco.backing, eco.community, eco.production stay red: people and time, PLAN-OF-PLANS §9) rows_in_scope: [data.orm, web.ui, web.admin, ai.llm, eco.i18n, eco.tenancy] # stored 0.13.0 targets 88, 67, 50, 75, 63, 75 (moved from 0.12.0 by 0.12 D1, values unchanged) rows_kept: [web.rpc, web.realtime, dist.tls] # the T12 follow-up leaves edit their code; no drop allowed (scorecard.sh --diff) - planned_margin: { web.ui: 83, web.admin: 88, ai.llm: 100, eco.tenancy: 88, E: 85.9, RT: 87.5 } # if every margin check lands; never written as targets (D13) - carry_over: [] # filled at kickoff from REPORT-0.12.0 (any 0.12 row below its target becomes a carry-over row here) + planned_margin: { web.ui: 83, web.admin: 88, ai.llm: 100, eco.tenancy: 88, E: 86.7, RT: 88.7 } # if every margin check lands (jq copy scored with --file --targets 0.13.0); never written as targets (D13) + carry_over: # from REPORT-0.12.0 "Misses and carry-over"; sec.supply is the only 0.12 row below its target + - { row: sec.supply, measured_0_12_0: 88, target_0_12_0: 100, target_0_13_0: 100, open_check: "sec.supply#7", leaf: r13-w8-kpis, note: "public security-release history: SECURITY.md lists 0.10.1 with every advisory pending and no GHSA is published; r13-w8-kpis verifies SECURITY.md Security releases against the published advisories; publishing them is a release-automation or owner step" } non_goal_rows: [web.graphql] security_kpis: { open_highs: 0, adr_0013: "every new surface complies (R1 R2 R3 R4 R6 R7 R9 R10 enforcement points named per leaf)", fuzz_targets: "≥ 5 new decoder targets (≥ 22 total) in CI, no open crash", compile_fail_security_cases: "+2 (admin site without a guard; MCP endpoint without an access decision)", secure_stack_overhead: "secure_stack − bare ≤ 5 µs (unchanged)", unary_rpc: "rpc/unary_json − rpc/axum_json_handler ≤ 3 µs (unchanged, ADR 0014 D5)" } bench_gates: { eager: "orm/eager_100x10 / orm/n_plus_one_100x10 ≤ 0.25 (pair gate, SQLite in-memory)", i18n: "i18n/format_plural reported, budget written after measuring (D15)", ui: "ui/render_list_50 reported, budget written after measuring (D15)", unchanged: "secure-stack delta, unary RPC delta, batch pair, h3/h2 pair" } diff --git a/.maestro/specs/roadmap/PLAN-OF-PLANS.md b/.maestro/specs/roadmap/PLAN-OF-PLANS.md index ea9a3e05f..d26906144 100644 --- a/.maestro/specs/roadmap/PLAN-OF-PLANS.md +++ b/.maestro/specs/roadmap/PLAN-OF-PLANS.md @@ -225,6 +225,21 @@ Each track lists its epics. Release slots are in §6, and row targets are in `sc - **E9.4 i18n:** `id_effect_i18n` over fluent with locale negotiation (0.13). - **E9.5 UI:** recipes for HTMX+maud and Leptos (0.13). +**The Pipeline reference app** (0.13 exit KPI; defined by +[ADR 0015](../../../docs/adrs/0015-breadth-batteries.md) §1, 0.13 D1) is `examples/reference/pipeline` +(package `pipeline`, tier example, built only through `id_effect_kit`): a multi-tenant +document-intake pipeline and its back office. Documents are ingested by RPC or upload with a job per +stage; a model extracts a typed `Extraction` (structured output through `Schema`); a bounded tool +loop classifies them with typed tools; embeddings flag near-duplicates within the tenant; reviewers +work in the admin generated by `id-effect generate admin` and an htmx + maud review queue in English +or German; an `after_update` lifecycle hook publishes `DocumentPublished` through the outbox; an MCP +server exposes the documents to outside agents and an MCP client enriches them. Data model `Source` +1:N `Document` N:M `Label`, `Document` 1:N `Extraction`, all tenant-scoped; tenant `acme` (en) on the +main database, `globex` (de) on its own. AI in CI is deterministic (scripted model, wiremock'ed +vendors). It is the smallest app that exercises `data.orm`, `web.ui`, `web.admin`, `ai.llm`, +`eco.i18n` and `eco.tenancy` at production level; it is always called "the Pipeline reference app" +because `id_effect_resilience::Pipeline` is a policy type. + ### T10 · Hardening & release candidate (0.18 – 0.19, L14 and L15) - **E10.1 API audit:** one pass over every crate for naming, error types, `Layer`/capability shapes and prelude contents, with findings fixed or recorded as accepted (0.18). - **E10.2 Shim removal:** remove every deprecation shim and `#[deprecated]` item so that 1.0 starts clean; each removal gets a migration-guide entry (0.18). @@ -337,11 +352,11 @@ whose target moves in that release. | **0.10.1** | Security patch | E11.1 | 0 | = 0.10 measured | = 0.10 measured | = 0.10 measured | 0 open high findings except H8; a regression test per closed high; Ledger green; bench gates pass; GHSA advisories published | | **0.11.0** | Distribution & hardening | E2.5, E7.5, E8.1–E8.3, E11.2, E11.3, E11.4 part 1 | 12 | 79.2 (#1) | 83.7 (#1) | ≤ 9 | 0 open highs; ADR 0013 accepted with an enforcement point per rule; Scorecard ≥ 7.5; Best Practices Passing; a verified attestation per crate; ≥ 8 fuzz targets | | **0.12.0** | One call surface | E12.1–E12.5 | 3 | 80.9 (#1) | 84.8 (#1) | ≤ 7 | Relay reference app (3-node chat + presence over HTTP/3 and WebSocket) green; `web.rpc` 100, `web.realtime` ≥ 88, `dist.tls` ≥ 88; the T12 bench gates (unary delta ≤ 3 µs, ADR 0014 D5) | -| **0.13.0** | Breadth | E1.3 admin, E3.3 part 2, E5.5 tenancy, E9.1, E9.3–E9.5 | 6 | 84.3 (#1) | 86.6 (#1) | ≤ 3 | Pipeline reference app green | -| **0.14.0** | Sweep & promote | E13.1, E13.2 | 0 † | 84.3 (#1) † | 86.6 (#1) † | ≤ 3 † | Warden reference app green; every promoted row cites a reference-app CI test | -| **0.15.0** | Medium items | E13.3, E13.4, E11.5 starts | 0 † | 84.3 (#1) † | 86.6 (#1) † | ≤ 3 † | AppHost brings up the Ledger and its services with one command; outside audit applied for | -| **0.16.0** | Scale | E13.5 | 0 † | 84.3 (#1) † | 86.6 (#1) † | ≤ 3 † | split-brain and sharding chaos tests green on Relay and Warden | -| **0.17.0** | Ergonomics & cloud | E13.6 | 0 † | 84.3 (#1) † | 86.6 (#1) † | ≤ 3 † | cold build of the `service` template < 60 s; migration diffing used by the Ledger | +| **0.13.0** | Breadth | E1.3 admin, E3.3 part 2, E5.5 tenancy, E9.1, E9.3–E9.5 | 7 | 85.2 (#1) | 87.8 (#1) | ≤ 3 | Pipeline reference app green | +| **0.14.0** | Sweep & promote | E13.1, E13.2 | 0 † | 85.2 (#1) † | 87.8 (#1) † | ≤ 3 † | Warden reference app green; every promoted row cites a reference-app CI test | +| **0.15.0** | Medium items | E13.3, E13.4, E11.5 starts | 0 † | 85.2 (#1) † | 87.8 (#1) † | ≤ 3 † | AppHost brings up the Ledger and its services with one command; outside audit applied for | +| **0.16.0** | Scale | E13.5 | 0 † | 85.2 (#1) † | 87.8 (#1) † | ≤ 3 † | split-brain and sharding chaos tests green on Relay and Warden | +| **0.17.0** | Ergonomics & cloud | E13.6 | 0 † | 85.2 (#1) † | 87.8 (#1) † | ≤ 3 † | cold build of the `service` template < 60 s; migration diffing used by the Ledger | | **0.18.0** | Consolidation (no new crates) | E10.1–E10.4, E10.6, E11.4 complete, E1.7 compile budget | 0 | ≥ 0.17 measured | ≥ 0.17 measured | ≤ 0.17 measured | No row −5 or worse; soak window passes for all four reference apps; ≥ 15 fuzz targets in CI with no open crash; ASVS L2 ≥ 90 %; K2 ≤ 5 s; K6 budget met; zero deprecation shims left | | **0.19.0** | Adoption & release candidate | E0.3 freeze RFC, E0.5 consumer migration, E1.6 docs complete, E10.8, E11.5 audit | 0 | ≥ 0.18 measured | ≥ 0.18 measured | ≤ 0.18 measured | API freeze RFC accepted; downstream consumers migrated; K5 = 100 %; consolidated migration guide; outside audit published with fixes; `1.0.0-rc.1` published | | **1.0.0** | Stability | E0.3 LTS, E0.4, E0.5 case studies, snapshot refresh | 24 (still stored at 1.0.0; L15 moves them earlier) | 86.4 (#1), and ≥ 0.19 measured | 88.4 (#1) | ≤ 1 | K1 ≤ 90 s, ≥ 5 public adopters, ≥ 3 maintainers; Scorecard ≥ 8.5; Best Practices Silver; ASVS L2 100 %; 0 open highs; **calendar gates:** the freeze has held ≥ 4 weeks with no accepted breaking change, and ≥ 2 adopters have run an rc in production ≥ 30 days | @@ -361,6 +376,16 @@ from `scripts/scorecard.sh --targets 0.12.0` / `--targets 0.13.0` on the measure `web.admin`, `ai.llm`, `eco.i18n`, `eco.tenancy`) moved 0.12.0 → 0.13.0 with their values (D1). 0.12.0 "Rows" counts `web.rpc`, `web.realtime` and the `sec.supply` carry-over from 0.11.0. +The 0.13.0 cell, and the † cells that carry it, were restated at the 0.13 kickoff (leaf +`r13-t0-adr0015`, [ADR 0015](../../../docs/adrs/0015-breadth-batteries.md) §17) from +`scripts/scorecard.sh --targets 0.13.0` on the measured 0.12.0 baseline (E 81.5 #1, RT 85.9, red 7): +E 85.2 (#1), RT 87.8, red ≤ 3, so the 0.13 gate is 84.2. The stored row targets did not change (0.13 +D13): the step from 84.3 / 86.6 comes from 0.12.0 rows measured above their plan (`web.realtime`, +`dist.tls`, `web.server`, `data.jsonschema`, `conc.perf`, `fault.actors`), which METHOD §5.1 carries +forward. 0.13.0 "Rows" counts the six breadth rows and the `sec.supply` carry-over from 0.12.0 +(target 100, `sec.supply#7`). On the same baseline `--targets 1.0.0` reads E 87.2, RT 89.2, red 1; +the 1.0.0 cell is left for the next retro (§7.5). + † No row target is stored for 0.14.0–0.17.0 yet, so the tool carries the 0.13.0 plan forward (METHOD §5.1). Each of those kickoffs moves its share of the 24 rows still targeted at 1.0.0. @@ -537,3 +562,4 @@ snapshot. | 2026-10-18 | Method 2.2 re-base done (§7.0b steps 1–3, leaf `r11-m22-rebase`); **owner acceptance pending** (step 4). Non-goals leave the score for every framework (row `web.graphql`; checks `fault.reload#2 #6 #7`, `dx.compile#3`, `web.ui#3 #4`, `data.backends#4 #5`); `dx.repl#3` stays scored because id_effect meets it (rule recorded in METHOD §3.3, 0.11.0.md D3; `dx.repl` stays 75). .NET scope adds Orleans and the Durable Task Framework (`docs/scorecard/alignment-2.2.json`): .NET U/E/RT 75.1/77.3/71.1 → 75.8/77.9/71.8 after step 1 → 76.7/78.8/72.8 after step 2. id_effect, 2.1 → 2.2 (E, RT, rank E): 0.6.0 37.3 → 37.8, 46.0 → 46.5, #10; 0.7.0 43.2 → 43.7, 56.1 → 56.5, #9; 0.8.0 55.2 → 55.6, 64.4 → 64.9, #5; 0.9.0 67.2 → 68.1, 70.8 → 71.4, #3; 0.10.0 75.5 → 76.6, 78.4 → 79.5, #2 (red 12 → 11). Planned: 0.11.0 committed slot 78.0 (2.1) → 79.2 (#1, RT 83.7, red ≤ 9; .NET 78.8); 0.12.0 82.5 → 83.9 (#1); 1.0.0 85.0 → 86.4 (#1). Targets moved: `web.rpc`, `web.realtime` 0.11.0 → 0.12.0; 0.11.0 bonus `conc.stm` 89, `sec.supply` 100, `test.qa` 100; `web.graphql` targets removed; targets of the four rows with non-goal checks re-based to the same check count. §1 and §6 restated; 0.11.0.md stays `provisional: true` until the owner accepts. | | 2026-10-19 | Owner decision: the T11 secure-stack KPI becomes an absolute budget, `gate/axum_request_secure_stack − gate/axum_request_bare ≤ 5 µs` per request (same run, same runner), replacing ≤ 1.15×; review at 0.18. After bf7be935 (inline request fibers) a bare request is 0.90 µs, so 1.15× would leave ~135 ns for CSP nonce, CSRF masking, API-key hashing, redaction, guard and rate limiting. Measured: bare 0.90 µs, secure 5.55 µs, +4.65 µs (passes; ratio 6.2×, informational). `docs/benchmarks/gates.toml` gates it with a `kind = "delta"` gate; `bench.yml` enforces `budget,delta`; T11 and 0.11.0.md (security_kpis, Risk 8, exit gate, leaves) restated. | | 2026-10-19 | 0.12 kickoff, leaf `r12-t0-adr0014`: ADR 0014 "One call surface" written as Proposed (owner acceptance at the 0.12 exit, D15). **D1:** the six 0.12.0 row targets that predate L15 (`data.orm` 88, `web.ui` 67, `web.admin` 50, `ai.llm` 75, `eco.i18n` 63, `eco.tenancy` 75) move to 0.13.0 "Breadth", values unchanged. **D2:** `web.rpc` 0.12.0 target 88 → 100 (§6 KPI). **D5:** the T12 unary gate "≤ 1.10× raw axum" becomes an absolute delta, `rpc/unary_json − rpc/axum_json_handler ≤ 3 µs` per call, same run and runner (ratio informational; inline request fibers since bf7be935 make a bare request 0.90 µs, so 1.10× would leave ~90 ns). §6 rows 0.12.0 and 0.13.0 restated from `scorecard.sh --targets` on the measured 0.11.0 baseline: 0.12.0 E 80.9 (#1), RT 84.8, red ≤ 7; 0.13.0 E 84.3 (#1), RT 86.6, red ≤ 3. The release spec planned 80.6 / 84.4 and 84.0 / 86.2 from the 0.11.0 targets; the measured 0.11.0 scores and the `sec.supply` 0.12.0 carry-over (100) account for the difference. METHOD §5.2 and the self-test copy in `scripts/scorecard.sh` restated (0.12.0 target 80.9; 1.0.0 target 86.6, RT 88.5, because `web.rpc` 100 now carries to 1.0.0). | +| 2026-10-21 | 0.13 kickoff, leaf `r13-t0-adr0015`: ADR 0015 "Breadth batteries" written as Proposed (owner acceptance at the 0.13 exit, 0.13 D16), recording 0.13 D1–D15 and the release spec's Security table as rules B1–B23 with their enforcement points. **D1:** the Pipeline reference app is defined (§5 T9, ADR 0015 §1). **D13:** the stored 0.13.0 row targets are unchanged and margins are reported, not targeted; `docs/scorecard/scorecard.json` is not edited. §6 row 0.13.0 (and the † rows 0.14.0–0.17.0) restated from `scorecard.sh --targets 0.13.0` on the measured 0.12.0 baseline (E 81.5, RT 85.9, red 7): E 84.3 → 85.2 (#1), RT 86.6 → 87.8, red ≤ 3, gate 84.2; full margin 86.7. Carry-over from REPORT-0.12.0: `sec.supply` 88 vs 100 (`sec.supply#7`, public security-release history), stored 0.13.0 target 100, owned by `r13-w8-kpis`. MCP pinned to specification revision 2026-07-28 (current stable on 2026-10-21; stateless, so the release spec's MCP session rule binds server-minted handles to the principal; `rmcp` 3.x as the test peer). The release spec's `scorecard:` block is restated to match; it stays `provisional` until the owner accepts ADR 0015. | diff --git a/CHANGELOG.md b/CHANGELOG.md index 96698954a..5cd42c9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.13.0 — Unreleased — Breadth + +### Documentation + +- [ADR 0015 "Breadth batteries"](docs/adrs/0015-breadth-batteries.md) (Proposed; r13-t0-adr0015): + the Pipeline reference app definition, crate placement for admin, the axum `ui` feature, i18n + and AI, MCP pinned to specification revision 2026-07-28, deterministic AI in CI, the tool-safety + budgets, tenant resolution, the keyset cursor format, lifecycle hooks inside the transaction, + and the ADR 0013 rules (B1–B23) every breadth surface follows. PLAN-OF-PLANS §6 restates 0.13.0 + on the measured 0.12.0 baseline: target E 85.2 (#1), RT 87.8, red ≤ 3; `sec.supply` carried over. + ## 0.12.0 — 2026-10-20 — One call surface RPC becomes the first-party call surface (ADR 0014): `id_effect_rpc` leaves `labs/` with six diff --git a/docs/adrs/0015-breadth-batteries.md b/docs/adrs/0015-breadth-batteries.md new file mode 100644 index 000000000..2511612ad --- /dev/null +++ b/docs/adrs/0015-breadth-batteries.md @@ -0,0 +1,473 @@ +# ADR 0015 — Breadth batteries + +## Status + +Proposed (2026-10-21). Release 0.13.0 "Breadth", track T9 with E1.3, E3.3 part 2 and E5.5, leaf +`r13-t0-adr0015`. The owner moves it to Accepted ([0.13.0 D16](../../.maestro/specs/releases/0.13.0.md)); +the 0.13.0 exit gate needs "Accepted" and every rule's enforcement point green at the tag. Until +then every 0.13 leaf cites this ADR as Proposed. The decisions come from the release spec's "Owner +decisions" table (D1–D15) and its "Security — ADR 0013 applied to the breadth batteries" table. +Where those disagree with PLAN-OF-PLANS, PLAN-OF-PLANS wins; where this ADR adds a fact the release +spec could not know (the MCP revision current on 2026-10-21, the measured 0.12.0 baseline), this +ADR records it and the leaf that owns the code follows it. + +Owner decisions already taken (not re-asked): everything buildable ships before 1.0 (L15); UI is +integrated, not built (L7); batteries are effect-native and built on proven Rust crates (L2); +reference apps are the consumers (L4); no JavaScript toolchain in the repository (L12); GraphQL is a +non-goal; the six breadth row targets moved from 0.12.0 to 0.13.0 with their values unchanged (0.12 +D1, ADR 0014 §15). The nine open questions of the release spec are recorded at the end with the +defaults this ADR takes; the owner can overturn any of them before accepting it. + +## Context + +At 0.12.0 (measured, `docs/scorecard/results/v0.12.0.json`) id_effect scores E 81.5 (#1, .NET 78.8), +RT 85.9 (#1) with 7 red rows. Four of the red rows are breadth the platform does not ship at all: + +| Row | 0.12.0 | What exists | +|---|---|---| +| `data.orm` | 63 | `Repository`, `#[derive(Entity)]`, global soft-delete/tenant filters, offset pages. No relations, no eager loading, no lifecycle hooks, no keyset pages. | +| `eco.tenancy` | 75 | `CurrentTenant` (fail closed), `id_effect_authz::{Tenanted, TenantScope}`, hash-chained audit. No shared tenant resolver, no per-tenant connection routing. | +| `ai.llm` | 38 | `labs/id_effect_ai` (`publish = false`): OpenAI and Anthropic chat, streaming, retries. No tools, embeddings, structured output, MCP or agent loop; labs cap 39. | +| `web.admin` | 1 | A template stub referenced only by scorecard evidence. | +| `web.ui` | 1 | No web templating (minijinja is used by mail only). | +| `eco.i18n` | 1 | Nothing. | + +PLAN-OF-PLANS §6 names a "Pipeline reference app" as the 0.13 exit KPI but never defines it. Every +row above needs a consumer at production level (L4), and every new surface (an admin, server +rendered views, a model that calls tools, an MCP endpoint, a locale header, a page cursor, a tenant +from a request) is an attack surface that ADR 0013 must cover from its first commit. This ADR fixes +the shape of each battery, the app that proves them, and the rule each surface obeys. + +## Decision + +### 1. The Pipeline reference app (D1) + +**Pipeline** (`examples/reference/pipeline`, package `pipeline`, tier `example`, `publish = false`, +built only through `id_effect_kit`) is a multi-tenant document-intake pipeline and its back office: + +1. **Ingest:** documents arrive by RPC mutation or upload (storage) for a tenant; one job per stage + (`id_effect_jobs`). +2. **Extract:** a model turns text into a typed `Extraction` (structured output decoded through + `Schema`, `ai.llm#4`). +3. **Classify:** a bounded tool loop calls `lookup_source` and `list_labels` (typed tools, `#3`, + `#8`) and attaches `Label`s. +4. **Embed:** embeddings (`#5`) flag near-duplicates within the tenant over a bounded candidate set. +5. **Review:** reviewers work in the admin generated by `id-effect generate admin` (`web.admin`) and + in an htmx + maud review queue (`web.ui`), in English or German (`eco.i18n`: Accept-Language plus + a per-user override). +6. **Publish:** an `after_update` lifecycle hook (`data.orm#7`) writes `DocumentPublished` to the + outbox in the same transaction; an RPC subscription shows live status. +7. **Agents:** an MCP server exposes `search_documents` / `get_document` to outside agents (guarded, + Origin-checked); an MCP client enriches documents from a fixture MCP tool server (`#6`). + +Data model: `Source` 1:N `Document` N:M `Label` (join table `document_labels`), `Document` 1:N +`Extraction`, all tenant-scoped. Tenant `acme` (locale `en`) shares the main database; tenant +`globex` (locale `de`) is routed to its own database (`eco.tenancy#8`, margin). AI in CI is +deterministic (§8). The exit KPI: CI job `reference-pipeline` green on Postgres with both tenants, +both locales, the admin, MCP server and client, and a clean prod posture with zero accepted risks. + +The app is built by two leaves (`r13-w6-pipeline` core, `r13-w7-pipeline-backoffice`) split by +directory, so each agent's context stays bounded. The name collides with +`id_effect_resilience::Pipeline` (a policy type); the book and README always say "the Pipeline +reference app". It is the smallest app that exercises all six rows at production level: an +admin-only app would leave `ai.llm` and `eco.tenancy#8` unproven, a chat app would leave +`web.admin` and `data.orm#7` unproven. + +### 2. Crate placement (D2, D3, D5, D6) + +| Battery | Where | Tier at entry | Depends on | +|---|---|---|---| +| Admin (D2) | new crate `crates/id_effect_admin` | integration | axum `ui`, sql `repository`/`audit`, authz; optional i18n | +| Server-rendered UI (D3) | `id_effect_axum` feature `ui` (no new crate) | integration (axum's) | `maud` (exact pin), the vendored htmx file | +| i18n (D5) | new crate `crates/id_effect_i18n` | integration | `fluent-bundle`, `fluent-langneg`, `intl_pluralrules` (exact pins); optional axum, maud, Schema | +| AI (D6) | `git mv labs/id_effect_ai crates/id_effect_ai`, `publish = false` removed | integration | `id_effect_platform` client for all HTTP; `id_effect_config::Secret` | +| Leptos (D4) | recipe `examples/recipes/leptos-ssr`, outside the workspace | example | — | + +- Each new crate moves to tier `platform` when the Pipeline depends on it (ADR 0010 check P), which + the 0.13.0 exit gate requires for all three. ADR 0010 Amendment 9 (leaf `r13-t0-skeletons`) admits + them; `scripts/validate-crate-packaging.sh` publishes them in dependency order: i18n after + core/axum, ai after platform/config, admin after sql/axum/authz/i18n, all three before kit and + tool. +- The admin is a crate, not generated code only: generated code cannot reach "documented" (no + published, tested crate to point at), and `id_effect_axum` must not depend on `id_effect_sql`. + `id-effect generate admin ` writes a registration module against the crate's API + (`AdminSite::register::()`). +- New kit features `ai`, `mcp`, `embeddings`, `admin`, `ui`, `i18n`, `tenancy` are in `full` and + **off by default**, so the `service` template's K1/K2/K6 do not move; the K6 cold-build delta of + each is reported at exit. + +### 3. Server-rendered UI (D3) + +`id_effect_axum` feature `ui`: maud `Markup` as a typed route response; `Component` (a props struct +plus slot children); `Layout` and partials, choosing fragment or full page by `HX-Request`; htmx +helpers (`HxRequest`, `HxTrigger`, a same-origin-only `HxRedirect`); form helpers bound to `Schema` +errors (margin, `web.ui#5`). + +- maud is chosen over minijinja because templates are compiled and type-checked with the handler, + and escaping is the default rather than a configuration. Raw HTML is reachable only through + `ui::trusted_html`, a reviewed allowlist module; a grep test keeps `PreEscaped` out of every other + file of `crates/id_effect_{axum,admin}/src`. +- **htmx 2.0.x is one vendored, hash-pinned file** (`crates/id_effect_axum/assets/`), served from the + app's own origin, its sha384 and 0BSD license recorded in `assets/README.md` and checked by + `htmx_asset_hash_matches_readme`. No CDN, no bundler, no npm (L12 spirit). The served page's CSP + is `script-src 'self'`; htmx runs with `htmx.config.allowEval = false` and + `htmx.config.selfRequestsOnly = true`. Updating the file is a reviewed leaf. +- Server-driven interactive views (`web.ui#3`) and an asset pipeline (`#4`) remain non-goals (L7). + +### 4. Leptos is a recipe (D4) + +`examples/recipes/leptos-ssr` (outside the workspace, its own lockfile, like `labs/`) shows Leptos +SSR with hydration served by the id_effect axum host and calling an id_effect RPC service, with a +pinned Leptos and no npm (cargo-leptos or the wasm target only). It has its own CI job +`recipe-leptos` and a book chapter. `web.ui#7` (a first-class component framework) is not counted; +a first-party Leptos or Dioxus crate stays a 1.x ecosystem lever (PLAN-OF-PLANS §9). The recipe is +first in the scope-cut order. + +### 5. i18n over Project Fluent (D5) + +`id_effect_i18n` wraps Fluent rather than gettext (no CLDR plural selectors in the message format, +`.po` tooling outside Rust) or ICU4X MessageFormat (MF2 tooling still young). Fluent gives CLDR +plurals through `intl_pluralrules`, and `fluent-langneg` does negotiation. 0.13 scope: + +- `#1` a `Catalog` of Fluent resources per locale, loaded from an allowlisted directory or embedded, + with a missing-key fallback chain and a startup check that every locale has every key of the + default locale; +- `#2` CLDR plurals through Fluent selectors; +- `#3` Accept-Language negotiation with a cookie or per-user override, bounded parsing and a locale + allowlist; +- `#5` translated `Schema` validation messages (error codes from `schema/parse_errors.rs` map to + message ids; `en` and `de` ship); +- `#7` a `Locale` capability (`CurrentLocale` in the environment) with `with_locale` switching per + request or fiber; +- `web.ui#8`: `maud::Render` for messages, arguments escaped, Unicode bidi isolation on. + +Locale-aware number/date/currency formatting (`#4`), extraction tooling (`#6`) and localized routes +(`#8`) are 0.15 (E13.3). + +### 6. The AI crate (D6) + +`id_effect_ai` leaves `labs/` as one crate with features `openai`, `anthropic`, `ollama`, `cursor`, +`tools`, `embeddings`, `mcp`, `mcp-http`, `test-util`. Chat: OpenAI and Anthropic. Embeddings: OpenAI +and Ollama. The labs Cursor agents module stays behind the non-default `cursor` feature with its +fixture tests (no API loss; Open question 3). Module layout: `chat`, `stream`, +`vendors/{openai,anthropic,ollama}/{mod,chat,embed}.rs`, `tools/`, `embeddings/`, `mcp/`, `testing`. + +- **All HTTP goes through `id_effect_platform`'s client**, so the 0.11 SSRF policy, body caps, + redirects off and timeouts apply to every provider base URL and every MCP client connection. No + adapter builds its own `reqwest::Client`. +- API keys are `id_effect_config::Secret`. Prompts and completions are never logged unless + `dangerous_log_prompts()` is taken (recorded). `max_tokens` has a finite default; requests have a + timeout; responses and streams have byte caps. +- `#4` structured output decodes through `Schema` (refinements run); a decode failure is a typed + error with one bounded repair retry. `#3` a `Tool` has `HasSchema` input and output, exported as + JSON Schema by `schema/json_schema_export.rs`, and maps to both vendors' tool-call formats. `#8` + `ToolLoop` runs under the §9 budgets. + +### 7. MCP: an own implementation pinned to one revision (D7) + +`id_effect_ai::mcp` is an effect-native MCP server and client (tools, resources, prompts, +cancellation; stdio and Streamable HTTP), not a wrapper around the `rmcp` SDK: tools are the same +`Tool` values the agent loop uses, the HTTP endpoint is an `id_effect_axum` route with a typed +`.guard(g)`, and the client is the platform client. `rmcp` is a **dev-dependency only**, the interop +peer in tests in both directions (`mcp_interop_rmcp_client_against_our_server`, +`mcp_interop_our_client_against_rmcp_server`). + +**Pinned revision: `2026-07-28`**, the current stable MCP specification revision on 2026-10-21 (the +release spec's floor is 2025-06-18). `rmcp` 3.x speaks it, so the interop peer is an `rmcp` 3.x +release, exact-pinned by `r13-e9-1-mcp`. The revision is stateless, and that shapes the +implementation: + +- There is no `initialize` handshake and no protocol-level session (`Mcp-Session-Id` is gone). Each + request carries its protocol version and client capabilities in `_meta`; the server implements + `server/discover`; a request for any other revision is answered with + `UnsupportedProtocolVersionError`. Interop with clients that only speak 2025-11-25 or older is not + in 0.13 (a compatibility path is a later, additive change). +- Server-to-client change notifications use `subscriptions/listen`; request-scoped notifications + (progress) flow on the request's own response stream. Cancellation interrupts the handler fiber + and runs its finalizers: on Streamable HTTP the client closing the response stream is the + cancellation signal; on stdio it is `notifications/cancelled`. +- Streamable HTTP POSTs carry `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name`; the server + answers a request whose headers disagree with its JSON-RPC body with `400` and `HeaderMismatch`, + so routing done by intermediaries on the headers cannot be steered by a different body. The + client mirrors `x-mcp-header` tool parameters into `Mcp-Param-*` headers, validating names and + values as B23 requires of the generated RPC clients. +- Server-initiated input (sampling, elicitation, roots through multi round-trip requests) and the + tasks extension are not offered in 0.13; the client advertises none of them. +- The release spec's "session ids bound to the principal" rule applies to what replaces sessions: + **every identifier the server mints and later accepts back** (a handle passed as a tool argument + for cross-call state) is 128-bit random and bound to the authenticated principal, and one + presented by another principal is refused; a `subscriptions/listen` stream carries only + notifications about resources its principal may read. The test keeps its + name, `mcp_session_bound_to_principal`, because the Security table and `surface_013.rs` look it up + by name. + +If a newer revision becomes current before `r13-e9-1-mcp` starts, that leaf re-pins this section (one +line here and a row in PLAN-OF-PLANS §11) before writing code; it never pins a draft revision. Open +question 4 lets the owner switch to wrapping `rmcp` instead. + +### 8. Deterministic AI in CI (D8) + +No PR or push job calls a live AI vendor, and no API key exists in CI. Vendor adapters are tested +against wiremock fixtures recorded from the vendors' documented examples (each fixture cites its +source page); the Pipeline and the agent loop run on `id_effect_ai::testing::ScriptedModel`, labelled +as scripted in the book and the README. An optional `workflow_dispatch` smoke with repository +secrets may exist; it is never a required check. + +### 9. Tool safety (D9) + +A model has no authority of its own; the library bounds what it can make the program do: + +- A model can call only tools registered on the call's `ToolSet`. An unknown name is answered with a + typed refusal fed back to the model and is **never executed**. +- Arguments are decoded through the tool's `Schema` (refinements included) before the handler runs; + a decode failure is fed back as a typed error. +- Default budgets per loop: **8 steps, 32 tool calls, 60 s**; tool output is capped at **64 KiB** + before it re-enters the context. Every limit is configurable and none can be infinite. +- A tool that writes declares `effects = write` and runs only under an explicit + `ToolPolicy::allow_writes`. Granting it to a set that is exposed over MCP is recorded in the + posture registry (`dangerous_mcp_write_tools`). + +Prompt injection is not solvable by a library; bounded authority is the library's part, and +THREAT-MODEL.md states the residual risk. The Pipeline test +`pipeline_prompt_injection_cannot_call_unlisted_tool` feeds a hostile document through the loop. + +### 10. Tenant resolution (D10) + +- The tenant comes from the authenticated principal's tenant claim (`id_effect_authz::Tenanted`) by + default. Host-based resolution works only through a configured allowlist (a Host outside it has + no tenant). Header-based resolution exists only as `dangerous_tenant_from_header()`, recorded, + even behind a trusted gateway (Open question 7). +- One `TenantResolver` feeds both `id_effect_authz::TenantScope` and `id_effect_sql::CurrentTenant` + through kit feature `tenancy`, so authorization and queries can never disagree about the tenant. +- `TenantRouter` (`eco.tenancy#8`) is an `SqlClient` mapping tenant → pool, a bounded LRU (default + 64 pools) that composes with `RoutingSqlClient` replicas. An unknown tenant has no connection: it + fails closed, it never falls back to the shared database. + +### 11. Keyset cursor format (D11) + +`find_after(PageCursor)` returns `Paged { items, next, prev }` over the primary key and the +declared sort columns, stable under concurrent inserts; offset `Page` stays. The cursor is opaque +base64url of the sort-key tuple plus a fingerprint of the filter set, at most 1 KiB, decoded through +a bounded `Schema` (`PageCursor::decode`, fuzzed as `sql_page_cursor`). It is **not signed**: every +query re-applies the tenant and access scope, so a forged cursor only moves within the caller's own +rows (`tampered_cursor_stays_in_scope`), and an HMAC key would add rotation and secret handling for +no isolation gain. A cursor whose fingerprint differs from the request's filters is refused. Page +size defaults to 50 and is capped at 500. + +### 12. Lifecycle hooks run inside the transaction (D12) + +`EntityHooks` (`before_insert`, `after_insert`, `before_update`, `after_update`, `before_delete`, +`after_delete`) run inside the write's transaction, in that order; a failing hook rolls the write +back. Callbacks after commit would make "write + event" non-atomic; events-only hooks could not +veto a write. The optional `outbox` adapter (sql feature `outbox`) writes an event through +`id_effect_messaging` in the same transaction, which is how the Pipeline publishes +`DocumentPublished`. + +### 13. Stored targets unchanged (D13) + +The stored 0.13.0 row targets in `docs/scorecard/scorecard.json` stay as moved by 0.12 D1: +`data.orm` 88, `web.ui` 67, `web.admin` 50, `ai.llm` 75, `eco.i18n` 63, `eco.tenancy` 75, plus the +`sec.supply` 100 carry-over from 0.12.0. Margins are reported, not targeted. This leaf does not edit +`scorecard.json`; it restates PLAN-OF-PLANS §6 and the release spec's `scorecard:` block on the +measured 0.12.0 baseline (§17). + +### 14. `any_env` (D14) + +`impl Effect { pub fn any_env(self) -> Effect }` in `kernel/effect.rs` +beside `widen`, plus the `LocalEffect` twin: an environment-free effect runs under any environment +without a closure or boxing beyond the existing representation (F52). It lets the F27 generated +client batcher drop its per-caller-environment resolver table. The name is Open question 6. + +### 15. Bench budgets: measure first (D15) + +The eager-loading pair gate `orm/eager_100x10 / orm/n_plus_one_100x10 ≤ 0.25` is written now: it is a +ratio of two measurements of the same stack (SQLite in memory). `i18n/format_plural` and +`ui/render_list_50` start as `report` gates; `r13-e-benches` writes their budgets after measuring, +and the owner confirms them at exit (as 0.12 D5). Every 0.12 gate (secure-stack delta ≤ 5 µs, unary +RPC delta ≤ 3 µs, batch pair, h3/h2 pair) is unchanged. + +### 16. Security rules for the breadth batteries (ADR 0013 applied) + +Every new surface follows ADR 0013: deny by default, finite limits, every weakening a named +`dangerous_*` opt-out that calls `id_effect::observability::posture::record_dangerous` and is listed +by `scripts/check-dangerous.sh`, and a prod posture probe in `crates/id_effect_kit/src/posture.rs`. +Each rule below holds while its enforcement point is green; the leaf named owns it, and +`tests/security_compile_fail/tests/surface_013.rs` asserts that every test id in this table exists. + +| # | Threat | Rule (default) | ADR 0013 | Enforcement point (mechanical) | Leaf | +|---|---|---|---|---|---| +| B1 | Unguarded admin | `AdminSite` mounts only through `.guard(g)` (H8 typestate); there is no public admin. | R1, R7 | compile-fail `r1_admin_site_without_guard`; `admin_denied_without_permission`; posture probe "admin guarded" | r13-e9-3-admin-core, r13-w5-kit-template | +| B2 | Admin action authorization | Each entity × action is a permission (`admin..`); default deny. | R1 | `admin_action_requires_entity_permission` | r13-e9-3-admin-core | +| B3 | Mass assignment | Only fields declared editable are bound; others are ignored and logged. | R1, R4 | `admin_form_ignores_undeclared_fields` | r13-e9-3-admin-edit | +| B4 | Admin bulk amplification | ≤ 100 ids per bulk action; form body ≤ 64 KiB. | R4 | `r4_admin_bulk_action_over_cap_rejected`, `r4_admin_form_over_cap_413`; fuzz `admin_form_body` | r13-e9-3-admin-edit | +| B5 | CSRF on admin and htmx forms | Masked tokens on every non-GET (the 0.11 layer); htmx sends the token header. | R9 | `r9_admin_post_without_csrf_token_refused`, `r9_htmx_requests_carry_masked_token` | r13-e9-3-admin-edit, r13-e9-5-views | +| B6 | XSS in views | maud escapes; raw HTML only through `ui::trusted_html`; CSP `script-src 'self'`, htmx `allowEval = false`, `selfRequestsOnly = true`. | R9 | `ui_escapes_untrusted_by_default`, `no_preescaped_outside_trusted_module` (grep over `crates/id_effect_{axum,admin}/src`), `htmx_asset_hash_matches_readme`, `hx_redirect_refuses_foreign_origin` | r13-e9-5-views, r13-e9-3-admin-core | +| B7 | Admin changes unaudited | Every create/update/delete/bulk writes the 0.9 hash-chained audit log. | R10 (integrity) | `admin_change_written_to_audit_log` | r13-e9-3-admin-edit | +| B8 | Tenant leak through relations | Eager loads and joins re-apply the child's tenant and soft-delete filters. | R1 | `m19_eager_loaded_children_scoped_to_tenant`, `relation_join_cannot_cross_tenant`, `admin_fk_picker_lists_only_tenant_rows` | r13-e3-3-relations, r13-e9-3-admin-edit | +| B9 | Forged or oversize page cursor | Cursor ≤ 1 KiB, fingerprint must match, page size ≤ 500 (§11). | R4 | `r4_page_cursor_over_limit_rejected`, `cursor_with_other_filter_refused`, `r4_page_size_capped`, `tampered_cursor_stays_in_scope`; fuzz `sql_page_cursor` | r13-e3-3-pagination | +| B10 | Tenant from an untrusted source | Principal claim by default; Host only through an allowlist; header only through `dangerous_tenant_from_header`. | R1, R10 | `tenant_resolved_from_principal_by_default`, `host_outside_allowlist_has_no_tenant`, `dangerous_tenant_from_header_is_recorded`; posture probe "tenant not from header" | r13-e5-5-tenancy, r13-w5-kit-template | +| B11 | Per-tenant routing | An unknown tenant has no connection; the pool count is bounded (LRU, default 64). | R1, R4 | `unknown_tenant_has_no_connection`, `r4_tenant_pool_count_bounded` | r13-e5-5-tenancy | +| B12 | Prompt injection → tool abuse | §9: registered tools only; Schema-decoded arguments; write tools need `allow_writes`; bounded loop. | R1, R4 | `ai_unregistered_tool_call_refused`, `ai_tool_arguments_schema_decoded`, `ai_write_tool_denied_without_policy`, `ai_tool_loop_step_budget_enforced`; fuzz `ai_tool_arguments` | r13-e9-1-tools | +| B13 | AI resource exhaustion | Finite `max_tokens`, request timeout, response and stream caps, tool output 64 KiB. | R4 | `r4_ai_max_tokens_finite_by_default`, `r4_ai_stream_over_cap_closed`; fuzz `ai_sse_event` | r13-e9-1-promote, r13-e9-1-tools | +| B14 | Secret and prompt leakage | API keys are `Secret`; prompts and completions are not logged unless `dangerous_log_prompts` is taken. | R2, R3, R10 | `ai_api_key_redacted_in_debug`, `ai_prompts_not_logged_by_default`, `dangerous_log_prompts_is_recorded`; posture probe "prompts not logged" | r13-e9-1-promote, r13-w5-kit-template | +| B15 | Outbound SSRF (provider base URL, MCP client) | The 0.11 platform `SsrfPolicy`; private ranges only through the platform's existing opt-out. | R6 | `ai_base_url_obeys_ssrf_policy`, `mcp_client_obeys_ssrf_policy` | r13-e9-1-promote, r13-e9-1-mcp | +| B16 | MCP server exposure | The Streamable HTTP endpoint needs an access decision; Origin allowlist (DNS rebinding); server-minted ids 128-bit random and bound to the principal (§7); message ≤ 1 MiB; closed in prod unless guarded; write tools over MCP recorded. | R1, R4, R7, R9 | compile-fail `r1_mcp_endpoint_without_access_decision`; `r9_mcp_foreign_origin_refused`, `mcp_session_bound_to_principal`, `r4_mcp_message_over_cap_rejected`, `dangerous_mcp_write_tools_is_recorded`; posture probe "mcp guarded"; fuzz `mcp_jsonrpc_message` | r13-e9-1-mcp, r13-w5-kit-template | +| B17 | Accept-Language and locale abuse | Header parse bounded (≤ 32 ranges, ≤ 256 bytes); catalogs load only allowlisted locales (no path from input). | R4 | `r4_accept_language_over_limit_ignored`, `locale_outside_allowlist_falls_back`; fuzz `i18n_accept_language` | r13-e9-4-i18n-web | +| B18 | Message arguments in HTML | Arguments escaped by maud; Unicode bidi isolation on. | R9 | `i18n_args_escaped_in_html`, `i18n_bidi_isolation_on_by_default` | r13-e9-4-i18n-web | +| B19 | WebSocket over HTTP/2 (RFC 8441, F15) | The Origin allowlist and the 0.12 limits apply to extended CONNECT. | R9, R4 | `r9_h2_extended_connect_foreign_origin_refused`, `h2_websocket_limits_match_h1` | r13-f-ext-connect | +| B20 | 0-RTT made real (F25) | Early data off; the opt-out admits safe methods on the `h3` route only, never `idm/1`. | R10 | `zero_rtt_admits_only_safe_methods`, `zero_rtt_mutation_gets_425`, `mesh_route_never_accepts_early_data`, `zero_rtt_off_by_default` | r13-f-h3-settings | +| B21 | Control frames uncounted (F39) | Credit and ping frames leave the message rate but have their own finite cap (default 4× the data rate). | R4 | `r4_control_frames_have_own_cap`, `credit_frames_do_not_throttle_data` | r13-f-ws-credit | +| B22 | `wss://` client trust (F40) | Platform roots; no "accept invalid certificates" API. | R8 | `ws_client_rejects_untrusted_server_cert` | r13-f-ws-client-tls | +| B23 | Header injection in generated clients (F56) | Header names and values validated; `x-rpc` and `content-type` cannot be overridden. | R4, R9 | `http_transport_rejects_invalid_header`, `http_transport_cannot_override_rpc_header` | r13-f-client-batching | + +**The ADR 0013 rules a 0.13 surface touches**, each with the named tests and probes that enforce it +at 0.13 (the rows above, grouped by rule): + +| ADR 0013 rule | 0.13 surfaces | Enforcement at 0.13 | +|---|---|---| +| R1 deny by default | admin mount and actions, mass assignment, relations, tenant resolution and routing, tool calls, MCP endpoint | compile-fail `r1_admin_site_without_guard`, `r1_mcp_endpoint_without_access_decision`; `admin_denied_without_permission`, `admin_action_requires_entity_permission`, `admin_form_ignores_undeclared_fields`, `m19_eager_loaded_children_scoped_to_tenant`, `relation_join_cannot_cross_tenant`, `tenant_resolved_from_principal_by_default`, `unknown_tenant_has_no_connection`, `ai_unregistered_tool_call_refused`, `ai_write_tool_denied_without_policy`, `mcp_session_bound_to_principal` | +| R2 secrets typed and redacted | AI provider keys | `ai_api_key_redacted_in_debug` | +| R3 logs redacted by default | prompts and completions | `ai_prompts_not_logged_by_default`; posture probe "prompts not logged" | +| R4 untrusted input bounded | admin form and bulk, page cursor and size, tenant pools, tool loop and output, AI tokens and streams, MCP messages, Accept-Language, WS control frames, h2 WebSockets, client headers | `r4_admin_bulk_action_over_cap_rejected`, `r4_admin_form_over_cap_413`, `r4_page_cursor_over_limit_rejected`, `r4_page_size_capped`, `r4_tenant_pool_count_bounded`, `ai_tool_loop_step_budget_enforced`, `r4_ai_max_tokens_finite_by_default`, `r4_ai_stream_over_cap_closed`, `r4_mcp_message_over_cap_rejected`, `r4_accept_language_over_limit_ignored`, `r4_control_frames_have_own_cap`, `h2_websocket_limits_match_h1`, `http_transport_rejects_invalid_header`; six new fuzz targets | +| R6 outbound requests guarded | AI provider base URLs, MCP client | `ai_base_url_obeys_ssrf_policy`, `mcp_client_obeys_ssrf_policy` | +| R7 operator surfaces closed in prod | admin site, MCP endpoint | posture probes "admin guarded", "mcp guarded"; `posture_prod_refuses_public_admin` | +| R8 transports encrypted and authenticated | `WsClient` over `wss://` | `ws_client_rejects_untrusted_server_cert` | +| R9 browser defences on | views, admin and htmx forms, MCP Origin, i18n in HTML, h2 extended CONNECT, generated-client headers | `ui_escapes_untrusted_by_default`, `no_preescaped_outside_trusted_module`, `r9_admin_post_without_csrf_token_refused`, `r9_htmx_requests_carry_masked_token`, `r9_mcp_foreign_origin_refused`, `i18n_args_escaped_in_html`, `i18n_bidi_isolation_on_by_default`, `r9_h2_extended_connect_foreign_origin_refused`, `http_transport_cannot_override_rpc_header` | +| R10 every weakening recorded | `dangerous_log_prompts`, `dangerous_tenant_from_header`, `dangerous_mcp_write_tools`, the made-real 0-RTT opt-out; admin audit integrity | `dangerous_log_prompts_is_recorded`, `dangerous_tenant_from_header_is_recorded`, `dangerous_mcp_write_tools_is_recorded`, `zero_rtt_admits_only_safe_methods`, `admin_change_written_to_audit_log`; `scripts/check-dangerous.sh`; posture probe "tenant not from header"; `posture_prod_fails_on_unaccepted_opt_out` (0.11) | + +R5 (raw SQL needs a capability) is not weakened by any 0.13 surface: relations, eager loading and +keyset pages build their SQL through the sea-query builder and add no string-taking query API, so +the 0.11 compile-fail `m16_query_with_string` and the `raw_sql_non_literal` lint keep holding. + +The security KPIs for 0.13 (release spec `security_kpis`): open highs 0; ≥ 5 new cargo-fuzz targets +(≥ 22 in total) in ClusterFuzzLite with no open crash; compile-fail security cases +2; the +secure-stack and unary-RPC budgets unchanged. + +### 17. Scorecard effect + +From the measured 0.12.0 baseline (E 81.5, RT 85.9, red 7), `bash scripts/scorecard.sh --targets +0.13.0` gives **E 85.2 (#1), RT 87.8 (#1), 3 red rows**, so the index gate is 84.2 (planned − 1.0). +The release spec planned 84.3 / 86.6 / 3 on the planned 0.12.0 numbers; the difference comes from +the 0.12.0 rows measured above their plan (`web.realtime`, `dist.tls`, `web.server`, +`data.jsonschema`, `conc.perf`, `fault.actors`), which METHOD §5.1 carries forward as the plan. The +stored targets did not change (D13). + +Scenarios on jq copies of `scorecard.json`, scored with `--file --targets 0.13.0`: + +| Scenario | E (rank) | RT | <40 | +|---|---|---|---| +| Committed (stored targets) | 85.2 (#1) | 87.8 | 3 | +| Margin: `web.ui` 83, `ai.llm` 88, `eco.tenancy` 88 | 85.9 (#1) | 88.2 | 3 | +| Full margin: + `web.admin` 88, `ai.llm` 100 | 86.7 (#1) | 88.7 | 3 | +| One check short on two rows (`eco.i18n` 50, `data.orm` 75) | 84.9 (#1) | 87.7 | 3 | +| Cut `data.orm` | 84.9 (#1) | 87.7 | 3 | +| Cut `web.admin` | 84.7 (#1) | 87.6 | 4 | +| Cut `web.ui` | 84.5 (#1) | 87.5 | 4 | +| Cut `eco.i18n` | 84.4 (#1) | 87.5 | 4 | +| Cut `web.admin` and `web.ui` | 84.1 (#1) | 87.2 | 5 — **below the gate 84.2** | +| Cut `ai.llm` | 83.9 (#1) | 87.0 | 4 — **below the gate 84.2** | + +Every scenario stays #1 (.NET E 78.8), so 0.13 carries no rank risk; the risk is the gate, and as +in the release spec, E9.1 (AI) cannot be cut alone and the admin and UI cannot both be cut. + +**Carry-over from 0.12.0** (REPORT-0.12.0 "Misses and carry-over"): `sec.supply` 88 against its +0.12.0 target 100, open check `sec.supply#7` (a CVE/GHSA process with a public security-release +history: SECURITY.md lists 0.10.1 with every advisory "pending" and no GitHub Security Advisory is +published). Its stored 0.13.0 target is 100. It is assigned to `r13-w8-kpis` (the security KPI leaf, +which verifies SECURITY.md "Security releases" against the published advisories); publishing the +advisories is a release-automation or owner step. No other 0.12 row missed its target. + +## Consequences + +Positive: + +- Six rows leave red or move up with one consumer that proves them together, so the exit re-score + can cite one CI job (`reference-pipeline`) for production level on all six. +- Every battery inherits ADR 0013 from its first commit: the admin cannot be mounted without a + guard, a model cannot call a tool that was not handed to it, a forged cursor stays inside the + caller's rows, and a tenant never comes from a header without a recorded opt-out. +- The UI, i18n and admin stack is all Rust, compile-checked and escaped by default, with one + hash-pinned JavaScript file and no toolchain (L12). +- AI evidence is reproducible: no secret in CI, no flaky vendor, and the same `Tool` values serve + the agent loop and the MCP server. + +Negative: + +- Three new crates (`id_effect_admin`, `id_effect_i18n`, `id_effect_ai`) for the 0.18 crate review + (E10.3) to justify, and three crates.io names to set up (D17). +- maud and Fluent are pre-1.0 and exact-pinned; every bump is work. htmx is a vendored supply-chain + item outside `cargo deny`, kept honest by its hash test and a reviewed update leaf. +- An own MCP implementation tracks a moving specification. The pinned 2026-07-28 revision is + stateless and does not interoperate with clients that only speak 2025-11-25 or older until a + compatibility path is added. +- Wiremock fixtures prove the adapters against documented examples, not against today's vendor + behaviour; an assessor may weigh that (release spec Risk 4). +- The keyset cursor is unsigned by design; it reveals the sort-key values of a row the caller could + already read, and nothing more. +- Kit features stay off by default, so the `service` template does not show the breadth batteries + until a user turns them on. + +## Alternatives considered + +- **A data pipeline without a back office, an admin-only app, or a chat/agent app as the Pipeline + (D1).** Rejected: each leaves at least one of the six rows without a production consumer. +- **Admin as generated code only, or as an `id_effect_axum` feature (D2).** Rejected: generated code + has no published, tested crate to cite ("documented" unreachable), and axum must not depend on sql. +- **minijinja for views, or a new `id_effect_ui` crate (D3).** minijinja is runtime-checked and + escapes by configuration; a new crate adds a review burden for what is a feature of the HTTP host. +- **A first-party Leptos crate (D4).** Rejected for 0.13 (L7, §9 lever): a recipe proves the + integration without freezing an API on a fast-moving framework. +- **gettext or ICU4X MessageFormat for i18n (D5).** gettext lacks CLDR plural selectors in the + message format and brings non-Rust tooling; ICU4X MF2 tooling is not yet mature. Fluent is listed + in L2. +- **Dropping the Cursor agents module, or splitting the AI crate (D6).** Rejected: no API loss for + labs users, and the feature gate keeps it out of default builds (Open question 3). +- **Wrapping `rmcp` (D7).** Rejected by default: the server must use the axum host's guard + typestate and the platform client's SSRF policy, and tools must be the agent loop's `Tool` + values; adapting `rmcp`'s own transport and tool model to both is more code than the protocol + surface 0.13 needs. Open question 4 keeps it available. +- **Pinning MCP 2025-06-18 or 2025-11-25.** Rejected: 2026-07-28 is the current stable revision and + removes sessions, so pinning an older one would ship a stateful design the specification has + already left, and its interop peer (`rmcp` 2.x) is no longer current. +- **Live vendor calls in CI (D8).** Rejected: secrets in CI, cost and flakiness, and no PR from a fork + could run them. +- **Tools callable by name (D9).** Rejected: a prompt-injected model could reach any handler in the + process. +- **Tenant from a header by default (D10).** Rejected: any client can set a header; a gateway that + sets it must be trusted explicitly, and that choice is recorded. +- **An HMAC-signed cursor (D11).** Rejected: the scope re-check already confines a forged cursor, and + a signing key adds rotation and secret handling for no isolation gain. +- **Hooks after commit, or events only (D12).** Rejected: after-commit loses atomicity between the + write and its event; events alone cannot veto a write. +- **Raising the stored targets to the margins (D13).** Rejected: margins are a buffer for the gate, + not a commitment (Open question 9). +- **Writing the i18n and UI budgets now (D15).** Rejected: a budget written before the first + measurement measures the guess, as the 0.11 secure-stack ratio did. + +## Open questions (defaults taken) + +1. **D1 Pipeline:** the multi-tenant document-intake pipeline? Default: yes. +2. **D2/D3 first-party batteries:** build `id_effect_admin` and the axum `ui` feature, or meet + `web.ui` / `web.admin` with documented third-party recipes? Default: build them. +3. **D6 Cursor agents module:** keep it behind the non-default `cursor` feature? Default: keep. +4. **D7 MCP:** own implementation with `rmcp` as a test peer, or wrap `rmcp`? Default: own + implementation, pinned to 2026-07-28. +5. **D5 i18n margin:** pull `id-effect i18n check|extract` (`eco.i18n#6`) forward from 0.15? Default: + no. +6. **D14 name:** `Effect::any_env::()`? Default: `any_env`. +7. **D10 tenancy:** a tenant from a request header needs a `dangerous_*` opt-out even behind a + trusted gateway? Default: yes. +8. **D15 budgets:** the owner sees the i18n format and UI render budgets at exit. Default: yes. +9. **D13:** keep the stored targets (E 85.2 on the measured baseline) rather than the margins (86.7)? + Default: keep. + +## References + +- Release spec: `.maestro/specs/releases/0.13.0.md` (D1–D17, Security, leaves); leaf + `.maestro/specs/releases/0.13/r13-t0-adr0015.md`. +- PLAN-OF-PLANS §5 T9 (Pipeline definition), §6 row 0.13.0, §11 (2026-10-21). +- ADR 0010 (crate tiers; Amendment 9 by `r13-t0-skeletons`), ADR 0012 (platform vision), ADR 0013 + (secure by default), ADR 0014 (one call surface). +- `docs/scorecard/REPORT-0.12.0.md`, `docs/scorecard/results/v0.12.0.json`. +- MCP specification revision 2026-07-28: + (key changes since 2025-11-25: ). From d8f3efba8a7123c6bca33048eccf3f1907fbd764 Mon Sep 17 00:00:00 2001 From: Tom Wieland Date: Wed, 21 Oct 2026 03:40:57 +0200 Subject: [PATCH 05/87] build(deps): one rcgen 0.14 across platform, node, axum, sql, tool and kit (r13-f-rcgen) F5: move the six crates from rcgen 0.13 to 0.14.10 (the version instant-acme already brings), so cargo tree -d lists rcgen once. Port the call sites: CertifiedKey.key_pair -> signing_key, signed_by(key, &Issuer) with the CA's params and key, id-effect dev keeps ECDSA P-256 and the 13-day leaf. No public signature exposes rcgen types. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Wieland --- CHANGELOG.md | 8 ++++ Cargo.lock | 38 ++++--------------- crates/id_effect_axum/Cargo.toml | 2 +- crates/id_effect_axum/examples/092_http3.rs | 2 +- .../id_effect_axum/src/server/connection.rs | 2 +- crates/id_effect_axum/src/server/tls.rs | 2 +- crates/id_effect_axum/tests/http3.rs | 7 ++-- crates/id_effect_axum/tests/tls_h2.rs | 7 ++-- crates/id_effect_kit/Cargo.toml | 2 +- crates/id_effect_node/Cargo.toml | 2 +- .../examples/134_mtls_cluster.rs | 7 ++-- .../tests/discovery_kubernetes.rs | 9 ++--- .../id_effect_node/tests/shared_endpoint.rs | 7 ++-- .../id_effect_node/tests/transport_tcp_tls.rs | 9 ++--- crates/id_effect_node/tests/transport_tls.rs | 9 ++--- crates/id_effect_platform/Cargo.toml | 6 +-- crates/id_effect_platform/src/tls/certs.rs | 6 +-- .../id_effect_platform/tests/http3_client.rs | 7 ++-- .../id_effect_platform/tests/quic_endpoint.rs | 7 ++-- crates/id_effect_platform/tests/tls_reload.rs | 7 ++-- crates/id_effect_sql/Cargo.toml | 2 +- crates/id_effect_sql/tests/tls.rs | 11 +++--- crates/id_effect_tool/Cargo.toml | 2 +- crates/id_effect_tool/src/dev.rs | 23 ++++++----- 24 files changed, 87 insertions(+), 97 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cd42c9a3..dc5c15a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## 0.13.0 — Unreleased — Breadth +### Changed + +- One rcgen (r13-f-rcgen, 0.12 follow-up F5): `id_effect_platform`, `id_effect_node`, + `id_effect_axum`, `id_effect_sql`, `id_effect_tool` and `id_effect_kit` move from rcgen 0.13 to + 0.14 (the version instant-acme already brought), so `cargo tree -d` lists rcgen once and the + `yasna` 0.5 copy rcgen 0.13 pulled is gone from `Cargo.lock`. No public signature exposes rcgen + types; certificates keep ECDSA P-256 and the `id-effect dev` leaf keeps its 13-day lifetime. + ### Documentation - [ADR 0015 "Breadth batteries"](docs/adrs/0015-breadth-batteries.md) (Proposed; r13-t0-adr0015): diff --git a/Cargo.lock b/Cargo.lock index d1353fd6e..70eb66c6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2654,7 +2654,7 @@ dependencies = [ "jsonschema", "quinn", "rand 0.9.5", - "rcgen 0.13.2", + "rcgen", "serde", "serde_json", "sha2 0.10.9", @@ -2799,7 +2799,7 @@ dependencies = [ "id_effect_storage", "id_effect_test", "id_effect_workflow", - "rcgen 0.13.2", + "rcgen", "tokio", "tower", ] @@ -2878,7 +2878,7 @@ dependencies = [ "proptest", "quinn", "rand 0.9.5", - "rcgen 0.13.2", + "rcgen", "rustls", "serde", "serde_json", @@ -2945,7 +2945,7 @@ dependencies = [ "instant-acme", "quinn", "quinn-proto", - "rcgen 0.13.2", + "rcgen", "reqwest", "rstest", "rustls", @@ -3086,7 +3086,7 @@ dependencies = [ "id_effect_tokio", "jiff", "proptest", - "rcgen 0.13.2", + "rcgen", "rust_decimal", "sea-query", "serde", @@ -3165,7 +3165,7 @@ dependencies = [ "id_effect_sql", "id_effect_tokio", "jiff", - "rcgen 0.13.2", + "rcgen", "serde", "serde_json", "sha2 0.10.9", @@ -3282,7 +3282,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", - "rcgen 0.14.10", + "rcgen", "rustls", "rustls-pki-types", "serde", @@ -4833,19 +4833,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "rcgen" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" -dependencies = [ - "aws-lc-rs", - "pem 3.0.6", - "rustls-pki-types", - "time", - "yasna 0.5.2", -] - [[package]] name = "rcgen" version = "0.14.10" @@ -4857,7 +4844,7 @@ dependencies = [ "rustls-pki-types", "time", "x509-parser", - "yasna 0.6.0", + "yasna", ] [[package]] @@ -7431,15 +7418,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" -[[package]] -name = "yasna" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" -dependencies = [ - "time", -] - [[package]] name = "yasna" version = "0.6.0" diff --git a/crates/id_effect_axum/Cargo.toml b/crates/id_effect_axum/Cargo.toml index 6f320db53..ad855c10e 100644 --- a/crates/id_effect_axum/Cargo.toml +++ b/crates/id_effect_axum/Cargo.toml @@ -189,7 +189,7 @@ hyper = { version = "=1.12.0", default-features = false, features = [ "http1", "http2", ] } -rcgen = { version = "=0.13.2", default-features = false, features = [ +rcgen = { version = "=0.14.10", default-features = false, features = [ "aws_lc_rs", "pem", ] } diff --git a/crates/id_effect_axum/examples/092_http3.rs b/crates/id_effect_axum/examples/092_http3.rs index c748083b8..c1ce7de98 100644 --- a/crates/id_effect_axum/examples/092_http3.rs +++ b/crates/id_effect_axum/examples/092_http3.rs @@ -49,7 +49,7 @@ fn tls() -> (HostTls, Option>) { std::fs::create_dir_all(&dir).expect("temp dir"); let (chain, key) = (dir.join("chain.pem"), dir.join("key.pem")); std::fs::write(&chain, ck.cert.pem()).expect("write chain"); - std::fs::write(&key, ck.key_pair.serialize_pem()).expect("write key"); + std::fs::write(&key, ck.signing_key.serialize_pem()).expect("write key"); (HostTls::files(chain, key), Some(ck.cert.der().to_vec())) } diff --git a/crates/id_effect_axum/src/server/connection.rs b/crates/id_effect_axum/src/server/connection.rs index fc4339457..7135f6406 100644 --- a/crates/id_effect_axum/src/server/connection.rs +++ b/crates/id_effect_axum/src/server/connection.rs @@ -792,7 +792,7 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); let (chain, key) = (dir.join("chain.pem"), dir.join("key.pem")); std::fs::write(&chain, ck.cert.pem()).unwrap(); - std::fs::write(&key, ck.key_pair.serialize_pem()).unwrap(); + std::fs::write(&key, ck.signing_key.serialize_pem()).unwrap(); crate::server::bootstrap::HostTls::Files { cert_file: chain, key_file: key, diff --git a/crates/id_effect_axum/src/server/tls.rs b/crates/id_effect_axum/src/server/tls.rs index 67c7f567f..257cacffe 100644 --- a/crates/id_effect_axum/src/server/tls.rs +++ b/crates/id_effect_axum/src/server/tls.rs @@ -399,7 +399,7 @@ mod tests { let certified = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap(); let (chain, key) = (dir.join("chain.pem"), dir.join("key.pem")); std::fs::write(&chain, certified.cert.pem()).unwrap(); - std::fs::write(&key, certified.key_pair.serialize_pem()).unwrap(); + std::fs::write(&key, certified.signing_key.serialize_pem()).unwrap(); let no_runtime = ServerTls::from_pem_files(&chain, &key, Some(Duration::from_secs(1))); assert!( matches!(&no_runtime, Err(CertError::Tls(m)) if m.contains("Tokio")), diff --git a/crates/id_effect_axum/tests/http3.rs b/crates/id_effect_axum/tests/http3.rs index 8ece6612b..dce62384b 100644 --- a/crates/id_effect_axum/tests/http3.rs +++ b/crates/id_effect_axum/tests/http3.rs @@ -45,7 +45,7 @@ const WAIT: Duration = Duration::from_secs(20); struct Ca { cert: rcgen::Certificate, - key: KeyPair, + issuer: rcgen::Issuer<'static, KeyPair>, } struct Leaf { @@ -62,13 +62,14 @@ impl Ca { .push(DnType::CommonName, "id_effect h3 test CA"); let key = KeyPair::generate().unwrap(); let cert = params.self_signed(&key).unwrap(); - Self { cert, key } + let issuer = rcgen::Issuer::new(params, key); + Self { cert, issuer } } fn issue(&self) -> Leaf { let params = CertificateParams::new(vec!["localhost".to_owned()]).unwrap(); let key = KeyPair::generate().unwrap(); - let cert = params.signed_by(&key, &self.cert, &self.key).unwrap(); + let cert = params.signed_by(&key, &self.issuer).unwrap(); Leaf { chain_pem: cert.pem(), key_pem: key.serialize_pem(), diff --git a/crates/id_effect_axum/tests/tls_h2.rs b/crates/id_effect_axum/tests/tls_h2.rs index 3155d0a7c..5ccca63c1 100644 --- a/crates/id_effect_axum/tests/tls_h2.rs +++ b/crates/id_effect_axum/tests/tls_h2.rs @@ -33,7 +33,7 @@ const WAIT: Duration = Duration::from_secs(5); /// A test CA issuing `localhost` certificates. struct Ca { cert: rcgen::Certificate, - key: KeyPair, + issuer: rcgen::Issuer<'static, KeyPair>, } /// A leaf certificate: PEM chain, PEM key and the leaf's SHA-256. @@ -52,13 +52,14 @@ impl Ca { .push(DnType::CommonName, "id_effect test CA"); let key = KeyPair::generate().unwrap(); let cert = params.self_signed(&key).unwrap(); - Self { cert, key } + let issuer = rcgen::Issuer::new(params, key); + Self { cert, issuer } } fn issue(&self) -> Leaf { let params = CertificateParams::new(vec!["localhost".to_owned()]).unwrap(); let key = KeyPair::generate().unwrap(); - let cert = params.signed_by(&key, &self.cert, &self.key).unwrap(); + let cert = params.signed_by(&key, &self.issuer).unwrap(); Leaf { chain_pem: cert.pem(), key_pem: key.serialize_pem(), diff --git a/crates/id_effect_kit/Cargo.toml b/crates/id_effect_kit/Cargo.toml index 2e1762c55..3e78f0bf3 100644 --- a/crates/id_effect_kit/Cargo.toml +++ b/crates/id_effect_kit/Cargo.toml @@ -240,7 +240,7 @@ id_effect_rpc = { path = "../id_effect_rpc", version = "0.12.0", optional = true axum = "0.8.9" # tests/posture.rs: a certificate carrying the `id-effect dev` organization (C13), with the # same rcgen the platform issues certificates with. -rcgen = { version = "=0.13.2", default-features = false, features = [ +rcgen = { version = "=0.14.10", default-features = false, features = [ "aws_lc_rs", "pem", ] } diff --git a/crates/id_effect_node/Cargo.toml b/crates/id_effect_node/Cargo.toml index 6083f94c4..769d8fdf9 100644 --- a/crates/id_effect_node/Cargo.toml +++ b/crates/id_effect_node/Cargo.toml @@ -120,7 +120,7 @@ tokio = { version = "1.50", features = [ ] } # Test CAs and node certificates (tests/transport_tls.rs; RSA keys need aws-lc-rs), and the # in-process TLS API server of the Kubernetes discovery tests. -rcgen = { version = "0.13", default-features = false, features = [ +rcgen = { version = "0.14", default-features = false, features = [ "aws_lc_rs", "pem", ] } diff --git a/crates/id_effect_node/examples/134_mtls_cluster.rs b/crates/id_effect_node/examples/134_mtls_cluster.rs index a8dd33d98..545bc0078 100644 --- a/crates/id_effect_node/examples/134_mtls_cluster.rs +++ b/crates/id_effect_node/examples/134_mtls_cluster.rs @@ -23,7 +23,7 @@ type Error = Box; /// server name every node verifies (`"cluster"`), valid for both TLS roles. struct Pki { cert: rcgen::Certificate, - key: rcgen::KeyPair, + issuer: rcgen::Issuer<'static, rcgen::KeyPair>, } impl Pki { @@ -39,7 +39,8 @@ impl Pki { ]; let key = rcgen::KeyPair::generate()?; let cert = params.self_signed(&key)?; - Ok(Self { cert, key }) + let issuer = rcgen::Issuer::new(params, key); + Ok(Self { cert, issuer }) } /// Write `.crt` (leaf, then CA) and `.key` (PKCS#8) to `dir`. @@ -53,7 +54,7 @@ impl Pki { rcgen::ExtendedKeyUsagePurpose::ClientAuth, ]; let key = rcgen::KeyPair::generate()?; - let cert = params.signed_by(&key, &self.cert, &self.key)?; + let cert = params.signed_by(&key, &self.issuer)?; write_atomic( &dir.join(format!("{name}.crt")), &format!("{}{}", cert.pem(), self.cert.pem()), diff --git a/crates/id_effect_node/tests/discovery_kubernetes.rs b/crates/id_effect_node/tests/discovery_kubernetes.rs index e468dfd52..ebd407b40 100644 --- a/crates/id_effect_node/tests/discovery_kubernetes.rs +++ b/crates/id_effect_node/tests/discovery_kubernetes.rs @@ -27,7 +27,7 @@ const TOKEN: &str = "sa-token-1"; /// A throwaway cluster CA. struct Ca { cert: rcgen::Certificate, - key: rcgen::KeyPair, + issuer: rcgen::Issuer<'static, rcgen::KeyPair>, } impl Ca { @@ -44,7 +44,8 @@ impl Ca { ]; let key = rcgen::KeyPair::generate().expect("ca key"); let cert = params.self_signed(&key).expect("ca cert"); - Self { cert, key } + let issuer = rcgen::Issuer::new(params, key); + Self { cert, issuer } } /// The API server's TLS config: a leaf for `127.0.0.1` issued by this CA. @@ -55,9 +56,7 @@ impl Ca { .distinguished_name .push(rcgen::DnType::CommonName, "kube-apiserver"); params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; - let leaf = params - .signed_by(&key, &self.cert, &self.key) - .expect("leaf cert"); + let leaf = params.signed_by(&key, &self.issuer).expect("leaf cert"); let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); rustls::ServerConfig::builder_with_provider(provider) .with_safe_default_protocol_versions() diff --git a/crates/id_effect_node/tests/shared_endpoint.rs b/crates/id_effect_node/tests/shared_endpoint.rs index d07b4c2eb..b99eb8f14 100644 --- a/crates/id_effect_node/tests/shared_endpoint.rs +++ b/crates/id_effect_node/tests/shared_endpoint.rs @@ -21,7 +21,7 @@ const WAIT: Duration = Duration::from_secs(5); /// A throwaway certificate authority. struct Ca { cert: rcgen::Certificate, - key: rcgen::KeyPair, + issuer: rcgen::Issuer<'static, rcgen::KeyPair>, } impl Ca { @@ -37,7 +37,8 @@ impl Ca { ]; let key = rcgen::KeyPair::generate().expect("ca key"); let cert = params.self_signed(&key).expect("ca cert"); - Self { cert, key } + let issuer = rcgen::Issuer::new(params, key); + Self { cert, issuer } } fn roots(&self) -> rustls::RootCertStore { @@ -52,7 +53,7 @@ impl Ca { rcgen::ExtendedKeyUsagePurpose::ClientAuth, ]; let key = rcgen::KeyPair::generate().expect("key"); - let cert = params.signed_by(&key, &self.cert, &self.key).expect("leaf"); + let cert = params.signed_by(&key, &self.issuer).expect("leaf"); NodeCertificate::from_pem( format!("{}{}", cert.pem(), self.cert.pem()).as_bytes(), key.serialize_pem().as_bytes(), diff --git a/crates/id_effect_node/tests/transport_tcp_tls.rs b/crates/id_effect_node/tests/transport_tcp_tls.rs index 19898259b..24fbd7a41 100644 --- a/crates/id_effect_node/tests/transport_tcp_tls.rs +++ b/crates/id_effect_node/tests/transport_tcp_tls.rs @@ -24,7 +24,7 @@ const WAIT: Duration = Duration::from_secs(5); /// A throwaway certificate authority. struct Ca { cert: rcgen::Certificate, - key: rcgen::KeyPair, + issuer: rcgen::Issuer<'static, rcgen::KeyPair>, } /// A node certificate issued by a [`Ca`], as the PEM files an operator ships. @@ -47,7 +47,8 @@ impl Ca { ]; let key = rcgen::KeyPair::generate().expect("ca key"); let cert = params.self_signed(&key).expect("ca cert"); - Self { cert, key } + let issuer = rcgen::Issuer::new(params, key); + Self { cert, issuer } } fn roots(&self) -> rustls::RootCertStore { @@ -65,9 +66,7 @@ impl Ca { rcgen::ExtendedKeyUsagePurpose::ClientAuth, ]; let key = rcgen::KeyPair::generate().expect("key"); - let cert = params - .signed_by(&key, &self.cert, &self.key) - .expect("leaf cert"); + let cert = params.signed_by(&key, &self.issuer).expect("leaf cert"); Leaf { chain_pem: format!("{}{}", cert.pem(), self.cert.pem()), key_pem: key.serialize_pem(), diff --git a/crates/id_effect_node/tests/transport_tls.rs b/crates/id_effect_node/tests/transport_tls.rs index bb1579e1c..933ad1a32 100644 --- a/crates/id_effect_node/tests/transport_tls.rs +++ b/crates/id_effect_node/tests/transport_tls.rs @@ -35,7 +35,7 @@ const SHAPES: [Shape; 2] = [Shape::Own, Shape::Shared]; /// A throwaway certificate authority. struct Ca { cert: rcgen::Certificate, - key: rcgen::KeyPair, + issuer: rcgen::Issuer<'static, rcgen::KeyPair>, } /// A node certificate issued by a [`Ca`], as the PEM files an operator ships. @@ -59,7 +59,8 @@ impl Ca { ]; let key = rcgen::KeyPair::generate().expect("ca key"); let cert = params.self_signed(&key).expect("ca cert"); - Self { cert, key } + let issuer = rcgen::Issuer::new(params, key); + Self { cert, issuer } } fn bundle_pem(&self) -> String { @@ -81,9 +82,7 @@ impl Ca { rcgen::ExtendedKeyUsagePurpose::ServerAuth, rcgen::ExtendedKeyUsagePurpose::ClientAuth, ]; - let cert = params - .signed_by(key, &self.cert, &self.key) - .expect("leaf cert"); + let cert = params.signed_by(key, &self.issuer).expect("leaf cert"); Leaf { chain_pem: format!("{}{}", cert.pem(), self.cert.pem()), key_pem, diff --git a/crates/id_effect_platform/Cargo.toml b/crates/id_effect_platform/Cargo.toml index c2ed28dc3..04bd07e21 100644 --- a/crates/id_effect_platform/Cargo.toml +++ b/crates/id_effect_platform/Cargo.toml @@ -71,7 +71,7 @@ tokio = { version = "1.50.0", features = [ # reqwest and jsonwebtoken use aws-lc-rs, and with both providers compiled in rustls cannot pick a # process default. One quinn and one rustls in `cargo tree -d`. rustls = { version = "0.23.46", optional = true } -rcgen = { version = "0.13", optional = true, default-features = false, features = [ +rcgen = { version = "0.14", optional = true, default-features = false, features = [ "aws_lc_rs", "pem", ] } @@ -96,8 +96,8 @@ rustls-platform-verifier = { version = "=0.7.1", optional = true } # Exact pins: h3 is pre-1.0 and h3-quinn tracks it release by release. h3 = { version = "=0.0.8", optional = true } h3-quinn = { version = "=0.0.10", optional = true } -# instant-acme's `aws-lc-rs` feature turns on its rcgen 0.14 (beside rcgen 0.13 above): the one -# duplicate, and only under `acme`. Our own CSR and TLS-ALPN-01 certificates use rcgen 0.13. +# instant-acme's `aws-lc-rs` feature turns on its rcgen 0.14, the same rcgen as above (one copy in +# `cargo tree -d`); our own CSR and TLS-ALPN-01 certificates use it too. instant-acme = { version = "=0.8.5", optional = true, default-features = false, features = [ "aws-lc-rs", "hyper-rustls", diff --git a/crates/id_effect_platform/src/tls/certs.rs b/crates/id_effect_platform/src/tls/certs.rs index b97c0c8ff..901af9d92 100644 --- a/crates/id_effect_platform/src/tls/certs.rs +++ b/crates/id_effect_platform/src/tls/certs.rs @@ -101,7 +101,7 @@ impl NodeCertificate { pub fn self_signed(host: &str) -> Result { let certified = rcgen::generate_simple_self_signed(vec![host.to_string()]) .map_err(|e| CertError::Certificate(format!("rcgen: {e}")))?; - let key = PrivateKeyDer::Pkcs8(certified.key_pair.serialize_der().into()); + let key = PrivateKeyDer::Pkcs8(certified.signing_key.serialize_der().into()); Ok(Self { chain: vec![certified.cert.der().clone()], key, @@ -852,7 +852,7 @@ mod tests { let certified = rcgen::generate_simple_self_signed(vec!["node".into()]).expect("rcgen"); let (chain, key) = (dir.join("node.crt"), dir.join("node.key")); std::fs::write(&chain, certified.cert.pem()).expect("chain"); - std::fs::write(&key, certified.key_pair.serialize_pem()).expect("key"); + std::fs::write(&key, certified.signing_key.serialize_pem()).expect("key"); let reloader = Arc::new(CertReloader::new(&chain, &key).expect("load")); assert!(format!("{reloader:?}").contains("node.crt")); let before = reloader.cert_sha256(); @@ -865,7 +865,7 @@ mod tests { assert_eq!(reloader.cert_sha256(), before); let next = rcgen::generate_simple_self_signed(vec!["node".into()]).expect("rcgen"); std::fs::write(&chain, next.cert.pem()).expect("chain"); - std::fs::write(&key, next.key_pair.serialize_pem()).expect("key"); + std::fs::write(&key, next.signing_key.serialize_pem()).expect("key"); tokio::time::timeout(Duration::from_secs(5), async { while reloader.cert_sha256() == before { tokio::time::sleep(Duration::from_millis(5)).await; diff --git a/crates/id_effect_platform/tests/http3_client.rs b/crates/id_effect_platform/tests/http3_client.rs index e4b93d475..4776d5a2b 100644 --- a/crates/id_effect_platform/tests/http3_client.rs +++ b/crates/id_effect_platform/tests/http3_client.rs @@ -28,7 +28,7 @@ use id_effect_platform::tls::{Identity, NodeCertificate, server_tls_config}; /// A throwaway certificate authority. struct Ca { cert: rcgen::Certificate, - key: rcgen::KeyPair, + issuer: rcgen::Issuer<'static, rcgen::KeyPair>, } impl Ca { @@ -44,7 +44,8 @@ impl Ca { ]; let key = rcgen::KeyPair::generate().expect("ca key"); let cert = params.self_signed(&key).expect("ca cert"); - Self { cert, key } + let issuer = rcgen::Issuer::new(params, key); + Self { cert, issuer } } fn der(&self) -> Vec { @@ -56,7 +57,7 @@ impl Ca { let mut params = rcgen::CertificateParams::new(vec![name.to_string()]).expect("params"); params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; let key = rcgen::KeyPair::generate().expect("key"); - let cert = params.signed_by(&key, &self.cert, &self.key).expect("leaf"); + let cert = params.signed_by(&key, &self.issuer).expect("leaf"); let cert = NodeCertificate::from_pem( format!("{}{}", cert.pem(), self.cert.pem()).as_bytes(), key.serialize_pem().as_bytes(), diff --git a/crates/id_effect_platform/tests/quic_endpoint.rs b/crates/id_effect_platform/tests/quic_endpoint.rs index 016bf3fc4..7861e97ac 100644 --- a/crates/id_effect_platform/tests/quic_endpoint.rs +++ b/crates/id_effect_platform/tests/quic_endpoint.rs @@ -18,7 +18,7 @@ const WAIT: Duration = Duration::from_secs(5); /// A throwaway certificate authority. struct Ca { cert: rcgen::Certificate, - key: rcgen::KeyPair, + issuer: rcgen::Issuer<'static, rcgen::KeyPair>, } impl Ca { @@ -34,7 +34,8 @@ impl Ca { ]; let key = rcgen::KeyPair::generate().expect("ca key"); let cert = params.self_signed(&key).expect("ca cert"); - Self { cert, key } + let issuer = rcgen::Issuer::new(params, key); + Self { cert, issuer } } fn roots(&self) -> rustls::RootCertStore { @@ -49,7 +50,7 @@ impl Ca { rcgen::ExtendedKeyUsagePurpose::ClientAuth, ]; let key = rcgen::KeyPair::generate().expect("key"); - let cert = params.signed_by(&key, &self.cert, &self.key).expect("leaf"); + let cert = params.signed_by(&key, &self.issuer).expect("leaf"); NodeCertificate::from_pem( format!("{}{}", cert.pem(), self.cert.pem()).as_bytes(), key.serialize_pem().as_bytes(), diff --git a/crates/id_effect_platform/tests/tls_reload.rs b/crates/id_effect_platform/tests/tls_reload.rs index a6956e505..4710c078c 100644 --- a/crates/id_effect_platform/tests/tls_reload.rs +++ b/crates/id_effect_platform/tests/tls_reload.rs @@ -18,7 +18,7 @@ use rustls::{ClientConnection, ServerConnection, StreamOwned}; /// A throwaway certificate authority. struct Ca { cert: rcgen::Certificate, - key: rcgen::KeyPair, + issuer: rcgen::Issuer<'static, rcgen::KeyPair>, } impl Ca { @@ -34,7 +34,8 @@ impl Ca { ]; let key = rcgen::KeyPair::generate().expect("ca key"); let cert = params.self_signed(&key).expect("ca cert"); - Self { cert, key } + let issuer = rcgen::Issuer::new(params, key); + Self { cert, issuer } } fn roots(&self) -> rustls::RootCertStore { @@ -46,7 +47,7 @@ impl Ca { let mut params = rcgen::CertificateParams::new(vec!["localhost".to_string()]).expect("params"); params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; let key = rcgen::KeyPair::generate().expect("key"); - let cert = params.signed_by(&key, &self.cert, &self.key).expect("leaf"); + let cert = params.signed_by(&key, &self.issuer).expect("leaf"); ( format!("{}{}", cert.pem(), self.cert.pem()), key.serialize_pem(), diff --git a/crates/id_effect_sql/Cargo.toml b/crates/id_effect_sql/Cargo.toml index 8619b51ce..2548edca7 100644 --- a/crates/id_effect_sql/Cargo.toml +++ b/crates/id_effect_sql/Cargo.toml @@ -90,7 +90,7 @@ trybuild = "1" # tests/tls.rs. testcontainers = { version = "0.28", default-features = false } # tests/tls.rs: a throwaway CA and server certificate (aws-lc-rs, as rustls uses). -rcgen = { version = "0.13", default-features = false, features = [ +rcgen = { version = "0.14", default-features = false, features = [ "aws_lc_rs", "pem", ] } diff --git a/crates/id_effect_sql/tests/tls.rs b/crates/id_effect_sql/tests/tls.rs index 2d3a41fe7..71afd8ad2 100644 --- a/crates/id_effect_sql/tests/tls.rs +++ b/crates/id_effect_sql/tests/tls.rs @@ -17,7 +17,7 @@ use id_effect::{Cause, Effect, Exit}; use id_effect_sql::{SqlError, SqlValue}; use id_effect_tokio::TokioRuntime; use rcgen::{ - BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, + BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair, KeyUsagePurpose, }; @@ -31,17 +31,18 @@ struct Pki { other_ca_pem: String, } -fn ca(name: &str) -> (rcgen::Certificate, KeyPair) { +fn ca(name: &str) -> (rcgen::Certificate, Issuer<'static, KeyPair>) { let key = KeyPair::generate().expect("CA key"); let mut params = CertificateParams::new(Vec::::new()).expect("CA params"); params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); params.distinguished_name.push(DnType::CommonName, name); params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; - (params.self_signed(&key).expect("CA certificate"), key) + let cert = params.self_signed(&key).expect("CA certificate"); + (cert, Issuer::new(params, key)) } fn pki() -> Pki { - let (ca_cert, ca_key) = ca("id_effect_sql test CA"); + let (ca_cert, ca_issuer) = ca("id_effect_sql test CA"); let key = KeyPair::generate().expect("server key"); let mut params = CertificateParams::new(vec!["localhost".to_owned()]).expect("server params"); params @@ -50,7 +51,7 @@ fn pki() -> Pki { params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; let server = params - .signed_by(&key, &ca_cert, &ca_key) + .signed_by(&key, &ca_issuer) .expect("server certificate"); Pki { ca_pem: ca_cert.pem(), diff --git a/crates/id_effect_tool/Cargo.toml b/crates/id_effect_tool/Cargo.toml index 32c20f90b..b943aa4e3 100644 --- a/crates/id_effect_tool/Cargo.toml +++ b/crates/id_effect_tool/Cargo.toml @@ -45,7 +45,7 @@ jiff = "0.2.23" # `id-effect dev`: the localhost development CA and its 13-day ECDSA P-256 certificate # (WebTransport `serverCertificateHashes`), with the rcgen the platform issues certificates with; # sha2 for the certificate hash it prints. -rcgen = { version = "=0.13.2", default-features = false, features = [ +rcgen = { version = "=0.14.10", default-features = false, features = [ "aws_lc_rs", "pem", ] } diff --git a/crates/id_effect_tool/src/dev.rs b/crates/id_effect_tool/src/dev.rs index 50a7d05f2..4da18669f 100644 --- a/crates/id_effect_tool/src/dev.rs +++ b/crates/id_effect_tool/src/dev.rs @@ -593,7 +593,7 @@ pub mod cert { use rcgen::{ BasicConstraints, Certificate, CertificateParams, DistinguishedName, DnType, - ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, PKCS_ECDSA_P256_SHA256, + ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair, KeyUsagePurpose, PKCS_ECDSA_P256_SHA256, }; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; @@ -746,9 +746,11 @@ pub mod cert { } else { ca_not_after - days(CA_DAYS) }; - let ca = ca_certificate(&ca_key, ca_start)?; + let ca_params = ca_params(ca_start)?; + let ca = ca_params.self_signed(&ca_key).map_err(|e| e.to_string())?; + let issuer = Issuer::from_params(&ca_params, &ca_key); let leaf_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).map_err(|e| e.to_string())?; - let (leaf, not_after) = leaf_certificate(&leaf_key, &ca, &ca_key, now_secs)?; + let (leaf, not_after) = leaf_certificate(&leaf_key, &issuer, now_secs)?; if action == Action::Created { write(&dir.join(CA_FILE), ca.pem().as_bytes(), false)?; write( @@ -811,9 +813,9 @@ pub mod cert { dn } - /// The CA (re)built on `key`: the same name and key sign leaves the stored CA certificate - /// verifies. - fn ca_certificate(key: &KeyPair, start: u64) -> Result { + /// The CA's parameters from `start`: rebuilt on the stored key, the same name and key sign + /// leaves the stored CA certificate verifies. + fn ca_params(start: u64) -> Result { let mut params = CertificateParams::default(); params.distinguished_name = names("id-effect development CA"); params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); @@ -826,13 +828,12 @@ pub mod cert { params.not_before = rcgen::date_time_ymd(y, m, d); let ((y, m, d), _) = day(start, CA_DAYS)?; params.not_after = rcgen::date_time_ymd(y, m, d); - params.self_signed(key).map_err(|e| e.to_string()) + Ok(params) } fn leaf_certificate( key: &KeyPair, - ca: &Certificate, - ca_key: &KeyPair, + issuer: &Issuer<'_, &KeyPair>, now: u64, ) -> Result<(Certificate, u64), String> { let mut params = @@ -845,9 +846,7 @@ pub mod cert { params.not_before = rcgen::date_time_ymd(y, m, d); let ((y, m, d), not_after_secs) = day(now, LEAF_DAYS)?; params.not_after = rcgen::date_time_ymd(y, m, d); - let leaf = params - .signed_by(key, ca, ca_key) - .map_err(|e| e.to_string())?; + let leaf = params.signed_by(key, issuer).map_err(|e| e.to_string())?; Ok((leaf, not_after_secs)) } From ee0815223b60ff3a4df59e644cffd0d121fce8b9 Mon Sep 17 00:00:00 2001 From: Tom Wieland Date: Wed, 21 Oct 2026 03:43:46 +0200 Subject: [PATCH 06/87] feat(core): Effect::any_env and LocalEffect::any_env (r13-f-any-env) Run an env-free effect (R = HNil) under any environment R, HList or not (F52 / D14). A ready effect is returned as is; a fused step is rewrapped once; a program gets one Provide frame over the shared static HNil env. Tests any_env_runs_under_unit_and_dyn_env, any_env_preserves_interruption_and_finalizers, local_any_env_round_trip; book section in part2 ch04-03. Signed-off-by: Tom Wieland Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 + .../src/part2/ch04-03-widening-narrowing.md | 41 +++ crates/id_effect/src/kernel/effect.rs | 50 +++- crates/id_effect/src/kernel/local.rs | 28 +- crates/id_effect/tests/any_env.rs | 277 ++++++++++++++++++ 5 files changed, 401 insertions(+), 3 deletions(-) create mode 100644 crates/id_effect/tests/any_env.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index dc5c15a99..e61c847d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## 0.13.0 — Unreleased — Breadth +### Added + +- `Effect::any_env` and `LocalEffect::any_env` (r13-f-any-env, F52 / D14): run an env-free + effect (`R = HNil`) under any environment `R`, HList or not (a bare `DynEnv`, a generic `R`), + replacing the `Effect::from_ctx(|ctx| async move { ctx.bind(e).await })` workaround. A ready + effect is returned as is; a program gets one `Provide` frame over the shared empty environment. + Book: "Env-free effects under any environment" in part 2, chapter 4.3. + ### Changed - One rcgen (r13-f-rcgen, 0.12 follow-up F5): `id_effect_platform`, `id_effect_node`, diff --git a/crates/id_effect/book/src/part2/ch04-03-widening-narrowing.md b/crates/id_effect/book/src/part2/ch04-03-widening-narrowing.md index aa9875b12..50fabe1b1 100644 --- a/crates/id_effect/book/src/part2/ch04-03-widening-narrowing.md +++ b/crates/id_effect/book/src/part2/ch04-03-widening-narrowing.md @@ -108,6 +108,46 @@ let program: Effect = child.fork().widen().flat_map(|h| h.join()); For a fallible error type, `.map_err(Into::into)` widens through `From`. +## Env-free effects under any environment: `any_env` + +`narrow` lifts into a wider *HList*. Some environments are not HLists: a procedure that runs over +a bare `DynEnv` (plugin-style wiring, a runtime-checked map of capabilities), or a +helper generic over an `R` it knows nothing about. An effect that needs nothing at all +(`Effect`, whose `R` is `HNil`) still fits there: `.any_env()` gives it any environment +type, without reading it. + +```rust +use id_effect::{DynEnv, Effect, Exit, succeed}; + +// Needs no capability. +fn audit_count() -> Effect { + succeed::(20).map(|n| n + 1) +} + +// A procedure over a bare `DynEnv`. Before `any_env` this took a body that only binds: +// `Effect::from_ctx(|ctx| async move { ctx.bind(audit_count()).await })`. +fn procedure() -> Effect { + audit_count().any_env() +} + +// Generic over the caller's environment, whatever it is. +fn in_any() -> Effect { + audit_count().any_env() +} + +let exit = procedure().provide_all(DynEnv::new()).block_on(); +assert!(matches!(exit, Exit::Success(21))); +let exit = in_any::() + .zip(procedure()) + .provide_all(DynEnv::new()) + .block_on(); +assert!(matches!(exit, Exit::Success((21, 21)))); +``` + +A ready effect is returned as is. Anything else runs under the shared empty environment (one +`Provide` frame, no allocation per run) on the same fiber, so interrupts, masks and finalizers +behave exactly as they would without the lift. The `!Send` twin is `LocalEffect::any_env`. + ## Generic helpers A helper generic over `R` (with a `Selector` bound) fits any environment that has its capability. @@ -137,6 +177,7 @@ fn signup() -> Effect<(), AppError, caps!(Logger, Db)> { |-----------|---------------------| | `~sub` in `effect!` | lifts `sub` into the block's `R`, widens its error | | `.narrow::()` | `R` → any `Wide` containing `R` | +| `.any_env::()` | `HNil` → any `R` (HList or not) | | `.provide(t)` / `.provide_some(..)` | removes capabilities from `R` | | `.widen()` | `Never` → any `E` | | `.map_err(Into::into)` | `E2` → `E` through `From` | diff --git a/crates/id_effect/src/kernel/effect.rs b/crates/id_effect/src/kernel/effect.rs index 577558942..683f94b81 100644 --- a/crates/id_effect/src/kernel/effect.rs +++ b/crates/id_effect/src/kernel/effect.rs @@ -1,5 +1,6 @@ //! `Effect`, its `Node` representation (`Pure`/`Sync`/`Op`), the core -//! combinators (`map`/`flat_map`/`map_err`/`catch`/`exit`/`then`/`zip`/`widen`) and `ExitKind`. +//! combinators (`map`/`flat_map`/`map_err`/`catch`/`exit`/`then`/`zip`/`widen`/`any_env`) and +//! `ExitKind`. //! //! Design A §1.1: an inline `Pure` node (no allocation for ready values), lazy synchronous //! closures (`Sync`) and erased runloop programs (`Op`). `ExitKind` is defined in @@ -12,7 +13,7 @@ //! fusion saved. The fiber-free prefix of a program still runs without a fiber or executor //! (`settle_instr`, used by `block_on` and `~` binds). -use super::anybox::{AnyBox, downcast_env, erase, unbox, unerase_exit}; +use super::anybox::{AnyBox, AnyEnv, downcast_env, erase, hnil_env, unbox, unerase_exit}; use super::cause::{map_fail_once, split_failure, to_exit}; use super::runloop::{Frame, Instr, SeqBody, frame_vec}; use crate::capability::HNil; @@ -238,3 +239,48 @@ impl Effect { self.map_err(|n| match n {}) } } + +impl Effect { + /// Runs this environment-free effect under any environment `R`: the result needs `R` in the + /// type only and never reads it. + /// + /// Use it to hand an env-free effect to code that expects an `Effect` for an `R` that + /// need not be an HList (a procedure over a bare [`DynEnv`](crate::DynEnv), a generic `R`), + /// where [`narrow`](Self::narrow) does not apply and a `from_ctx` body that only binds the + /// effect would be boilerplate. + /// + /// Cost: a ready effect is returned as is (no allocation); a fused synchronous step is rewrapped + /// once (one closure box, as [`narrow`](Self::narrow) does); an erased program gets one + /// `Provide` frame whose inner environment is the shared static `HNil` (no allocation per run). + /// The inner program runs on the same fiber, so interrupts, masks and finalizers behave as they + /// would without the lift. + /// + /// ``` + /// use id_effect::{DynEnv, Effect, Exit, HNil, succeed}; + /// + /// fn audit_count() -> Effect { + /// succeed::(20).map(|n| n + 1) + /// } + /// + /// // A procedure over a `DynEnv`: before `any_env` this was + /// // `Effect::from_ctx(|ctx| async move { ctx.bind(audit_count()).await })`. + /// fn procedure() -> Effect { + /// audit_count().any_env() + /// } + /// + /// let exit = procedure().provide_all(DynEnv::new()).block_on(); + /// assert!(matches!(exit, Exit::Success(21))); + /// ``` + pub fn any_env(self) -> Effect { + match self.node { + Node::Pure(r) => Effect::pure(r), + Node::Sync(f, d) => Effect { + node: Node::Sync(Box::new(move |_: &R| f(&HNil).any_env::()), d), + }, + Node::Op(i) => Effect::from_instr(Instr::Provide( + Box::new(i), + Box::new(|_: &AnyEnv| hnil_env().clone()), + )), + } + } +} diff --git a/crates/id_effect/src/kernel/local.rs b/crates/id_effect/src/kernel/local.rs index c42e8cebd..aca12fa12 100644 --- a/crates/id_effect/src/kernel/local.rs +++ b/crates/id_effect/src/kernel/local.rs @@ -36,7 +36,7 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::task::{Context, Poll, Waker}; use std::time::Duration; -use super::anybox::{AnyEnv, downcast_env, downcast_env_arc, erase_exit, unerase_exit}; +use super::anybox::{AnyEnv, downcast_env, downcast_env_arc, erase_exit, hnil_env, unerase_exit}; use super::bind::BindHold; use super::cause::{ cause_from_panic, from_exit, map_fail_mut, map_fail_once, split_failure, to_exit, @@ -622,6 +622,32 @@ impl LocalEffect { } } +impl LocalEffect { + /// Runs this environment-free local effect under any environment `R` (the `!Send` twin of + /// [`Effect::any_env`]): the result needs `R` in the type only and never reads it. + /// + /// A ready effect is returned as is (no allocation); an erased program gets one `Provide` + /// frame whose inner environment is the shared static `HNil` (no allocation per run). + /// + /// ``` + /// use id_effect::{DynEnv, Exit, LocalEffect}; + /// + /// let lifted: LocalEffect = + /// LocalEffect::::sync(|| 7).any_env(); + /// let exit = lifted.provide_env(DynEnv::new()).block_on(); + /// assert!(matches!(exit, Exit::Success(7))); + /// ``` + pub fn any_env(self) -> LocalEffect { + match self.node { + LNode::Ready(r) => LocalEffect::ready(r), + LNode::Op(i, _) => LocalEffect::from_instr(LInstr::Provide( + Box::new(i), + Box::new(|_: &AnyEnv| hnil_env().clone()), + )), + } + } +} + impl LocalEffect<(), E, R> { /// Suspends the fiber for `d` on its executor's timer. pub fn sleep(d: Duration) -> Self { diff --git a/crates/id_effect/tests/any_env.rs b/crates/id_effect/tests/any_env.rs new file mode 100644 index 000000000..00a220a56 --- /dev/null +++ b/crates/id_effect/tests/any_env.rs @@ -0,0 +1,277 @@ +//! `r13-f-any-env` (F52 / D14) acceptance tests: `Effect::any_env` and `LocalEffect::any_env` +//! run an environment-free effect under any environment `R` (the unit env, an HList, a bare +//! `DynEnv`) without changing its outcome, its interruption or its finalizers. + +use std::future::Future; +use std::pin::pin; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::task::{Context, Poll, Waker}; +use std::time::Duration; + +use id_effect::{ + Cause, Ctx, DynEnv, Effect, Executor, Exit, HCons, HNil, LocalEffect, Never, TestExecutor, die, + fail, from_async, sleep, succeed, suspend, sync, try_need, yield_fiber, +}; + +const HOUR: Duration = Duration::from_secs(3600); + +/// Polls `fut` once; it must be ready (the test executor has already run everything). +fn take_ready(fut: F) -> F::Output { + let mut fut = pin!(fut); + match fut.as_mut().poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(v) => v, + Poll::Pending => panic!("root fiber is not done"), + } +} + +/// Runs `eff` as a root fiber on a deterministic test executor, advancing virtual time while it +/// waits on a timer. +fn run(eff: Effect) -> Exit { + let exec = TestExecutor::new(); + let h = eff.fork_root(Arc::new(exec.clone())); + exec.run_until_stalled(); + while !h.is_done() { + let deadline = exec + .next_timer_deadline() + .expect("root fiber stalled with no timer pending"); + exec.advance(deadline.saturating_duration_since(exec.now())); + } + take_ready(h.join_future()) +} + +/// The three shapes of an env-free effect: ready, fused synchronous step, erased program. +fn shapes() -> Vec<(&'static str, Effect)> { + vec![ + ("ready", succeed(7)), + ("sync", sync(|| 7)), + ("fused", succeed::(3).map(|n| n + 4)), + ("program", from_async(|| async { Ok(7) })), + ( + "sync-to-program", + suspend(|| from_async(|| async { Ok(7) })), + ), + ( + "async-chain", + yield_fiber::().then(from_async(|| async { Ok(7) })), + ), + ] +} + +/// An effect over a bare `DynEnv` that reads the environment it runs under. +fn dyn_len() -> Effect { + Effect::from_ctx(|ctx: Ctx| { + let n = ctx.env().len(); + async move { Ok(n) } + }) +} + +struct Marker(&'static str); + +#[test] +fn any_env_runs_under_unit_and_dyn_env() { + for (name, eff) in shapes() { + // Under the unit environment (`HNil` itself and the literal `()`). + let unit: Effect = eff.any_env(); + assert!(matches!(run(unit), Exit::Success(7)), "{name}: HNil"); + } + for (name, eff) in shapes() { + let unit: Effect = eff.any_env(); + assert!( + matches!(run(unit.provide_all(())), Exit::Success(7)), + "{name}: ()" + ); + } + for (name, eff) in shapes() { + // Under a bare `DynEnv`: the environment outside the lifted effect is untouched, before and + // after it ran. + let procedure: Effect<(usize, u32, usize), String, DynEnv> = dyn_len() + .flat_map(move |before| eff.any_env().map(move |v| (before, v))) + .flat_map(|(before, v)| dyn_len().map(move |after| (before, v, after))); + let env = DynEnv::new().with(Marker("audit")); + assert!( + matches!(run(procedure.provide_all(env)), Exit::Success((1, 7, 1))), + "{name}: DynEnv" + ); + } + for (name, eff) in shapes() { + // Under an HList holding a `DynEnv`, next to a capability lookup. + let procedure: Effect<(u32, &'static str), String, HCons> = + eff.any_env().flat_map(|v| { + try_need::() + .map(move |m| (v, m.0)) + .map_err(|e| e.to_string()) + }); + let env = HCons::new(DynEnv::new().with(Marker("audit")), HNil); + assert!( + matches!(run(procedure.provide_all(env)), Exit::Success((7, "audit"))), + "{name}: HCons" + ); + } + + // Typed failures and defects pass through unchanged, ready or not. + let failed: Effect = fail::("no".into()).any_env(); + assert!(matches!( + run(failed.provide_all(DynEnv::new())), + Exit::Failure(Cause::Fail(e)) if e == "no" + )); + let failed_later: Effect = + from_async::(|| async { Err("later".to_owned()) }).any_env(); + assert!(matches!( + run(failed_later.provide_all(DynEnv::new())), + Exit::Failure(Cause::Fail(e)) if e == "later" + )); + let died: Effect = sync::(|| panic!("boom")).any_env(); + assert!(matches!( + run(died.provide_all(DynEnv::new())), + Exit::Failure(Cause::Die(_)) + )); + let dead: Effect = die::("dead").any_env(); + assert!(matches!( + run(dead.provide_all(DynEnv::new())), + Exit::Failure(Cause::Die(_)) + )); +} + +#[test] +fn any_env_preserves_interruption_and_finalizers() { + let go = Arc::new(AtomicBool::new(false)); + let fin = Arc::new(AtomicUsize::new(0)); + let outer_fin = Arc::new(AtomicUsize::new(0)); + + // An env-free child that marks `go`, sleeps for an hour and bumps `fin` in its finalizer. + let child: Effect<(), Never> = { + let (go, fin) = (Arc::clone(&go), Arc::clone(&fin)); + sync(move || go.store(true, Ordering::SeqCst)) + .then(sleep(HOUR)) + .ensuring(sync(move || { + fin.fetch_add(1, Ordering::SeqCst); + })) + }; + // Lifted into a `DynEnv` procedure with a finalizer of its own that reads the environment. + let procedure: Effect<(), Never, DynEnv> = { + let outer_fin = Arc::clone(&outer_fin); + child + .any_env() + .ensuring(Effect::from_ctx(move |ctx: Ctx| { + outer_fin.fetch_add(ctx.env().len(), Ordering::SeqCst); + async { Ok(()) } + })) + }; + let env = DynEnv::new().with(Marker("audit")); + + let root: Effect> = procedure.provide_all(env).fork().flat_map({ + let go = Arc::clone(&go); + move |h| wait_until(move || go.load(Ordering::SeqCst)).then(h.interrupt()) + }); + let Exit::Success(child_exit) = run(root) else { + panic!("root failed") + }; + assert!( + matches!(child_exit, Exit::Failure(Cause::Interrupt(_))), + "{child_exit:?}" + ); + assert_eq!(fin.load(Ordering::SeqCst), 1, "inner finalizer ran once"); + assert_eq!( + outer_fin.load(Ordering::SeqCst), + 1, + "outer finalizer ran under the restored DynEnv" + ); + + // An uninterruptible region inside the lifted effect still defers the interrupt to its end. + let done = Arc::new(AtomicBool::new(false)); + let masked: Effect<(), Never> = { + let (go, done) = (Arc::new(AtomicBool::new(false)), Arc::clone(&done)); + let started = Arc::clone(&go); + let lifted: Effect<(), Never, DynEnv> = sync::<(), Never, HNil>(move || { + started.store(true, Ordering::SeqCst); + }) + .then(sleep(Duration::from_secs(1))) + .then(sync(move || done.store(true, Ordering::SeqCst))) + .uninterruptible() + .any_env(); + lifted.provide_all(DynEnv::new()).fork().flat_map(move |h| { + wait_until(move || go.load(Ordering::SeqCst)) + .then(h.interrupt()) + .map(|_| ()) + }) + }; + assert!(matches!(run(masked), Exit::Success(()))); + assert!( + done.load(Ordering::SeqCst), + "the masked region ran to completion" + ); +} + +/// Yields until `pred` holds (so a forked child has reached a known point). +fn wait_until(pred: impl Fn() -> bool + Send + Sync + 'static) -> Effect<(), Never> { + fn go(pred: Arc bool + Send + Sync>) -> Effect<(), Never> { + suspend(move || { + if pred() { + succeed(()) + } else { + yield_fiber().flat_map(move |()| go(pred)) + } + }) + } + go(Arc::new(pred)) +} + +#[test] +fn local_any_env_round_trip() { + // Ready: no program to run under the new environment. + let ready: LocalEffect = LocalEffect::::succeed(3).any_env(); + assert!(matches!( + ready.provide_env(DynEnv::new()).block_on(), + Exit::Success(3) + )); + let ready_fail: LocalEffect = + LocalEffect::::fail("no".into()).any_env(); + assert!(matches!( + ready_fail.provide_env(()).block_on(), + Exit::Failure(Cause::Fail(e)) if e == "no" + )); + + // A `!Send` program lifted into a `DynEnv`, between two reads of that environment, and back + // down to `HNil` with `provide_env`. + let shared = Rc::new(5_u8); + let s = Rc::clone(&shared); + let lifted: LocalEffect = + LocalEffect::::sync(move || *s * 2).any_env(); + let len = || { + LocalEffect::::from_ctx(|ctx| { + let n = ctx.env().len(); + async move { Ok(n) } + }) + }; + let procedure = len() + .flat_map(move |before| lifted.map(move |v| (before, v))) + .flat_map(move |(before, v)| len().map(move |after| (before, v, after))); + let back: LocalEffect<(usize, u8, usize), String> = + procedure.provide_env(DynEnv::new().with(Marker("a")).with(5_u32)); + assert!(matches!(back.block_on(), Exit::Success((2, 10, 2)))); + assert_eq!( + Rc::strong_count(&shared), + 1, + "the `!Send` capture was dropped" + ); + + // A lifted program that fails or dies keeps its cause. + let died: LocalEffect = + LocalEffect::::sync(|| panic!("boom")).any_env(); + assert!(matches!( + died.provide_env(DynEnv::new()).block_on(), + Exit::Failure(Cause::Die(_)) + )); + + // A `Send` effect turned local, then lifted. + let from_send: LocalEffect = succeed::(4) + .map(|n| n + 1) + .local() + .any_env(); + assert!(matches!( + from_send.provide_env(DynEnv::new()).block_on(), + Exit::Success(5) + )); +} From d58df3c16dc020902ad0a2629850e5456145691d Mon Sep 17 00:00:00 2001 From: Tom Wieland Date: Wed, 21 Oct 2026 04:24:56 +0200 Subject: [PATCH 07/87] chore(release): ADR 0010 Amendment 9, ai move, admin/i18n/pipeline skeletons (r13-t0-skeletons) Move labs/id_effect_ai to crates/id_effect_ai (manifest paths only, transitional merge tier, PENDING_PROMOTION until r13-e9-1-promote); add the id_effect_admin and id_effect_i18n tier-integration crates and the Pipeline reference app (tier example, kit only); publish order for the three crates; CI job breadth; examples/recipes excluded from the root workspace; delete crates/id_effect_cli/templates/admin-stub; MIGRATION-0.13 headings; crates.io names checked. Signed-off-by: Tom Wieland Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 33 +++ CHANGELOG.md | 8 + Cargo.lock | 103 ++++++++ Cargo.toml | 8 +- crates/id_effect_admin/Cargo.toml | 35 +++ crates/id_effect_admin/README.md | 39 +++ .../examples/010_permissions.rs | 23 ++ crates/id_effect_admin/moon.yml | 37 +++ crates/id_effect_admin/src/lib.rs | 233 ++++++++++++++++++ crates/id_effect_admin/tests/permissions.rs | 23 ++ {labs => crates}/id_effect_ai/Cargo.toml | 16 +- {labs => crates}/id_effect_ai/README.md | 0 .../id_effect_ai/examples/010_openai_once.rs | 0 .../examples/011_anthropic_once.rs | 0 .../examples/020_cursor_agent_once.rs | 0 .../id_effect_ai/examples/ask_once.rs | 0 .../examples/mcp_server_template.rs | 0 {labs => crates}/id_effect_ai/moon.yml | 0 {labs => crates}/id_effect_ai/src/config.rs | 0 .../id_effect_ai/src/cursor/agents.rs | 0 .../id_effect_ai/src/cursor/mod.rs | 0 .../id_effect_ai/src/cursor/models.rs | 0 .../id_effect_ai/src/cursor/types.rs | 0 {labs => crates}/id_effect_ai/src/error.rs | 0 .../id_effect_ai/src/http_util.rs | 0 {labs => crates}/id_effect_ai/src/lib.rs | 0 {labs => crates}/id_effect_ai/src/model.rs | 0 {labs => crates}/id_effect_ai/src/retry.rs | 0 {labs => crates}/id_effect_ai/src/sse.rs | 0 .../id_effect_ai/src/streaming.rs | 0 .../id_effect_ai/src/tracing_util.rs | 0 .../id_effect_ai/src/vendors/anthropic.rs | 0 .../id_effect_ai/src/vendors/mod.rs | 0 .../id_effect_ai/src/vendors/openai.rs | 0 .../id_effect_ai/tests/anthropic_wiremock.rs | 0 {labs => crates}/id_effect_ai/tests/config.rs | 0 .../id_effect_ai/tests/cursor_fixtures.rs | 0 .../id_effect_ai/tests/mock_complete.rs | 0 {labs => crates}/id_effect_ai/tests/model.rs | 0 .../id_effect_ai/tests/openai_wiremock.rs | 0 .../id_effect_ai/tests/streaming.rs | 0 .../templates/admin-stub/README.md | 11 - .../admin-stub/src/admin.rs.template | 9 - crates/id_effect_i18n/Cargo.toml | 34 +++ crates/id_effect_i18n/README.md | 38 +++ crates/id_effect_i18n/examples/010_locale.rs | 19 ++ crates/id_effect_i18n/moon.yml | 37 +++ crates/id_effect_i18n/src/lib.rs | 223 +++++++++++++++++ crates/id_effect_i18n/tests/locale.rs | 13 + docs/MIGRATION-0.13.md | 67 +++++ docs/adrs/0010-crate-tiers.md | 48 ++++ examples/reference/pipeline/Cargo.toml | 31 +++ examples/reference/pipeline/README.md | 30 +++ .../reference/pipeline/examples/010_stages.rs | 18 ++ examples/reference/pipeline/moon.yml | 32 +++ examples/reference/pipeline/src/lib.rs | 112 +++++++++ examples/reference/pipeline/tests/stages.rs | 10 + labs/Cargo.toml | 5 +- scripts/validate-crate-packaging.sh | 14 +- 59 files changed, 1278 insertions(+), 31 deletions(-) create mode 100644 crates/id_effect_admin/Cargo.toml create mode 100644 crates/id_effect_admin/README.md create mode 100644 crates/id_effect_admin/examples/010_permissions.rs create mode 100644 crates/id_effect_admin/moon.yml create mode 100644 crates/id_effect_admin/src/lib.rs create mode 100644 crates/id_effect_admin/tests/permissions.rs rename {labs => crates}/id_effect_ai/Cargo.toml (72%) rename {labs => crates}/id_effect_ai/README.md (100%) rename {labs => crates}/id_effect_ai/examples/010_openai_once.rs (100%) rename {labs => crates}/id_effect_ai/examples/011_anthropic_once.rs (100%) rename {labs => crates}/id_effect_ai/examples/020_cursor_agent_once.rs (100%) rename {labs => crates}/id_effect_ai/examples/ask_once.rs (100%) rename {labs => crates}/id_effect_ai/examples/mcp_server_template.rs (100%) rename {labs => crates}/id_effect_ai/moon.yml (100%) rename {labs => crates}/id_effect_ai/src/config.rs (100%) rename {labs => crates}/id_effect_ai/src/cursor/agents.rs (100%) rename {labs => crates}/id_effect_ai/src/cursor/mod.rs (100%) rename {labs => crates}/id_effect_ai/src/cursor/models.rs (100%) rename {labs => crates}/id_effect_ai/src/cursor/types.rs (100%) rename {labs => crates}/id_effect_ai/src/error.rs (100%) rename {labs => crates}/id_effect_ai/src/http_util.rs (100%) rename {labs => crates}/id_effect_ai/src/lib.rs (100%) rename {labs => crates}/id_effect_ai/src/model.rs (100%) rename {labs => crates}/id_effect_ai/src/retry.rs (100%) rename {labs => crates}/id_effect_ai/src/sse.rs (100%) rename {labs => crates}/id_effect_ai/src/streaming.rs (100%) rename {labs => crates}/id_effect_ai/src/tracing_util.rs (100%) rename {labs => crates}/id_effect_ai/src/vendors/anthropic.rs (100%) rename {labs => crates}/id_effect_ai/src/vendors/mod.rs (100%) rename {labs => crates}/id_effect_ai/src/vendors/openai.rs (100%) rename {labs => crates}/id_effect_ai/tests/anthropic_wiremock.rs (100%) rename {labs => crates}/id_effect_ai/tests/config.rs (100%) rename {labs => crates}/id_effect_ai/tests/cursor_fixtures.rs (100%) rename {labs => crates}/id_effect_ai/tests/mock_complete.rs (100%) rename {labs => crates}/id_effect_ai/tests/model.rs (100%) rename {labs => crates}/id_effect_ai/tests/openai_wiremock.rs (100%) rename {labs => crates}/id_effect_ai/tests/streaming.rs (100%) delete mode 100644 crates/id_effect_cli/templates/admin-stub/README.md delete mode 100644 crates/id_effect_cli/templates/admin-stub/src/admin.rs.template create mode 100644 crates/id_effect_i18n/Cargo.toml create mode 100644 crates/id_effect_i18n/README.md create mode 100644 crates/id_effect_i18n/examples/010_locale.rs create mode 100644 crates/id_effect_i18n/moon.yml create mode 100644 crates/id_effect_i18n/src/lib.rs create mode 100644 crates/id_effect_i18n/tests/locale.rs create mode 100644 docs/MIGRATION-0.13.md create mode 100644 examples/reference/pipeline/Cargo.toml create mode 100644 examples/reference/pipeline/README.md create mode 100644 examples/reference/pipeline/examples/010_stages.rs create mode 100644 examples/reference/pipeline/moon.yml create mode 100644 examples/reference/pipeline/src/lib.rs create mode 100644 examples/reference/pipeline/tests/stages.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4ccaec09..3ad6edf2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -465,6 +465,37 @@ jobs: cargo nextest run --locked -p id_effect_rpc -p id_effect_rpc_macros -p relay --all-features cargo test --locked -p id_effect_rpc -p id_effect_rpc_macros --doc --all-features # ────────────────────────────────────────────────────────────────────────── + # Breadth (0.13, ADR 0010 Amendment 9): id_effect_ai (moved out of labs), id_effect_admin, + # id_effect_i18n and the Pipeline reference app, all features. They are not moon projects yet, + # so this job is what runs their clippy and tests on every PR (the workspace jobs build them + # too). No AI vendor is called: wiremock fixtures and scripted models only (0.13 D8). + # ────────────────────────────────────────────────────────────────────────── + breadth: + name: Breadth crates (ai, admin, i18n, Pipeline) + runs-on: ubuntu-latest + needs: [moon-ci] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Rust (pinned, rust-toolchain.toml) + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + components: clippy + - name: Cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: breadth + - name: Install cargo-nextest + uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2.87.27 + with: + tool: cargo-nextest + - name: Clippy + run: cargo clippy --locked -p id_effect_ai -p id_effect_admin -p id_effect_i18n -p pipeline --all-targets --all-features -- -D warnings + - name: Tests + run: | + cargo nextest run --locked -p id_effect_ai -p id_effect_admin -p id_effect_i18n -p pipeline --all-features + cargo test --locked -p id_effect_ai -p id_effect_admin -p id_effect_i18n -p pipeline --doc --all-features + # ────────────────────────────────────────────────────────────────────────── # Relay reference app (0.12 §6 KPI, never cut): three nodes on real QUIC and TLS on # loopback, the mesh (mutual TLS, `idm/1`) on the endpoint that serves HTTP/3 and # WebTransport (ADR 0014 D7), PostgreSQL in the tests' own container; the Rust client over @@ -542,6 +573,7 @@ jobs: # Labs: unpublished crates in the separate labs/ workspace (ADR 0010 Amendment 1). # No API guarantees, but they must keep building and passing against core. # id_effect_rpc left labs in 0.12 (ADR 0010 Amendment 8); the `rpc` job above tests it. + # id_effect_ai left labs in 0.13 (ADR 0010 Amendment 9); the `breadth` job above tests it. # No system packages: since 0.11 (bundled SQLite and duroxide left with the workflow crate) no # labs crate links OpenSSL, and aws-lc-sys (rustls via reqwest) builds without the cmake # binary on Linux, as in the main-workspace jobs. @@ -827,6 +859,7 @@ jobs: - reference-ledger - reference-relay - rpc + - breadth - labs - minimal-versions - validate-packaging diff --git a/CHANGELOG.md b/CHANGELOG.md index e61c847d9..af30b9515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ replacing the `Effect::from_ctx(|ctx| async move { ctx.bind(e).await })` workaround. A ready effect is returned as is; a program gets one `Provide` frame over the shared empty environment. Book: "Env-free effects under any environment" in part 2, chapter 4.3. +- Crate layout for 0.13 (r13-t0-skeletons, ADR 0010 Amendment 9): `id_effect_ai` moves from + `labs/` to `crates/id_effect_ai` with its history and joins the root workspace (still + `publish = false` until r13-e9-1-promote); new tier-`integration` crates `id_effect_i18n` + (`Locale`, a bounded, case-normalized language identifier) and `id_effect_admin` + (`AdminPermission` / `AdminAction`, the `admin..` permission model); the + Pipeline reference app (`examples/reference/pipeline`, tier `example`, its stage model); CI job + `breadth`; `examples/recipes/` is outside the root workspace. The unused + `crates/id_effect_cli/templates/admin-stub/` is removed ([MIGRATION-0.13.md](docs/MIGRATION-0.13.md)). ### Changed diff --git a/Cargo.lock b/Cargo.lock index 70eb66c6f..6a1d507d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,12 +111,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -864,8 +908,10 @@ version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", ] [[package]] @@ -929,6 +975,12 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.9" @@ -2572,6 +2624,28 @@ dependencies = [ "trybuild", ] +[[package]] +name = "id_effect_admin" +version = "0.12.0" + +[[package]] +name = "id_effect_ai" +version = "0.12.0" +dependencies = [ + "base64 0.22.1", + "clap", + "id_effect", + "id_effect_cli", + "id_effect_config", + "id_effect_platform", + "id_effect_tokio", + "serde", + "serde_json", + "thiserror 2.0.21", + "tokio", + "wiremock", +] + [[package]] name = "id_effect_auth" version = "0.12.0" @@ -2760,6 +2834,10 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "id_effect_i18n" +version = "0.12.0" + [[package]] name = "id_effect_jobs" version = "0.12.0" @@ -3297,6 +3375,12 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -4029,6 +4113,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -4378,6 +4468,13 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pipeline" +version = "0.12.0" +dependencies = [ + "id_effect_kit", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -6795,6 +6892,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.28.0" diff --git a/Cargo.toml b/Cargo.toml index 8c6fc1e66..b2c2f56aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [workspace] resolver = "2" # labs/ is a separate workspace of unpublished crates (ADR 0010 Amendment 1). -exclude = [".tmp", "tmp", "labs"] +# examples/recipes/ holds standalone recipe projects with their own CI jobs (0.13 D4, like labs/). +exclude = [".tmp", "tmp", "labs", "examples/recipes"] members = [ "crates/id_effect", "crates/id_effect_axum", @@ -36,11 +37,16 @@ members = [ # 0.12 (ADR 0010 Amendment 8): RPC moved out of labs, and its macro crate. "crates/id_effect_rpc", "crates/id_effect_rpc_macros", + # 0.13 (ADR 0010 Amendment 9): AI moved out of labs; admin and i18n are new. + "crates/id_effect_ai", + "crates/id_effect_admin", + "crates/id_effect_i18n", "examples/book-snippets", "examples/cli-minimal", "examples/http-showcase", "examples/reference/ledger", "examples/reference/relay", + "examples/reference/pipeline", # Compile-fail security cases (ADR 0010 Amendment 7); publish = false. "tests/security_compile_fail", ] diff --git a/crates/id_effect_admin/Cargo.toml b/crates/id_effect_admin/Cargo.toml new file mode 100644 index 000000000..862190b69 --- /dev/null +++ b/crates/id_effect_admin/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "id_effect_admin" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Admin back office for id_effect services: entity registration, guarded list/detail/edit pages and per-action permissions" +license.workspace = true +repository.workspace = true +readme = "README.md" +keywords = ["effect", "admin", "crud", "backoffice", "axum"] +categories = ["web-programming", "database"] + +[lints.rust] +unsafe_code = "forbid" +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } + +[lib] +name = "id_effect_admin" + +# r13-e9-3-admin-core declares the 0.13 features and dependencies (`i18n`, `audit`, `test-util`; +# id_effect_axum `ui`, id_effect_sql `repository`, id_effect_authz); the skeleton +# (r13-t0-skeletons) has none. +[dependencies] + +[[example]] +name = "010_permissions" +path = "examples/010_permissions.rs" + +[package.metadata.docs.rs] +all-features = true + +[package.metadata.id_effect] +# ADR 0010 crate tier (Amendment 9) — checked by scripts/tier-conformance.sh. Moves to `platform` +# when the Pipeline reference app depends on it (check P). +tier = "integration" diff --git a/crates/id_effect_admin/README.md b/crates/id_effect_admin/README.md new file mode 100644 index 000000000..3c8fe3ad5 --- /dev/null +++ b/crates/id_effect_admin/README.md @@ -0,0 +1,39 @@ +# id_effect_admin + +Admin back office for [`id_effect`](../id_effect) services. + +0.13 builds this crate in steps (ADR 0010 Amendment 9, release spec decision D2): an `AdminSite` +registration API that mounts only behind a guard, list and detail pages generated from entity field +metadata (search, filter, sort, pagination), edit forms that bind only declared fields, bulk +actions, and a hash-chained audit trail. `id-effect generate admin ` writes the registration +module. The first step is the permission model every admin page is checked against. + +## Quick start + +```rust +use id_effect_admin::{AdminAction, AdminPermission}; + +// One permission per entity and action: admin... +let perms = AdminPermission::all_for("document").unwrap(); +assert_eq!(perms[0].as_str(), "admin.document.list"); +assert!(perms.iter().filter(|p| p.action().writes()).count() == 4); + +// A crafted entity name cannot widen a permission. +assert!(AdminPermission::new("*", AdminAction::Delete).is_err()); +``` + +## Secure by default + +There is no public admin: every entity × action is its own permission and a principal holds none +unless granted (ADR 0013 R1). Entity names are snake_case ASCII of at most 64 bytes. + +## Examples + +```bash +cargo run -p id_effect_admin --example 010_permissions +``` + +## Tier + +`integration` (ADR 0010 Amendment 9); it moves to `platform` when the Pipeline reference app depends +on it. diff --git a/crates/id_effect_admin/examples/010_permissions.rs b/crates/id_effect_admin/examples/010_permissions.rs new file mode 100644 index 000000000..b09c472d5 --- /dev/null +++ b/crates/id_effect_admin/examples/010_permissions.rs @@ -0,0 +1,23 @@ +//! List the permissions an admin role needs for one entity. +//! +//! ```bash +//! cargo run -p id_effect_admin --example 010_permissions +//! ``` + +use id_effect_admin::AdminPermission; + +fn main() { + match AdminPermission::all_for("document") { + Ok(perms) => { + for perm in perms { + let kind = if perm.action().writes() { + "write" + } else { + "read" + }; + println!("{perm} ({kind})"); + } + } + Err(err) => eprintln!("invalid entity: {err}"), + } +} diff --git a/crates/id_effect_admin/moon.yml b/crates/id_effect_admin/moon.yml new file mode 100644 index 000000000..7514e6eb7 --- /dev/null +++ b/crates/id_effect_admin/moon.yml @@ -0,0 +1,37 @@ +# https://moonrepo.dev/docs/config/project +language: 'rust' +layer: 'library' +tasks: + check: + deps: ['effect:ci-format', '^:check'] + command: [cargo, check, -p, id_effect_admin, --all-targets, --all-features] + inputs: ['Cargo.toml', 'src/**/*.rs', 'tests/**/*.rs', 'examples/**/*.rs'] + options: {cache: true, outputStyle: buffer-only-failure} + clippy: + deps: ['effect:ci-format', '^:clippy', '~:check'] + command: [cargo, clippy, -p, id_effect_admin, --all-targets, --all-features, --, -D, warnings] + inputs: ['Cargo.toml', 'src/**/*.rs', 'tests/**/*.rs', 'examples/**/*.rs'] + options: {cache: true, outputStyle: buffer-only-failure} + lint: + description: 'Alias for ~:clippy (format-gated)' + deps: ['effect:ci-format', '~:clippy'] + command: 'bash' + args: ['-c', ':'] + options: {cache: true, outputStyle: buffer-only-failure} + test: + deps: ['effect:ci-format', '^:test', '~:clippy'] + command: [cargo, nextest, run, -p, id_effect_admin] + inputs: ['Cargo.toml', 'src/**/*.rs', 'tests/**/*.rs', 'examples/**/*.rs'] + options: {cache: true, outputStyle: buffer-only-failure} + build: + deps: ['effect:ci-format', '^:build', '~:test'] + command: [cargo, build, -p, id_effect_admin, --all-targets, --all-features] + inputs: ['Cargo.toml', 'src/**/*.rs', 'tests/**/*.rs', 'examples/**/*.rs'] + options: {cache: true, outputStyle: buffer-only-failure} + examples: + description: 'Run all crate example binaries (examples/*.rs); no-op if none' + deps: ['effect:ci-format', '~:test'] + command: 'bash' + args: ['../../scripts/moon-run-crate-examples.sh', 'id_effect_admin'] + inputs: ['Cargo.toml', 'src/**/*.rs', 'examples/**/*.rs'] + options: {cache: true, outputStyle: buffer-only-failure} diff --git a/crates/id_effect_admin/src/lib.rs b/crates/id_effect_admin/src/lib.rs new file mode 100644 index 000000000..ce129b981 --- /dev/null +++ b/crates/id_effect_admin/src/lib.rs @@ -0,0 +1,233 @@ +//! Admin back office for id_effect services. +//! +//! 0.13 builds this crate in steps (ADR 0010 Amendment 9, release spec 0.13.0 decision D2): an +//! `AdminSite` registration API (`AdminSite::register::()`) that mounts only behind a guard, +//! list and detail pages generated from entity field metadata, edit forms, bulk actions and an +//! audit trail. This first release step provides the permission model every admin page is checked +//! against. +//! +//! ## Secure by default +//! +//! Every entity × action pair is its own permission, `admin..` +//! ([`AdminPermission`]), and a principal holds none of them unless granted (ADR 0013 R1). Entity +//! names are validated, so a permission string can never be widened by a crafted name such as +//! `*` or `a.b`. +//! +//! ``` +//! use id_effect_admin::{AdminAction, AdminPermission}; +//! +//! let perm = AdminPermission::new("document", AdminAction::Update).unwrap(); +//! assert_eq!(perm.as_str(), "admin.document.update"); +//! assert!(AdminPermission::new("*", AdminAction::Delete).is_err()); +//! ``` + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +use std::fmt; + +/// Compiles and runs the README quick start as a doctest. +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +struct ReadmeDoctests; + +/// An action the admin can perform on a registered entity. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum AdminAction { + /// See the list page (search, filter, sort, paginate). + List, + /// See one record's detail page. + View, + /// Create a record. + Create, + /// Change a record. + Update, + /// Delete a record. + Delete, + /// Run a bulk action over selected records. + Bulk, +} + +impl AdminAction { + /// Every action, in the order the admin shows them. + pub const ALL: [Self; 6] = [ + Self::List, + Self::View, + Self::Create, + Self::Update, + Self::Delete, + Self::Bulk, + ]; + + /// The action's name in a permission string (`list`, `view`, …). + pub const fn as_str(self) -> &'static str { + match self { + Self::List => "list", + Self::View => "view", + Self::Create => "create", + Self::Update => "update", + Self::Delete => "delete", + Self::Bulk => "bulk", + } + } + + /// Whether the action changes data (create, update, delete, bulk). + pub const fn writes(self) -> bool { + !matches!(self, Self::List | Self::View) + } +} + +impl fmt::Display for AdminAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// The permission `admin..` that guards one action on one entity. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AdminPermission { + name: String, + entity_len: usize, + action: AdminAction, +} + +/// Why [`AdminPermission::new`] rejected an entity name. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AdminPermissionError { + /// The entity name was empty. + EmptyEntity, + /// The entity name was longer than [`AdminPermission::MAX_ENTITY_LEN`] bytes. + EntityTooLong, + /// The entity name has a character outside `a-z`, `0-9` and `_`, or does not start with a + /// letter. + InvalidEntity, +} + +impl fmt::Display for AdminPermissionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::EmptyEntity => "admin entity name is empty", + Self::EntityTooLong => "admin entity name is longer than the limit", + Self::InvalidEntity => "admin entity name must be snake_case ASCII starting with a letter", + }) + } +} + +impl std::error::Error for AdminPermissionError {} + +impl AdminPermission { + /// The longest entity name accepted, in bytes. + pub const MAX_ENTITY_LEN: usize = 64; + + const PREFIX: &'static str = "admin."; + + /// The permission for `action` on `entity` (a snake_case entity name such as `document`). + /// + /// # Errors + /// + /// Returns an [`AdminPermissionError`] when `entity` is empty, longer than + /// [`AdminPermission::MAX_ENTITY_LEN`] bytes, or not snake_case ASCII starting with a letter. + pub fn new(entity: &str, action: AdminAction) -> Result { + if entity.is_empty() { + return Err(AdminPermissionError::EmptyEntity); + } + if entity.len() > Self::MAX_ENTITY_LEN { + return Err(AdminPermissionError::EntityTooLong); + } + let starts_with_letter = entity.as_bytes()[0].is_ascii_lowercase(); + let snake = entity + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_'); + if !starts_with_letter || !snake { + return Err(AdminPermissionError::InvalidEntity); + } + Ok(Self { + name: format!("{}{entity}.{}", Self::PREFIX, action.as_str()), + entity_len: entity.len(), + action, + }) + } + + /// Every permission for `entity`, one per [`AdminAction`]. + /// + /// # Errors + /// + /// As [`AdminPermission::new`]. + pub fn all_for(entity: &str) -> Result, AdminPermissionError> { + AdminAction::ALL + .iter() + .map(|action| Self::new(entity, *action)) + .collect() + } + + /// The permission string, for example `admin.document.update`. + pub fn as_str(&self) -> &str { + &self.name + } + + /// The entity name. + pub fn entity(&self) -> &str { + &self.name[Self::PREFIX.len()..Self::PREFIX.len() + self.entity_len] + } + + /// The action. + pub fn action(&self) -> AdminAction { + self.action + } +} + +impl fmt::Display for AdminPermission { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn admin_permission_names_entity_and_action() { + let perm = AdminPermission::new("document_label", AdminAction::Bulk).unwrap(); + assert_eq!(perm.as_str(), "admin.document_label.bulk"); + assert_eq!(perm.to_string(), "admin.document_label.bulk"); + assert_eq!(perm.entity(), "document_label"); + assert_eq!(perm.action(), AdminAction::Bulk); + } + + #[test] + fn admin_actions_cover_read_and_write() { + let names: Vec = AdminAction::ALL.iter().map(ToString::to_string).collect(); + assert_eq!( + names, + ["list", "view", "create", "update", "delete", "bulk"] + ); + let writes: Vec = AdminAction::ALL.iter().map(|a| a.writes()).collect(); + assert_eq!(writes, [false, false, true, true, true, true]); + } + + #[test] + fn admin_permission_rejects_wildcards_and_oversize_names() { + use AdminPermissionError::*; + assert_eq!( + AdminPermission::new("", AdminAction::List), + Err(EmptyEntity) + ); + let long = "a".repeat(AdminPermission::MAX_ENTITY_LEN + 1); + assert_eq!( + AdminPermission::new(&long, AdminAction::List), + Err(EntityTooLong) + ); + for bad in ["*", "a.b", "Document", "1doc", "_doc", "doc-x", "dóc"] { + assert_eq!( + AdminPermission::new(bad, AdminAction::List), + Err(InvalidEntity), + "{bad}" + ); + } + assert_eq!(AdminPermission::all_for("a*"), Err(InvalidEntity)); + for err in [EmptyEntity, EntityTooLong, InvalidEntity] { + assert!(!err.to_string().is_empty()); + } + } +} diff --git a/crates/id_effect_admin/tests/permissions.rs b/crates/id_effect_admin/tests/permissions.rs new file mode 100644 index 000000000..741ef661d --- /dev/null +++ b/crates/id_effect_admin/tests/permissions.rs @@ -0,0 +1,23 @@ +//! Public-API test of the admin permission model (r13-t0-skeletons). + +use id_effect_admin::{AdminAction, AdminPermission}; + +#[test] +fn admin_permissions_one_per_action_and_distinct() { + let perms = AdminPermission::all_for("source").unwrap(); + let names: Vec<&str> = perms.iter().map(AdminPermission::as_str).collect(); + assert_eq!( + names, + [ + "admin.source.list", + "admin.source.view", + "admin.source.create", + "admin.source.update", + "admin.source.delete", + "admin.source.bulk", + ] + ); + assert!(perms.iter().all(|p| p.entity() == "source")); + let actions: Vec = perms.iter().map(AdminPermission::action).collect(); + assert_eq!(actions, AdminAction::ALL); +} diff --git a/labs/id_effect_ai/Cargo.toml b/crates/id_effect_ai/Cargo.toml similarity index 72% rename from labs/id_effect_ai/Cargo.toml rename to crates/id_effect_ai/Cargo.toml index acb51d7e4..ce79dfcd0 100644 --- a/labs/id_effect_ai/Cargo.toml +++ b/crates/id_effect_ai/Cargo.toml @@ -23,9 +23,9 @@ cursor = [] full = ["openai", "anthropic", "cursor"] [dependencies] -id_effect = { path = "../../crates/id_effect", version = "0.12.0" } -id_effect_config = { path = "../../crates/id_effect_config", version = "0.12.0" } -id_effect_platform = { path = "../../crates/id_effect_platform", version = "0.12.0" } +id_effect = { path = "../id_effect", version = "0.12.0" } +id_effect_config = { path = "../id_effect_config", version = "0.12.0" } +id_effect_platform = { path = "../id_effect_platform", version = "0.12.0" } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" @@ -33,8 +33,8 @@ base64 = "0.22" [dev-dependencies] tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "time"] } -id_effect_tokio = { path = "../../crates/id_effect_tokio" } -id_effect_cli = { path = "../../crates/id_effect_cli", features = ["clap"] } +id_effect_tokio = { path = "../id_effect_tokio" } +id_effect_cli = { path = "../id_effect_cli", features = ["clap"] } wiremock = "0.6" clap = { version = "4", features = ["derive"] } @@ -63,5 +63,7 @@ path = "examples/020_cursor_agent_once.rs" required-features = ["cursor"] [package.metadata.id_effect] -# ADR 0010 crate tier — checked by scripts/tier-conformance.sh -tier = "labs" +# ADR 0010 crate tier — checked by scripts/tier-conformance.sh. Amendment 9 (0.13): moved out of +# labs/ by r13-t0-skeletons; transitional `merge` (publish = false, listed in PENDING_PROMOTION of +# scripts/validate-crate-packaging.sh) until r13-e9-1-promote brings it to `integration`. +tier = "merge" diff --git a/labs/id_effect_ai/README.md b/crates/id_effect_ai/README.md similarity index 100% rename from labs/id_effect_ai/README.md rename to crates/id_effect_ai/README.md diff --git a/labs/id_effect_ai/examples/010_openai_once.rs b/crates/id_effect_ai/examples/010_openai_once.rs similarity index 100% rename from labs/id_effect_ai/examples/010_openai_once.rs rename to crates/id_effect_ai/examples/010_openai_once.rs diff --git a/labs/id_effect_ai/examples/011_anthropic_once.rs b/crates/id_effect_ai/examples/011_anthropic_once.rs similarity index 100% rename from labs/id_effect_ai/examples/011_anthropic_once.rs rename to crates/id_effect_ai/examples/011_anthropic_once.rs diff --git a/labs/id_effect_ai/examples/020_cursor_agent_once.rs b/crates/id_effect_ai/examples/020_cursor_agent_once.rs similarity index 100% rename from labs/id_effect_ai/examples/020_cursor_agent_once.rs rename to crates/id_effect_ai/examples/020_cursor_agent_once.rs diff --git a/labs/id_effect_ai/examples/ask_once.rs b/crates/id_effect_ai/examples/ask_once.rs similarity index 100% rename from labs/id_effect_ai/examples/ask_once.rs rename to crates/id_effect_ai/examples/ask_once.rs diff --git a/labs/id_effect_ai/examples/mcp_server_template.rs b/crates/id_effect_ai/examples/mcp_server_template.rs similarity index 100% rename from labs/id_effect_ai/examples/mcp_server_template.rs rename to crates/id_effect_ai/examples/mcp_server_template.rs diff --git a/labs/id_effect_ai/moon.yml b/crates/id_effect_ai/moon.yml similarity index 100% rename from labs/id_effect_ai/moon.yml rename to crates/id_effect_ai/moon.yml diff --git a/labs/id_effect_ai/src/config.rs b/crates/id_effect_ai/src/config.rs similarity index 100% rename from labs/id_effect_ai/src/config.rs rename to crates/id_effect_ai/src/config.rs diff --git a/labs/id_effect_ai/src/cursor/agents.rs b/crates/id_effect_ai/src/cursor/agents.rs similarity index 100% rename from labs/id_effect_ai/src/cursor/agents.rs rename to crates/id_effect_ai/src/cursor/agents.rs diff --git a/labs/id_effect_ai/src/cursor/mod.rs b/crates/id_effect_ai/src/cursor/mod.rs similarity index 100% rename from labs/id_effect_ai/src/cursor/mod.rs rename to crates/id_effect_ai/src/cursor/mod.rs diff --git a/labs/id_effect_ai/src/cursor/models.rs b/crates/id_effect_ai/src/cursor/models.rs similarity index 100% rename from labs/id_effect_ai/src/cursor/models.rs rename to crates/id_effect_ai/src/cursor/models.rs diff --git a/labs/id_effect_ai/src/cursor/types.rs b/crates/id_effect_ai/src/cursor/types.rs similarity index 100% rename from labs/id_effect_ai/src/cursor/types.rs rename to crates/id_effect_ai/src/cursor/types.rs diff --git a/labs/id_effect_ai/src/error.rs b/crates/id_effect_ai/src/error.rs similarity index 100% rename from labs/id_effect_ai/src/error.rs rename to crates/id_effect_ai/src/error.rs diff --git a/labs/id_effect_ai/src/http_util.rs b/crates/id_effect_ai/src/http_util.rs similarity index 100% rename from labs/id_effect_ai/src/http_util.rs rename to crates/id_effect_ai/src/http_util.rs diff --git a/labs/id_effect_ai/src/lib.rs b/crates/id_effect_ai/src/lib.rs similarity index 100% rename from labs/id_effect_ai/src/lib.rs rename to crates/id_effect_ai/src/lib.rs diff --git a/labs/id_effect_ai/src/model.rs b/crates/id_effect_ai/src/model.rs similarity index 100% rename from labs/id_effect_ai/src/model.rs rename to crates/id_effect_ai/src/model.rs diff --git a/labs/id_effect_ai/src/retry.rs b/crates/id_effect_ai/src/retry.rs similarity index 100% rename from labs/id_effect_ai/src/retry.rs rename to crates/id_effect_ai/src/retry.rs diff --git a/labs/id_effect_ai/src/sse.rs b/crates/id_effect_ai/src/sse.rs similarity index 100% rename from labs/id_effect_ai/src/sse.rs rename to crates/id_effect_ai/src/sse.rs diff --git a/labs/id_effect_ai/src/streaming.rs b/crates/id_effect_ai/src/streaming.rs similarity index 100% rename from labs/id_effect_ai/src/streaming.rs rename to crates/id_effect_ai/src/streaming.rs diff --git a/labs/id_effect_ai/src/tracing_util.rs b/crates/id_effect_ai/src/tracing_util.rs similarity index 100% rename from labs/id_effect_ai/src/tracing_util.rs rename to crates/id_effect_ai/src/tracing_util.rs diff --git a/labs/id_effect_ai/src/vendors/anthropic.rs b/crates/id_effect_ai/src/vendors/anthropic.rs similarity index 100% rename from labs/id_effect_ai/src/vendors/anthropic.rs rename to crates/id_effect_ai/src/vendors/anthropic.rs diff --git a/labs/id_effect_ai/src/vendors/mod.rs b/crates/id_effect_ai/src/vendors/mod.rs similarity index 100% rename from labs/id_effect_ai/src/vendors/mod.rs rename to crates/id_effect_ai/src/vendors/mod.rs diff --git a/labs/id_effect_ai/src/vendors/openai.rs b/crates/id_effect_ai/src/vendors/openai.rs similarity index 100% rename from labs/id_effect_ai/src/vendors/openai.rs rename to crates/id_effect_ai/src/vendors/openai.rs diff --git a/labs/id_effect_ai/tests/anthropic_wiremock.rs b/crates/id_effect_ai/tests/anthropic_wiremock.rs similarity index 100% rename from labs/id_effect_ai/tests/anthropic_wiremock.rs rename to crates/id_effect_ai/tests/anthropic_wiremock.rs diff --git a/labs/id_effect_ai/tests/config.rs b/crates/id_effect_ai/tests/config.rs similarity index 100% rename from labs/id_effect_ai/tests/config.rs rename to crates/id_effect_ai/tests/config.rs diff --git a/labs/id_effect_ai/tests/cursor_fixtures.rs b/crates/id_effect_ai/tests/cursor_fixtures.rs similarity index 100% rename from labs/id_effect_ai/tests/cursor_fixtures.rs rename to crates/id_effect_ai/tests/cursor_fixtures.rs diff --git a/labs/id_effect_ai/tests/mock_complete.rs b/crates/id_effect_ai/tests/mock_complete.rs similarity index 100% rename from labs/id_effect_ai/tests/mock_complete.rs rename to crates/id_effect_ai/tests/mock_complete.rs diff --git a/labs/id_effect_ai/tests/model.rs b/crates/id_effect_ai/tests/model.rs similarity index 100% rename from labs/id_effect_ai/tests/model.rs rename to crates/id_effect_ai/tests/model.rs diff --git a/labs/id_effect_ai/tests/openai_wiremock.rs b/crates/id_effect_ai/tests/openai_wiremock.rs similarity index 100% rename from labs/id_effect_ai/tests/openai_wiremock.rs rename to crates/id_effect_ai/tests/openai_wiremock.rs diff --git a/labs/id_effect_ai/tests/streaming.rs b/crates/id_effect_ai/tests/streaming.rs similarity index 100% rename from labs/id_effect_ai/tests/streaming.rs rename to crates/id_effect_ai/tests/streaming.rs diff --git a/crates/id_effect_cli/templates/admin-stub/README.md b/crates/id_effect_cli/templates/admin-stub/README.md deleted file mode 100644 index 6219516d4..000000000 --- a/crates/id_effect_cli/templates/admin-stub/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Admin scaffold stub - -Placeholder internal admin UI — wire routes into your Axum host. -Not a production admin product; extend with your auth layer. - -## Why this directory stays - -Nothing generates, builds or tests it: `id-effect new` uses its own templates in -`crates/id_effect_tool/templates/`. It is kept because the capability scorecard cites it as the -evidence of its admin row (`docs/scorecard/scorecard.json`, "Template stub only"). Remove it -together with that evidence when the scorecard is next re-scored. diff --git a/crates/id_effect_cli/templates/admin-stub/src/admin.rs.template b/crates/id_effect_cli/templates/admin-stub/src/admin.rs.template deleted file mode 100644 index 8127a859b..000000000 --- a/crates/id_effect_cli/templates/admin-stub/src/admin.rs.template +++ /dev/null @@ -1,9 +0,0 @@ -//! Admin stub routes for {{name}} - -use axum::{Router, routing::get}; - -pub fn admin_router() -> Router { - Router::new() - .route("/admin", get(|| async { "admin home (stub)" })) - .route("/admin/health", get(|| async { "ok" })) -} diff --git a/crates/id_effect_i18n/Cargo.toml b/crates/id_effect_i18n/Cargo.toml new file mode 100644 index 000000000..eab0709ab --- /dev/null +++ b/crates/id_effect_i18n/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "id_effect_i18n" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Internationalization for id_effect services: locales, message catalogs and per-request locale selection" +license.workspace = true +repository.workspace = true +readme = "README.md" +keywords = ["effect", "i18n", "l10n", "locale", "fluent"] +categories = ["internationalization", "localization"] + +[lints.rust] +unsafe_code = "forbid" +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } + +[lib] +name = "id_effect_i18n" + +# r13-e9-4-i18n-core declares the 0.13 features and dependencies (Project Fluent, `axum`, `maud`, +# `schema`); the skeleton (r13-t0-skeletons) has none. +[dependencies] + +[[example]] +name = "010_locale" +path = "examples/010_locale.rs" + +[package.metadata.docs.rs] +all-features = true + +[package.metadata.id_effect] +# ADR 0010 crate tier (Amendment 9) — checked by scripts/tier-conformance.sh. Moves to `platform` +# when the Pipeline reference app depends on it (check P). +tier = "integration" diff --git a/crates/id_effect_i18n/README.md b/crates/id_effect_i18n/README.md new file mode 100644 index 000000000..29cfbe1e8 --- /dev/null +++ b/crates/id_effect_i18n/README.md @@ -0,0 +1,38 @@ +# id_effect_i18n + +Internationalization for [`id_effect`](../id_effect) services. + +0.13 builds this crate in steps (ADR 0010 Amendment 9): Project Fluent message catalogs with CLDR +plural rules, a `CurrentLocale` capability switched per request or fiber, `Accept-Language` +negotiation for `id_effect_axum`, and translated `Schema` validation messages. The first step is +`Locale`, a validated, case-normalized language identifier. + +## Quick start + +```rust +use id_effect_i18n::Locale; + +let locale: Locale = "de_at".parse().unwrap(); +assert_eq!(locale.as_str(), "de-AT"); +assert_eq!(locale.language_only().as_str(), "de"); + +// Untrusted input never becomes a path or an unbounded string. +assert!(Locale::parse("../../etc/passwd").is_err()); +``` + +## Secure by default + +`Locale::parse` reads at most `Locale::MAX_LEN` bytes and accepts only ASCII subtags of the shapes +`language[-Script][-REGION]` (ADR 0013 R4), so a locale taken from a header or a query parameter is +safe to use as a catalog key. + +## Examples + +```bash +cargo run -p id_effect_i18n --example 010_locale +``` + +## Tier + +`integration` (ADR 0010 Amendment 9); it moves to `platform` when the Pipeline reference app depends +on it. diff --git a/crates/id_effect_i18n/examples/010_locale.rs b/crates/id_effect_i18n/examples/010_locale.rs new file mode 100644 index 000000000..21d70200c --- /dev/null +++ b/crates/id_effect_i18n/examples/010_locale.rs @@ -0,0 +1,19 @@ +//! Parse and normalize locales the way a request handler receives them. +//! +//! ```bash +//! cargo run -p id_effect_i18n --example 010_locale +//! ``` + +use id_effect_i18n::Locale; + +fn main() { + for input in ["en", "de_at", "sr-latn-rs", "es-419", "../etc", ""] { + match Locale::parse(input) { + Ok(locale) => println!( + "{input:?} -> {locale} (fallback {})", + locale.language_only() + ), + Err(err) => println!("{input:?} rejected: {err}"), + } + } +} diff --git a/crates/id_effect_i18n/moon.yml b/crates/id_effect_i18n/moon.yml new file mode 100644 index 000000000..d2e43fd5c --- /dev/null +++ b/crates/id_effect_i18n/moon.yml @@ -0,0 +1,37 @@ +# https://moonrepo.dev/docs/config/project +language: 'rust' +layer: 'library' +tasks: + check: + deps: ['effect:ci-format', '^:check'] + command: [cargo, check, -p, id_effect_i18n, --all-targets, --all-features] + inputs: ['Cargo.toml', 'src/**/*.rs', 'tests/**/*.rs', 'examples/**/*.rs'] + options: {cache: true, outputStyle: buffer-only-failure} + clippy: + deps: ['effect:ci-format', '^:clippy', '~:check'] + command: [cargo, clippy, -p, id_effect_i18n, --all-targets, --all-features, --, -D, warnings] + inputs: ['Cargo.toml', 'src/**/*.rs', 'tests/**/*.rs', 'examples/**/*.rs'] + options: {cache: true, outputStyle: buffer-only-failure} + lint: + description: 'Alias for ~:clippy (format-gated)' + deps: ['effect:ci-format', '~:clippy'] + command: 'bash' + args: ['-c', ':'] + options: {cache: true, outputStyle: buffer-only-failure} + test: + deps: ['effect:ci-format', '^:test', '~:clippy'] + command: [cargo, nextest, run, -p, id_effect_i18n] + inputs: ['Cargo.toml', 'src/**/*.rs', 'tests/**/*.rs', 'examples/**/*.rs'] + options: {cache: true, outputStyle: buffer-only-failure} + build: + deps: ['effect:ci-format', '^:build', '~:test'] + command: [cargo, build, -p, id_effect_i18n, --all-targets, --all-features] + inputs: ['Cargo.toml', 'src/**/*.rs', 'tests/**/*.rs', 'examples/**/*.rs'] + options: {cache: true, outputStyle: buffer-only-failure} + examples: + description: 'Run all crate example binaries (examples/*.rs); no-op if none' + deps: ['effect:ci-format', '~:test'] + command: 'bash' + args: ['../../scripts/moon-run-crate-examples.sh', 'id_effect_i18n'] + inputs: ['Cargo.toml', 'src/**/*.rs', 'examples/**/*.rs'] + options: {cache: true, outputStyle: buffer-only-failure} diff --git a/crates/id_effect_i18n/src/lib.rs b/crates/id_effect_i18n/src/lib.rs new file mode 100644 index 000000000..6e0d2cac5 --- /dev/null +++ b/crates/id_effect_i18n/src/lib.rs @@ -0,0 +1,223 @@ +//! Internationalization for id_effect services. +//! +//! 0.13 builds this crate in steps (ADR 0010 Amendment 9, release spec 0.13.0 decision D5): Project +//! Fluent message catalogs with CLDR plural rules, a `CurrentLocale` capability switched per +//! request or fiber, `Accept-Language` negotiation and translated `Schema` errors. This first +//! release step provides [`Locale`], a validated language identifier. +//! +//! ## Secure by default +//! +//! A locale usually comes from untrusted input (an `Accept-Language` header, a query parameter, a +//! user setting). [`Locale::parse`] reads at most [`Locale::MAX_LEN`] bytes, accepts only ASCII +//! subtags of the documented shapes and normalizes their case, so a parsed locale can never carry a +//! path separator, a control character or an unbounded string into a catalog lookup. +//! +//! ``` +//! use id_effect_i18n::Locale; +//! +//! let locale = Locale::parse("de_at").unwrap(); +//! assert_eq!(locale.as_str(), "de-AT"); +//! assert_eq!(locale.language(), "de"); +//! assert_eq!(locale.region(), Some("AT")); +//! assert!(Locale::parse("../etc/passwd").is_err()); +//! ``` + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +use std::fmt; + +/// Compiles and runs the README quick start as a doctest. +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +struct ReadmeDoctests; + +/// A validated language identifier: `language[-Script][-REGION]`. +/// +/// The language subtag is 2–3 ASCII letters (stored lowercase), the optional script subtag 4 ASCII +/// letters (stored titlecase) and the optional region subtag 2 ASCII letters (stored uppercase) or +/// 3 ASCII digits. Subtags are separated by `-` or `_`; the canonical form uses `-`. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Locale { + tag: String, + language_len: usize, + script: Option<(usize, usize)>, + region: Option<(usize, usize)>, +} + +/// Why [`Locale::parse`] rejected its input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LocaleError { + /// The input was empty. + Empty, + /// The input was longer than [`Locale::MAX_LEN`] bytes. + TooLong, + /// The language subtag is not 2–3 ASCII letters. + InvalidLanguage, + /// A subtag after the language is neither a script (4 letters) nor a region (2 letters or 3 + /// digits), or appears out of order. + InvalidSubtag, +} + +impl fmt::Display for LocaleError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Empty => "locale is empty", + Self::TooLong => "locale is longer than the limit", + Self::InvalidLanguage => "locale language subtag must be 2-3 ASCII letters", + Self::InvalidSubtag => "locale subtag is not a script or region", + }) + } +} + +impl std::error::Error for LocaleError {} + +impl Locale { + /// The longest input [`Locale::parse`] reads, in bytes. + pub const MAX_LEN: usize = 16; + + /// Parses and normalizes a language identifier such as `en`, `de-AT`, `sr_Latn_RS` or `es-419`. + /// + /// # Errors + /// + /// Returns a [`LocaleError`] when the input is empty, longer than [`Locale::MAX_LEN`] bytes, or + /// not of the shape `language[-Script][-REGION]`. + pub fn parse(input: &str) -> Result { + if input.is_empty() { + return Err(LocaleError::Empty); + } + if input.len() > Self::MAX_LEN { + return Err(LocaleError::TooLong); + } + let mut parts = input.split(['-', '_']); + let language = parts.next().unwrap_or_default(); + if !(2..=3).contains(&language.len()) || !language.bytes().all(|b| b.is_ascii_alphabetic()) { + return Err(LocaleError::InvalidLanguage); + } + let mut tag = language.to_ascii_lowercase(); + let language_len = tag.len(); + let mut script = None; + let mut region = None; + for part in parts { + let start = tag.len() + 1; + if script.is_none() && region.is_none() && is_script(part) { + tag.push('-'); + tag.push_str(&part[..1].to_ascii_uppercase()); + tag.push_str(&part[1..].to_ascii_lowercase()); + script = Some((start, tag.len())); + } else if region.is_none() && is_region(part) { + tag.push('-'); + tag.push_str(&part.to_ascii_uppercase()); + region = Some((start, tag.len())); + } else { + return Err(LocaleError::InvalidSubtag); + } + } + Ok(Self { + tag, + language_len, + script, + region, + }) + } + + /// The canonical form, for example `de-AT`. + pub fn as_str(&self) -> &str { + &self.tag + } + + /// The language subtag, lowercase (`de` for `de-AT`). + pub fn language(&self) -> &str { + &self.tag[..self.language_len] + } + + /// The script subtag, titlecase (`Latn` for `sr-Latn-RS`), if present. + pub fn script(&self) -> Option<&str> { + self.script.map(|(start, end)| &self.tag[start..end]) + } + + /// The region subtag, uppercase or digits (`AT` for `de-AT`, `419` for `es-419`), if present. + pub fn region(&self) -> Option<&str> { + self.region.map(|(start, end)| &self.tag[start..end]) + } + + /// The locale with only its language subtag (`de` for `de-AT`): the next step of a fallback + /// chain. + pub fn language_only(&self) -> Self { + Self { + tag: self.language().to_owned(), + language_len: self.language_len, + script: None, + region: None, + } + } +} + +impl fmt::Display for Locale { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.tag) + } +} + +impl std::str::FromStr for Locale { + type Err = LocaleError; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +fn is_script(part: &str) -> bool { + part.len() == 4 && part.bytes().all(|b| b.is_ascii_alphabetic()) +} + +fn is_region(part: &str) -> bool { + (part.len() == 2 && part.bytes().all(|b| b.is_ascii_alphabetic())) + || (part.len() == 3 && part.bytes().all(|b| b.is_ascii_digit())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn locale_parse_normalizes_case_and_separator() { + let locale = Locale::parse("SR_latn_rs").unwrap(); + assert_eq!(locale.as_str(), "sr-Latn-RS"); + assert_eq!(locale.language(), "sr"); + assert_eq!(locale.script(), Some("Latn")); + assert_eq!(locale.region(), Some("RS")); + assert_eq!(locale.to_string(), "sr-Latn-RS"); + assert_eq!(locale.language_only().as_str(), "sr"); + } + + #[test] + fn locale_parse_accepts_numeric_region_and_language_only() { + let es = "es-419".parse::().unwrap(); + assert_eq!(es.region(), Some("419")); + assert_eq!(es.script(), None); + let en = Locale::parse("en").unwrap(); + assert_eq!((en.script(), en.region()), (None, None)); + assert_eq!(en.language_only(), en); + } + + #[test] + fn r4_locale_parse_rejects_malformed_and_oversize_input() { + assert_eq!(Locale::parse(""), Err(LocaleError::Empty)); + assert_eq!(Locale::parse(&"a".repeat(17)), Err(LocaleError::TooLong)); + assert_eq!(Locale::parse("e"), Err(LocaleError::InvalidLanguage)); + assert_eq!(Locale::parse("e1"), Err(LocaleError::InvalidLanguage)); + assert_eq!(Locale::parse("../etc"), Err(LocaleError::InvalidLanguage)); + assert_eq!(Locale::parse("de-AT-Latn"), Err(LocaleError::InvalidSubtag)); + assert_eq!(Locale::parse("de-"), Err(LocaleError::InvalidSubtag)); + assert_eq!(Locale::parse("de-AT-DE"), Err(LocaleError::InvalidSubtag)); + for err in [ + LocaleError::Empty, + LocaleError::TooLong, + LocaleError::InvalidLanguage, + LocaleError::InvalidSubtag, + ] { + assert!(!err.to_string().is_empty()); + } + } +} diff --git a/crates/id_effect_i18n/tests/locale.rs b/crates/id_effect_i18n/tests/locale.rs new file mode 100644 index 000000000..b4c1d88bd --- /dev/null +++ b/crates/id_effect_i18n/tests/locale.rs @@ -0,0 +1,13 @@ +//! Public-API test of `Locale` (r13-t0-skeletons). + +use id_effect_i18n::{Locale, LocaleError}; + +#[test] +fn locale_round_trips_through_display_and_from_str() { + for input in ["en", "de-AT", "sr-Latn-RS", "es-419", "zh-Hant"] { + let locale: Locale = input.parse().unwrap(); + assert_eq!(locale.to_string(), input); + assert_eq!(Locale::parse(locale.as_str()).unwrap(), locale); + } + assert_eq!("".parse::(), Err(LocaleError::Empty)); +} diff --git a/docs/MIGRATION-0.13.md b/docs/MIGRATION-0.13.md new file mode 100644 index 000000000..1fb88a4b8 --- /dev/null +++ b/docs/MIGRATION-0.13.md @@ -0,0 +1,67 @@ +# Migrating to id_effect 0.13 + +This guide is the "if you used X, now do Y" companion to the `0.13.0` section of +[CHANGELOG.md](../CHANGELOG.md). It assumes you are on 0.12. If you are on 0.11, apply +[MIGRATION-0.12.md](MIGRATION-0.12.md) first. + +Every intentional public-API break in 0.13 is listed here, each with its shim (rule L9) or the +reason it has none. A break reported by `cargo semver-checks` (the `semver` CI job, baseline the +last 0.12.x release) that is not listed below is a bug. The deprecation rule and semver policy are +in [STABILITY.md](STABILITY.md). + +0.13 is the "Breadth" release: AI (`id_effect_ai`, promoted from labs), an admin back office +(`id_effect_admin`), server-rendered UI (`id_effect_axum` feature `ui`), i18n (`id_effect_i18n`), +ORM relations, keyset pagination and lifecycle hooks, and tenancy helpers. + +Each section below names the leaf that owns it; the leaf writes its body when it lands, and +`r13-t0-migration-guide` checks the list against `cargo semver-checks` at the release exit. + +## SQL: `id_effect_sql::repository` split into a module tree + +Owner: r13-e3-3-relations. The paths of 0.12 stay importable through `pub use`. + +## SQL: `Entity` gains associated items + +Owners: r13-e3-3-relations, r13-e3-3-hooks. Every new item has a default, so manual `Entity` +implementations keep compiling. + +## Events: `EventLog::head` + +Owner: r13-f-eventlog-head. A provided method; implementors outside the workspace need no change. + +## Axum: `WsConfig` control-frame cap + +Owner: r13-f-ws-credit. + +## Axum / platform: `Http3Options` early-data and congestion settings + +Owner: r13-f-h3-settings. + +## RPC: `HttpTransport::with_header` / `with_bearer` + +Owner: r13-f-client-batching. + +## TLS: rcgen 0.14 + +Owner: r13-f-rcgen. Only a break if an rcgen type appears in a public signature. + +## CLI: `crates/id_effect_cli/templates/admin-stub` removed + +Owner: r13-t0-skeletons. The `admin-stub` directory shipped as unused files in the `id_effect_cli` +package; no API, command or template ever read it (`id-effect new` uses its own templates in +`crates/id_effect_tool/templates/`). Nothing to change in code. For an admin, use the new +`id_effect_admin` crate (ADR 0010 Amendment 9) and `id-effect generate admin `. + +## AI: `id_effect_ai` published from `crates/` (was `labs/`) + +Owner: r13-e9-1-promote. The crate moved from `labs/id_effect_ai` to `crates/id_effect_ai` (ADR 0010 +Amendment 9) and is a root-workspace member. A path dependency on the labs crate changes its path: + +```toml +# before (0.12, labs) +id_effect_ai = { path = "../id_effect/labs/id_effect_ai" } +# after (0.13) +id_effect_ai = { path = "../id_effect/crates/id_effect_ai" } # or: id_effect_ai = "0.13" +``` + +The API changes of the promotion are listed here by r13-e9-1-promote. diff --git a/docs/adrs/0010-crate-tiers.md b/docs/adrs/0010-crate-tiers.md index f484a9c98..bb79a851a 100644 --- a/docs/adrs/0010-crate-tiers.md +++ b/docs/adrs/0010-crate-tiers.md @@ -291,6 +291,54 @@ amendment, this amendment wins. excluded from the semver job until their first 0.12 publish. No placeholder versions are published: each crate's first release carries its API. +#### Amendment 9 (2026-10-21, for 0.13) + +Names the tiers of the three crates of 0.13 "Breadth" and of the Pipeline reference app (release +spec 0.13.0 decisions D1, D2, D3, D5 and D6), as rule 5 requires. Where the table above disagrees +with this amendment, this amendment wins. + +- **`id_effect_ai` leaves the `labs` row and enters tier `integration`.** Provider-neutral chat + (OpenAI, Anthropic) with streaming, typed tools and a bounded agent loop, structured output, + embeddings (OpenAI, Ollama) and an effect-native MCP server and client; all HTTP goes through + `id_effect_platform`'s client. The Cursor agents module stays behind the non-default `cursor` + feature. The crate moved with its history (`git mv labs/id_effect_ai crates/id_effect_ai`, leaf + `r13-t0-skeletons`; manifest paths only) and is a root-workspace member. Until leaf + `r13-e9-1-promote` brings it to the integration bar it stays `publish = false` in the + transitional `merge` tier (no bar; the `labs` tier is for crates under `labs/` only), listed in + `PENDING_PROMOTION` of `scripts/validate-crate-packaging.sh`; that leaf sets `integration`, + makes it publishable and removes the entry. +- **`id_effect_i18n` is tier `integration`** (new). Project Fluent message catalogs with CLDR + plural rules, a per-request / per-fiber locale, `Accept-Language` negotiation and translated + `Schema` messages. +- **`id_effect_admin` is tier `integration`** (new). The back office: `AdminSite` registration of + `id_effect_sql` entities, list/detail/edit pages rendered with `id_effect_axum`'s `ui` feature, + per-entity × action permissions through `id_effect_authz`, mounted only behind a guard. Generated + code alone could not be published and tested, and `id_effect_axum` must not depend on + `id_effect_sql`, so it is a crate of its own. +- **Server-rendered UI adds no crate.** It is the `ui` feature of `id_effect_axum` (maud + components, htmx helpers, layouts, form helpers); no tier change. +- **`examples/reference/pipeline` is tier `example`** and `publish = false`: the 0.13 reference app + (a multi-tenant document-intake pipeline and its back office). It depends on `id_effect_kit` + only, like the Ledger and the Relay. `examples/recipes/` (the Leptos SSR recipe) is outside the + root workspace, like `labs/`, with its own CI job. +- They publish in dependency order (`scripts/validate-crate-packaging.sh --print-order`), all at + the last level after `id_effect_rpc` and before `id_effect_kit` and `id_effect_tool`: + `id_effect_i18n` after `id_effect` and `id_effect_axum`; `id_effect_ai` after + `id_effect_platform` and `id_effect_config`; `id_effect_admin` after `id_effect_sql`, + `id_effect_axum`, `id_effect_authz` and `id_effect_i18n`. +- Each of the three moves to the `platform` tier of ADR 0012, with no other change, when the + Pipeline depends on it (check P of `scripts/tier-conformance.sh`). +- `crates/id_effect_cli/templates/admin-stub/` is deleted: nothing generated, built or tested it, + and `id_effect_admin` replaces it. Its only references were scorecard evidence text, re-scored at + the 0.13 exit. +- crates.io (checked 2026-10-21, generic User-Agent): `id_effect_ai` is ours, last published at + 0.4.0 before it moved to labs, so its promoted release is its next one. `id_effect_admin` and + `id_effect_i18n` are free; like any never-published crate each needs its crates.io + trusted-publisher setup before `publish.yml` can release it (docs/RELEASING.md; owner step D17, + before the v0.13.0 tag). None has a 0.12.x baseline, so all three are excluded from the semver + job until their first 0.13 publish. No placeholder versions are published: each crate's first + release carries its API. + The requirements for each tier are in [`docs/QUALITY_BAR.md`](../QUALITY_BAR.md). `scripts/tier-conformance.sh` checks them. diff --git a/examples/reference/pipeline/Cargo.toml b/examples/reference/pipeline/Cargo.toml new file mode 100644 index 000000000..8bef75505 --- /dev/null +++ b/examples/reference/pipeline/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "pipeline" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false +description = "id_effect reference app: a multi-tenant document-intake pipeline and its back office, built through id_effect_kit" +readme = "README.md" + +[lints.rust] +unsafe_code = "forbid" + +[lib] +path = "src/lib.rs" + +[dependencies] +# Every id_effect battery comes through the kit, one feature each (like the Ledger and the Relay); +# r13-w6-pipeline and r13-w7-pipeline-backoffice add the features the app uses. +id_effect_kit = { path = "../../../crates/id_effect_kit", version = "0.12.0" } + +[[example]] +name = "010_stages" +path = "examples/010_stages.rs" + +[package.metadata.id_effect] +# ADR 0010 crate tier (Amendment 9) — checked by scripts/tier-conformance.sh. The Pipeline is the +# CI-tested consumer that moves id_effect_ai, id_effect_admin and id_effect_i18n to tier `platform` +# (check P) once it depends on them. +tier = "example" diff --git a/examples/reference/pipeline/README.md b/examples/reference/pipeline/README.md new file mode 100644 index 000000000..059baf634 --- /dev/null +++ b/examples/reference/pipeline/README.md @@ -0,0 +1,30 @@ +# The Pipeline reference app + +The 0.13 reference app (release spec 0.13.0, decision D1): a multi-tenant document-intake pipeline +and its back office, built only through `id_effect_kit`. It is tier `example` and `publish = false`. +The name is the app's; `id_effect_resilience::Pipeline` is an unrelated policy type. + +1. **Ingest** — documents arrive by RPC mutation or upload for a tenant; one job per stage. +2. **Extract** — an AI model turns text into a typed `Extraction` (structured output). +3. **Classify** — a bounded tool loop calls `lookup_source` and `list_labels` and attaches labels. +4. **Embed** — embeddings flag near-duplicates within the tenant. +5. **Review** — reviewers work in the generated admin and an htmx review queue, in English or German. +6. **Publish** — an `after_update` hook writes `DocumentPublished` to the outbox. +7. **Agents** — an MCP server exposes `search_documents` / `get_document` to outside agents. + +0.13 builds it in steps; this first step fixes the stage model. + +```rust +use pipeline::{Stage, stages}; + +let all = stages().block_on().into_result().unwrap(); +assert_eq!(all.len(), 6); +assert_eq!(Stage::Ingest.next(), Some(Stage::Extract)); +``` + +## Run + +```bash +cargo run -p pipeline --example 010_stages +cargo nextest run -p pipeline +``` diff --git a/examples/reference/pipeline/examples/010_stages.rs b/examples/reference/pipeline/examples/010_stages.rs new file mode 100644 index 000000000..70fb40be0 --- /dev/null +++ b/examples/reference/pipeline/examples/010_stages.rs @@ -0,0 +1,18 @@ +//! Print the Pipeline reference app's stages in order. +//! +//! ```bash +//! cargo run -p pipeline --example 010_stages +//! ``` + +use pipeline::stages; + +fn main() { + match stages().block_on().into_result() { + Ok(all) => { + for (i, stage) in all.iter().enumerate() { + println!("{}. {stage}", i + 1); + } + } + Err(cause) => eprintln!("stages failed: {cause:?}"), + } +} diff --git a/examples/reference/pipeline/moon.yml b/examples/reference/pipeline/moon.yml new file mode 100644 index 000000000..bcf6d34c1 --- /dev/null +++ b/examples/reference/pipeline/moon.yml @@ -0,0 +1,32 @@ +# https://moonrepo.dev/docs/config/project +# The Pipeline reference app (multi-tenant document intake and its back office). Its integration +# tests run in the `reference-pipeline` CI job (r13-w6-pipeline). +language: 'rust' +layer: 'application' +tasks: + check: + deps: ['effect:ci-format', '^:check'] + command: [cargo, check, -p, pipeline, --all-targets] + inputs: ['Cargo.toml', 'src/**/*.rs', 'tests/**/*.rs', 'examples/**/*.rs'] + options: {cache: true, outputStyle: buffer-only-failure} + clippy: + deps: ['effect:ci-format', '^:clippy', '~:check'] + command: [cargo, clippy, -p, pipeline, --all-targets, --, -D, warnings] + inputs: ['Cargo.toml', 'src/**/*.rs', 'tests/**/*.rs', 'examples/**/*.rs'] + options: {cache: true, outputStyle: buffer-only-failure} + lint: + description: 'Alias for ~:clippy (format-gated)' + deps: ['effect:ci-format', '~:clippy'] + command: 'bash' + args: ['-c', ':'] + options: {cache: true, outputStyle: buffer-only-failure} + test: + deps: ['effect:ci-format', '^:test', '~:clippy'] + command: [cargo, nextest, run, -p, pipeline] + inputs: ['Cargo.toml', 'src/**/*.rs', 'tests/**/*.rs', 'examples/**/*.rs'] + options: {cache: true, outputStyle: buffer-only-failure} + build: + deps: ['effect:ci-format', '^:build', '~:test'] + command: [cargo, build, -p, pipeline, --all-targets] + inputs: ['Cargo.toml', 'src/**/*.rs', 'tests/**/*.rs', 'examples/**/*.rs'] + options: {cache: true, outputStyle: buffer-only-failure} diff --git a/examples/reference/pipeline/src/lib.rs b/examples/reference/pipeline/src/lib.rs new file mode 100644 index 000000000..2a04d81a5 --- /dev/null +++ b/examples/reference/pipeline/src/lib.rs @@ -0,0 +1,112 @@ +//! The Pipeline reference app: a multi-tenant document-intake pipeline and its back office. +//! +//! Documents arrive for a tenant and move through [`Stage::ALL`] in order: ingest, AI extraction +//! into a typed record, classification by a bounded tool loop, embedding-based near-duplicate +//! detection, human review in the generated admin and an htmx review queue (English or German), +//! and publication through an outbox event. 0.13 builds the app in steps (release spec 0.13.0, +//! decision D1); this first step fixes the stage model. +//! +//! The name is the app's, not `id_effect_resilience::Pipeline` (a policy type): the docs always +//! say "the Pipeline reference app". +//! +//! ``` +//! use pipeline::{Stage, stages}; +//! +//! let all = stages().block_on().into_result().unwrap(); +//! assert_eq!(all.first(), Some(&Stage::Ingest)); +//! assert_eq!(Stage::Review.next(), Some(Stage::Publish)); +//! ``` + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +use id_effect_kit::id_effect::{Effect, succeed}; +use std::fmt; + +/// Compiles and runs the README snippet as a doctest. +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +struct ReadmeDoctests; + +/// One stage a document passes through, in order. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Stage { + /// A document arrives for a tenant (RPC mutation or upload); one job per stage follows. + Ingest, + /// An AI model turns the text into a typed extraction (structured output). + Extract, + /// A bounded tool loop attaches labels. + Classify, + /// Embeddings flag near-duplicates within the tenant. + Embed, + /// Reviewers accept or correct the document in the back office. + Review, + /// A lifecycle hook writes `DocumentPublished` to the outbox. + Publish, +} + +impl Stage { + /// Every stage, in pipeline order. + pub const ALL: [Self; 6] = [ + Self::Ingest, + Self::Extract, + Self::Classify, + Self::Embed, + Self::Review, + Self::Publish, + ]; + + /// The stage after this one, or `None` after [`Stage::Publish`]. + pub fn next(self) -> Option { + Self::ALL + .iter() + .position(|s| *s == self) + .and_then(|i| Self::ALL.get(i + 1).copied()) + } + + /// The stage's name (`ingest`, `extract`, …), used as its job queue name. + pub const fn as_str(self) -> &'static str { + match self { + Self::Ingest => "ingest", + Self::Extract => "extract", + Self::Classify => "classify", + Self::Embed => "embed", + Self::Review => "review", + Self::Publish => "publish", + } + } +} + +impl fmt::Display for Stage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// The pipeline's stages, as an effect (the app wires one job queue per stage from it). +pub fn stages() -> Effect> { + succeed(Stage::ALL.to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stages_chain_in_order_and_end_at_publish() { + let mut stage = Stage::Ingest; + let mut seen = vec![stage]; + while let Some(next) = stage.next() { + seen.push(next); + stage = next; + } + assert_eq!(seen, Stage::ALL); + let names: Vec = seen.iter().map(ToString::to_string).collect(); + assert_eq!( + names, + [ + "ingest", "extract", "classify", "embed", "review", "publish" + ] + ); + } +} diff --git a/examples/reference/pipeline/tests/stages.rs b/examples/reference/pipeline/tests/stages.rs new file mode 100644 index 000000000..f9d1903ba --- /dev/null +++ b/examples/reference/pipeline/tests/stages.rs @@ -0,0 +1,10 @@ +//! The stage model, run through the kit's effect runtime (r13-t0-skeletons). + +use pipeline::{Stage, stages}; + +#[test] +fn pipeline_stages_run_through_the_kit() { + let all = stages().block_on().into_result().unwrap(); + assert_eq!(all, Stage::ALL); + assert_eq!(Stage::Publish.next(), None); +} diff --git a/labs/Cargo.toml b/labs/Cargo.toml index cea2a8590..11ac93156 100644 --- a/labs/Cargo.toml +++ b/labs/Cargo.toml @@ -4,10 +4,11 @@ # cargo nextest run --manifest-path labs/Cargo.toml --workspace --all-features # id_effect_workflow left labs in 0.11: it is the published crates/id_effect_workflow. # id_effect_rpc left labs in 0.12 (ADR 0010 Amendment 8): it is crates/id_effect_rpc. +# id_effect_ai left labs in 0.13 (ADR 0010 Amendment 9): it is crates/id_effect_ai. [workspace] resolver = "2" -exclude = ["id_effect_lint"] # nightly-only Dylint crate; builds standalone -members = ["id_effect_ai", "id_effect_parse"] +exclude = ["id_effect_lint"] # nightly-only Dylint crate; builds standalone +members = ["id_effect_parse"] # Keep in lockstep with the root [workspace.package]. [workspace.package] diff --git a/scripts/validate-crate-packaging.sh b/scripts/validate-crate-packaging.sh index a75848407..bc2c4bf38 100755 --- a/scripts/validate-crate-packaging.sh +++ b/scripts/validate-crate-packaging.sh @@ -72,6 +72,14 @@ declare -A LEVEL=( # (mesh transport) and id_effect_events (subscriptions), and its guards on id_effect_authz; the # kit re-exports it, so it publishes last before id_effect_kit and id_effect_tool. [id_effect_rpc]=5 + # 0.13 (ADR 0010 Amendment 9), all at the last level and before id_effect_kit and + # id_effect_tool. i18n follows id_effect and id_effect_axum (its `axum` feature); ai follows + # id_effect_platform and id_effect_config (HTTP client, `Secret` keys) and stays in + # PENDING_PROMOTION until r13-e9-1-promote; admin follows id_effect_sql, id_effect_axum, + # id_effect_authz and id_effect_i18n (repository, `ui` views, guards, labels). + [id_effect_i18n]=5 + [id_effect_ai]=5 + [id_effect_admin]=5 ) # Publish order (levels ascending; within a level, dependencies first). @@ -85,13 +93,17 @@ declare -a ORDER=( id_effect_authz id_effect_auth id_effect_redis id_effect_test id_effect_jobs id_effect_mail id_effect_console id_effect_workflow id_effect_rpc + id_effect_i18n id_effect_ai id_effect_admin id_effect_kit id_effect_tool ) # Crates in ORDER that still await promotion (ADR 0010 rule 2): either still under labs/, or # already moved into crates/ but `publish = false` until the promoting leaf. Remove the entry in # that leaf (id_effect_rpc: moved in r12-t0-skeletons, publishable since r12-e12-1-promote). -declare -A PENDING_PROMOTION=() +declare -A PENDING_PROMOTION=( + # Moved out of labs/ by r13-t0-skeletons; r13-e9-1-promote makes it publishable. + [id_effect_ai]=r13-e9-1-promote +) if [[ "${1:-}" == "--print-order" ]]; then printf '%s\n' "${ORDER[@]}" From 9cc00721b73c4bfeb776eb54c6f326a7d7c7cb97 Mon Sep 17 00:00:00 2001 From: Tom Wieland Date: Wed, 21 Oct 2026 05:23:54 +0200 Subject: [PATCH 08/87] chore(maestro): record follow-ups F64, F65 from #113 for 0.13 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Wieland --- .maestro/missions/release-0-12.execution.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.maestro/missions/release-0-12.execution.md b/.maestro/missions/release-0-12.execution.md index 6116a8181..54bf76f4a 100644 --- a/.maestro/missions/release-0-12.execution.md +++ b/.maestro/missions/release-0-12.execution.md @@ -182,4 +182,6 @@ webtransport (wave 5). | F61 | kpis | security run builds the workspace `--all-features` (30+ min loaded); `/tmp` too small for `DX_KPIS_DIR` — use `.tmp/`; stale-artifact `QuicLimits::with_datagrams` not found in an old target dir → `cargo clean -p id_effect_platform` | note | | F62 | CI (PR #112) | first `http3-conformance` run: h3spec v0.1.14 77 examples, 19 failures. **Ours (server/h3.rs adapter):** connection-specific headers not rejected as malformed (connection, keep-alive, proxy-connection, te, transfer-encoding, upgrade — HTTP/3 4.2), duplicated / misplaced pseudo-headers (4.1.1, 4.1.3), frame longer than SETTINGS_MAX_FIELD_SECTION_SIZE buffered (7.1). **h3 0.0.8:** QPACK encoder/decoder stream errors (QPACK 4.1.3, 4.2, 4.4.3), SETTINGS mid-parameter / repeated id (7.1, 7.2.4.1), CANCEL_PUSH on a request stream (7.2.5), client push stream (6.2.2). **quinn/rustls:** missing_extension alert without quic_transport_parameters (TLS 8.2). Job is not in `ci-pass` (F26). Low risk today (the server terminates h3; those headers never frame bodies) | 0.13 leaf: adapter validation + upstream h3 issues/PRs; add the job to ci-pass when green | | F63 | CI (PR #112) | flaky: `id_effect_sql::conformance mysql::dropped_streams_release_their_connections` failed once in Container integration (2627692d: `SELECT 1 AS n: sql client not connected` after 88 s, i.e. pool acquire timed out); passed on aa84c378 and #111 with no sql source change between 0.11 and 0.12. Make it deterministic (wait for the pool's idle count instead of a fixed timeout; check the MySQL container's max_connections) | 0.13 | +| F64 | main CI (#113) | Windows: the first sysinfo sample blocks the calling thread for ~350 ms (CI: 370.9 ms stable, 343.8 ms beta) in `ComputeFabric::tick` / `SysinfoTelemetry::snapshot`. #113 relaxed the two non-blocking tests to a 1 s bound on Windows only, keeping 50 ms elsewhere (`crates/id_effect/src/compute/{fabric,telemetry}.rs`). Real fix: take the first sample off the calling thread (spawn it, return a neutral snapshot until it lands), then restore the 50 ms bound everywhere. | 0.13 | +| F65 | main CI (#113) | MySQL conformance `dropped_streams_release_their_connections`: in CI a dropped MySQL stream returns its connection to the pool only after the server sends the rest of the result. The final `SELECT 1` timed out after ~80 s (Coverage, Container integration) and still after the 120 s retry (beta / ubuntu: 142 s), against ~6 s locally. #113 cut the streams to 10,000 rows and kept a bounded retry on NotConnected (`crates/id_effect_sql/tests/conformance/mod.rs`). Investigate why the drain is 20x slower in CI: the runner's MySQL container, sqlx-mysql's drain-on-return, or our stream driver holding the connection until the producer task ends. Consider closing the connection (or KILL QUERY) instead of draining when a large result is dropped. | 0.13 | | F21 | subscriptions | no cursor = replay from `starting_after` (default whole log), not "from now" — `EventLog` has no head read; document, or add a head read in id_effect_events | 0.13 / document in ch33-08 | From 26959abcde5504dc7a3da082c90c122a3017e932 Mon Sep 17 00:00:00 2001 From: Tom Wieland Date: Wed, 21 Oct 2026 07:03:58 +0200 Subject: [PATCH 09/87] feat(tool): rpc check --allow-retired Service:N (r13-f-rpc-retire) id-effect rpc check gains --allow-retired Service:N (repeatable, 0.12 follow-up F46): the removal of that service version passes and is printed as retired; every other breaking change still exits 1, and naming a version that was not removed exits 2 (typo guard). Library: rpc::RetiredVersion, rpc::check::retire. Book ch33-12, CHANGELOG, MIGRATION-0.13 (CheckArgs gains allow_retired). Signed-off-by: Tom Wieland Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + .../book/src/part6/ch33-12-contract-check.md | 7 + crates/id_effect_tool/src/rpc/check.rs | 110 ++++++++++- crates/id_effect_tool/src/rpc/mod.rs | 63 ++++++- crates/id_effect_tool/tests/rpc_check.rs | 177 ++++++++++++++++++ docs/MIGRATION-0.13.md | 17 ++ 6 files changed, 371 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af30b9515..04e360786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ replacing the `Effect::from_ctx(|ctx| async move { ctx.bind(e).await })` workaround. A ready effect is returned as is; a program gets one `Provide` frame over the shared empty environment. Book: "Env-free effects under any environment" in part 2, chapter 4.3. +- `id-effect rpc check --allow-retired Service:N` (r13-f-rpc-retire, 0.12 follow-up F46), + repeatable: the removal of that service version passes and is printed as `retired`; every other + breaking change still exits 1, and naming a version that was not removed exits 2 (typo guard). + Library: `id_effect_tool::rpc::RetiredVersion` and `rpc::check::retire`; `CheckArgs` gains + `allow_retired` ([MIGRATION-0.13.md](docs/MIGRATION-0.13.md)). Book: chapter 33.12. - Crate layout for 0.13 (r13-t0-skeletons, ADR 0010 Amendment 9): `id_effect_ai` moves from `labs/` to `crates/id_effect_ai` with its history and joins the root workspace (still `publish = false` until r13-e9-1-promote); new tier-`integration` crates `id_effect_i18n` diff --git a/crates/id_effect/book/src/part6/ch33-12-contract-check.md b/crates/id_effect/book/src/part6/ch33-12-contract-check.md index ae0da2996..1e280bf89 100644 --- a/crates/id_effect/book/src/part6/ch33-12-contract-check.md +++ b/crates/id_effect/book/src/part6/ch33-12-contract-check.md @@ -64,6 +64,13 @@ compatible Greeter v2 history: procedure added Dropping v1 in the same release fails with `breaking Greeter v1: version no longer served`. +When the time comes to retire v1 on purpose, say so on the command line: `--allow-retired +Greeter:1` (repeatable, one `Service:N` per retired version) lets exactly that removal pass and +prints it as `retired Greeter v1: version no longer served (--allow-retired)`. It excuses +nothing else: any other breaking change still exits `1`, and naming a version that was not +removed (a typo, a version still served, one that never existed) is a usage error that exits `2`, +so the flag can never quietly widen the check. + ## Type names `$defs` keys are Rust `type_name` paths (`app::store::Todo`), which change whenever a module diff --git a/crates/id_effect_tool/src/rpc/check.rs b/crates/id_effect_tool/src/rpc/check.rs index f346ee9ef..c3f388ed6 100644 --- a/crates/id_effect_tool/src/rpc/check.rs +++ b/crates/id_effect_tool/src/rpc/check.rs @@ -35,6 +35,11 @@ //! //! **Caps.** An absent `max_items` / `max_bytes` is the server default (10,000 items, 4 MiB). //! +//! **Retiring a version.** `--allow-retired Service:N` (repeatable) declares the removal of that +//! service version intended: its `version no longer served` change is reported as `retired` and +//! passes ([`retire`]). It excuses nothing else, and naming a version that was not removed is a +//! usage error, so a typo cannot widen the check. +//! //! `--generated ` regenerates the TypeScript client in memory ([`gen_ts::generate`]) and //! fails when a file in `dir` is missing or differs ([`stale_files`]). //! @@ -71,8 +76,13 @@ use id_effect_rpc::contract::{ }; use serde_json::Value; +use super::RetiredVersion; use super::gen_ts::{self, GenTsError}; +/// The reason of the [`Class::Breaking`] change for a service version the current contract no +/// longer serves (the change `--allow-retired` excuses). +const VERSION_REMOVED: &str = "version no longer served"; + /// The server's client-stream item cap when the contract names none. pub const DEFAULT_MAX_ITEMS: u64 = 10_000; @@ -97,6 +107,11 @@ pub struct CheckArgs { /// writes for `--contract`. #[arg(long, value_name = "DIR")] pub generated: Option, + /// A service version whose removal is intended (`Service:N`, repeatable): its `version no + /// longer served` change passes as `retired`. Every other breaking change still fails; naming a + /// version that was not removed is a usage error. + #[arg(long, value_name = "SERVICE:N", requires = "against")] + pub allow_retired: Vec, } /// How a change affects clients and servers of a published version. @@ -167,6 +182,58 @@ pub fn passes(changes: &[Change]) -> bool { changes.iter().all(|c| c.class != Class::Breaking) } +/// Take the removals of the service versions in `allowed` (`--allow-retired`) out of `changes` +/// and return them: each is the [`Class::Breaking`] `version no longer served` change of that +/// service version. Nothing else is excused, so [`passes`] still fails on any other breaking +/// change. +/// +/// Fails, leaving `changes` untouched, with the first entry of `allowed` that names no removed +/// version (a typo, a version still served, or one that never existed). +/// +/// ```rust +/// use id_effect_rpc::contract::{Contract, ServiceContract}; +/// use id_effect_tool::rpc::RetiredVersion; +/// use id_effect_tool::rpc::check::{compare, passes, retire}; +/// +/// let old = Contract::new() +/// .with_service(ServiceContract::new("Greeter", 1)) +/// .with_service(ServiceContract::new("Greeter", 2)); +/// let new = Contract::new().with_service(ServiceContract::new("Greeter", 2)); +/// let mut changes = compare(&old, &new); +/// assert!(!passes(&changes)); +/// +/// let retired = retire(&mut changes, &[RetiredVersion::new("Greeter", 1)]).expect("v1 was removed"); +/// assert_eq!(retired.len(), 1); +/// assert!(passes(&changes)); +/// assert_eq!( +/// retire(&mut changes, &[RetiredVersion::new("Greeter", 2)]), +/// Err(RetiredVersion::new("Greeter", 2)) +/// ); +/// ``` +pub fn retire( + changes: &mut Vec, + allowed: &[RetiredVersion], +) -> Result, RetiredVersion> { + let removes = |c: &Change, r: &RetiredVersion| { + c.class == Class::Breaking + && c.procedure.is_none() + && c.reason == VERSION_REMOVED + && c.service == r.service + && c.version == r.version + }; + if let Some(unknown) = allowed + .iter() + .find(|r| !changes.iter().any(|c| removes(c, r))) + { + return Err(unknown.clone()); + } + let (retired, kept) = std::mem::take(changes) + .into_iter() + .partition(|c| allowed.iter().any(|r| removes(c, r))); + *changes = kept; + Ok(retired) +} + /// Why a contract could not be read. #[derive(Debug)] #[non_exhaustive] @@ -296,17 +363,48 @@ pub fn run(args: &CheckArgs, out: &mut dyn Write, err: &mut dyn Write) -> ExitCo Ok(c) => c, Err(e) => return unreadable(err, e), }; - let changes = compare(&previous, ¤t); + let mut changes = compare(&previous, ¤t); + let retired = match retire(&mut changes, &args.allow_retired) { + Ok(retired) => retired, + Err(r) => { + let _ = writeln!( + err, + "error: --allow-retired {r}: {} v{} was not removed (it is still served, or {spec} \ + never served it)", + r.service, r.version + ); + return ExitCode::from(2); + } + }; let _ = writeln!(out, "rpc check: {} against {spec}", args.contract.display()); - for change in &changes { - let _ = writeln!(out, "{change}"); + let mut lines: Vec<_> = changes + .iter() + .map(|c| (c, c.to_string())) + .chain(retired.iter().map(|c| { + let line = format!( + "{:<10} {} v{}: {} (--allow-retired)", + "retired", c.service, c.version, c.reason + ); + (c, line) + })) + .collect(); + lines.sort_by(|(a, _), (b, _)| { + (&a.service, a.version, &a.procedure).cmp(&(&b.service, b.version, &b.procedure)) + }); + for (_, line) in &lines { + let _ = writeln!(out, "{line}"); } let count = |class| changes.iter().filter(|c| c.class == class).count(); let breaking = count(Class::Breaking); + let retired_count = if retired.is_empty() { + String::new() + } else { + format!(" {} retired,", retired.len()) + }; let _ = writeln!( out, - "{} changes: {breaking} breaking, {} versioned, {} compatible", - changes.len(), + "{} changes: {breaking} breaking,{retired_count} {} versioned, {} compatible", + lines.len(), count(Class::Versioned), count(Class::Compatible) ); @@ -365,7 +463,7 @@ pub fn compare(previous: &Contract, current: &Contract) -> Vec { version: old.version, procedure: None, path: String::new(), - reason: "version no longer served".to_owned(), + reason: VERSION_REMOVED.to_owned(), }), } } diff --git a/crates/id_effect_tool/src/rpc/mod.rs b/crates/id_effect_tool/src/rpc/mod.rs index 78df78ab4..a8819d0a9 100644 --- a/crates/id_effect_tool/src/rpc/mod.rs +++ b/crates/id_effect_tool/src/rpc/mod.rs @@ -14,15 +14,19 @@ //! //! `check` classifies every change against the previous contract and fails on a breaking change //! within a published version ([`check`]); `--generated ` also fails when the checked-in -//! TypeScript client is stale. Exit codes: `0` passes, `1` a breaking change or a stale client, -//! `2` a usage error or an unreadable contract. +//! TypeScript client is stale. `--allow-retired Service:N` (repeatable, [`RetiredVersion`]) lets +//! the removal of that service version pass, reported as `retired`; naming a version that was not +//! removed is a usage error. Exit codes: `0` passes, `1` a breaking change or a stale client, `2` a +//! usage error or an unreadable contract. pub mod check; pub mod gen_ts; +use std::fmt; use std::io::Write; use std::path::PathBuf; use std::process::ExitCode; +use std::str::FromStr; use clap::{Args, Subcommand}; @@ -74,6 +78,61 @@ pub struct GenTsArgs { pub out: PathBuf, } +/// A service version whose removal is intended: a value of `id-effect rpc check +/// --allow-retired Service:N`. +/// +/// Parsed from `Service:N` (the last `:` separates the version, a decimal `u32`). A retired version +/// only excuses its own `version no longer served` change ([`check::retire`]); every other breaking +/// change still fails the check. +/// +/// ```rust +/// use id_effect_tool::rpc::RetiredVersion; +/// +/// let retired: RetiredVersion = "Greeter:1".parse().expect("Service:N"); +/// assert_eq!(retired, RetiredVersion::new("Greeter", 1)); +/// assert_eq!(retired.to_string(), "Greeter:1"); +/// assert!("Greeter".parse::().is_err()); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RetiredVersion { + /// The service name, as in the contract. + pub service: String, + /// The removed version. + pub version: u32, +} + +impl RetiredVersion { + /// The retired `version` of `service`. + pub fn new(service: impl Into, version: u32) -> Self { + Self { + service: service.into(), + version, + } + } +} + +impl FromStr for RetiredVersion { + type Err = String; + + fn from_str(s: &str) -> Result { + let expected = || format!("`{s}`: expected `Service:N`, e.g. `Greeter:1`"); + let (service, version) = s.rsplit_once(':').ok_or_else(expected)?; + if service.is_empty() { + return Err(expected()); + } + let version = version + .parse::() + .map_err(|e| format!("`{s}`: version `{version}`: {e}"))?; + Ok(Self::new(service, version)) + } +} + +impl fmt::Display for RetiredVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.service, self.version) + } +} + /// Runs `id-effect rpc`, writing to `out` and `err`. pub fn run(args: &RpcArgs, out: &mut dyn Write, err: &mut dyn Write) -> ExitCode { match &args.command { diff --git a/crates/id_effect_tool/tests/rpc_check.rs b/crates/id_effect_tool/tests/rpc_check.rs index 2c6209042..7784aaa0e 100644 --- a/crates/id_effect_tool/tests/rpc_check.rs +++ b/crates/id_effect_tool/tests/rpc_check.rs @@ -382,6 +382,183 @@ fn unreadable_input_exits_2() { assert!(run.err.contains("--against"), "{}", run.err); } +/// `rpc check --contract --against ` in a fresh directory. +fn check_with(old: &Value, new: &Value, extra: &[&str]) -> Run { + let dir = tempfile::tempdir().expect("tempdir"); + let old = write(dir.path(), "old.json", old); + let new = write(dir.path(), "rpc.contract.json", new); + let mut args = vec![ + "check", + "--contract", + new.to_str().expect("utf-8"), + "--against", + old.to_str().expect("utf-8"), + ]; + args.extend_from_slice(extra); + tool(&args) +} + +/// The fixture without the services `drop` names (`(name, version)`). +fn without(drop: &[(&str, u64)]) -> Value { + let mut c = fixture(); + c["services"].as_array_mut().expect("services").retain(|s| { + !drop + .iter() + .any(|(n, v)| s["name"] == *n && s["version"] == *v) + }); + c +} + +#[test] +fn retired_version_allowed_with_flag() { + let old = fixture(); + let new = without(&[("Greeter", 1)]); + + // Without the flag the removal is breaking. + let run = check_with(&old, &new, &[]); + assert_eq!(run.code, ExitCode::FAILURE, "{}", run.out); + assert!( + run + .out + .contains("breaking Greeter v1: version no longer served") + ); + + let run = check_with(&old, &new, &["--allow-retired", "Greeter:1"]); + assert_eq!(run.code, ExitCode::SUCCESS, "{}{}", run.out, run.err); + assert!( + run + .out + .contains("retired Greeter v1: version no longer served (--allow-retired)"), + "{}", + run.out + ); + assert!(!run.out.contains("breaking "), "{}", run.out); + assert!( + run + .out + .contains("1 changes: 0 breaking, 1 retired, 0 versioned, 0 compatible"), + "{}", + run.out + ); + + // Repeatable: two services retired at once, printed in contract order. + let new = without(&[("Greeter", 1), ("Todos", 1)]); + let run = check_with( + &old, + &new, + &["--allow-retired", "Todos:1", "--allow-retired", "Greeter:1"], + ); + assert_eq!(run.code, ExitCode::SUCCESS, "{}{}", run.out, run.err); + let greeter = run.out.find("retired Greeter v1").expect("Greeter v1"); + let todos = run.out.find("retired Todos v1").expect("Todos v1"); + assert!(greeter < todos, "{}", run.out); + assert!(run.out.contains("2 changes: 0 breaking, 2 retired,")); +} + +#[test] +fn retired_flag_does_not_hide_other_breaks() { + let old = fixture(); + // Greeter v1 retired, and a procedure removed from Todos v1 in place. + let mut new = without(&[("Greeter", 1)]); + service(&mut new, "Todos", 1)["procedures"] + .as_array_mut() + .expect("procedures") + .retain(|p| p["name"] != "count"); + let run = check_with(&old, &new, &["--allow-retired", "Greeter:1"]); + assert_eq!(run.code, ExitCode::FAILURE, "{}", run.out); + assert!( + run + .out + .contains("retired Greeter v1: version no longer served (--allow-retired)") + ); + assert!( + run + .out + .contains("breaking Todos v1 count: procedure removed") + ); + assert!( + run + .out + .contains("2 changes: 1 breaking, 1 retired, 0 versioned, 0 compatible"), + "{}", + run.out + ); + assert!( + run + .err + .contains("1 breaking change(s) within a published version") + ); + + // Retiring a whole service version does not excuse a break in another version of it. + let mut new = without(&[("Greeter", 1)]); + service(&mut new, "Greeter", 2)["procedures"] + .as_array_mut() + .expect("procedures") + .retain(|p| p["name"] != "history"); + let run = check_with(&old, &new, &["--allow-retired", "Greeter:1"]); + assert_eq!(run.code, ExitCode::FAILURE, "{}", run.out); + assert!( + run + .out + .contains("breaking Greeter v2 history: procedure removed") + ); +} + +#[test] +fn retired_flag_for_unknown_version_is_error() { + let old = fixture(); + let new = without(&[("Greeter", 1)]); + // A version that was never served, one that is still served, and a misspelt service. + for (flag, shown) in [ + ("Greeter:3", "Greeter v3"), + ("Greeter:2", "Greeter v2"), + ("Greter:1", "Greter v1"), + ] { + let run = check_with( + &old, + &new, + &["--allow-retired", "Greeter:1", "--allow-retired", flag], + ); + assert_eq!( + run.code, + ExitCode::from(2), + "{flag}: {}{}", + run.out, + run.err + ); + assert!( + run.err.contains(&format!( + "error: --allow-retired {flag}: {shown} was not removed" + )), + "{}", + run.err + ); + assert!(run.out.is_empty(), "{}", run.out); + } + + // Not `Service:N`: a usage error from argument parsing. + for bad in ["Greeter", ":1", "Greeter:", "Greeter:v1", "Greeter:-1"] { + let run = check_with(&old, &new, &["--allow-retired", bad]); + assert_eq!(run.code, ExitCode::from(2), "{bad}: {}", run.out); + assert!(run.err.contains("--allow-retired"), "{bad}: {}", run.err); + } + + // It only means something next to --against. + let dir = tempfile::tempdir().expect("tempdir"); + let contract = write(dir.path(), "rpc.contract.json", &new); + let run = tool(&[ + "check", + "--contract", + contract.to_str().expect("utf-8"), + "--generated", + dir.path().to_str().expect("utf-8"), + "--allow-retired", + "Greeter:1", + ]); + assert_eq!(run.code, ExitCode::from(2), "{}", run.out); + assert!(run.err.contains("--against"), "{}", run.err); +} + #[test] fn generated_client_must_be_current() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/docs/MIGRATION-0.13.md b/docs/MIGRATION-0.13.md index 1fb88a4b8..3d4bbf469 100644 --- a/docs/MIGRATION-0.13.md +++ b/docs/MIGRATION-0.13.md @@ -41,6 +41,23 @@ Owner: r13-f-h3-settings. Owner: r13-f-client-batching. +## Tool: `rpc::check::CheckArgs::allow_retired` + +Owner: r13-f-rpc-retire. `id_effect_tool::rpc::check::CheckArgs` (the clap arguments of +`id-effect rpc check`) gains the public field `allow_retired: Vec` for the new +`--allow-retired Service:N` flag. The command line is unchanged for existing invocations. Code +that builds `CheckArgs` with a struct literal adds the field: + +```rust,ignore +// before (0.12) +let args = CheckArgs { contract, against: Some(spec), generated: None }; +// after (0.13) +let args = CheckArgs { contract, against: Some(spec), generated: None, allow_retired: Vec::new() }; +``` + +No shim: a struct literal cannot default a field. Parsing arguments with clap (`CheckArgs` derives +`Args`) needs no change. + ## TLS: rcgen 0.14 Owner: r13-f-rcgen. Only a break if an rcgen type appears in a public signature. From 4f3abaf0cb10733b97f49fe7942c849be5d0155f Mon Sep 17 00:00:00 2001 From: Tom Wieland Date: Wed, 21 Oct 2026 07:22:52 +0200 Subject: [PATCH 10/87] feat(i18n): Fluent catalogs, CLDR plurals and the CurrentLocale capability (r13-e9-4-i18n-core) id_effect_i18n core over Project Fluent (D5; eco.i18n #1 #2 #7): Catalog of Fluent resources per locale, embedded or loaded once from allowlisted directories (parse errors fail startup), fallback chain requested -> language -> default, Catalog::check for missing keys, CLDR plural categories through Fluent selectors, and the fiber-local CurrentLocale capability with with_locale / current_locale / translate. Declares the axum, maud and schema features for r13-e9-4-i18n-web. Book chapter 30.9. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Wieland --- CHANGELOG.md | 10 + Cargo.lock | 126 +++- crates/id_effect/book/src/SUMMARY.md | 1 + .../id_effect/book/src/part6/ch30-09-i18n.md | 218 ++++++ crates/id_effect_i18n/Cargo.toml | 39 +- crates/id_effect_i18n/README.md | 76 ++- crates/id_effect_i18n/examples/020_catalog.rs | 50 ++ crates/id_effect_i18n/src/args.rs | 180 +++++ crates/id_effect_i18n/src/catalog.rs | 646 ++++++++++++++++++ crates/id_effect_i18n/src/current.rs | 121 ++++ crates/id_effect_i18n/src/lib.rs | 257 ++----- crates/id_effect_i18n/src/locale.rs | 243 +++++++ crates/id_effect_i18n/tests/catalog.rs | 519 ++++++++++++++ crates/id_effect_i18n/tests/current.rs | 118 ++++ .../tests/fixtures/locales/README.md | 4 + .../tests/fixtures/locales/ar/plurals.ftl | 8 + .../tests/fixtures/locales/de-AT/app.ftl | 2 + .../tests/fixtures/locales/de/app.ftl | 2 + .../tests/fixtures/locales/de/plurals.ftl | 8 + .../tests/fixtures/locales/en/app.ftl | 6 + .../tests/fixtures/locales/en/notes.txt | 1 + .../tests/fixtures/locales/en/plurals.ftl | 9 + .../tests/fixtures/locales/ja/plurals.ftl | 4 + .../tests/fixtures/locales/pl/plurals.ftl | 11 + examples/book-snippets/Cargo.toml | 1 + examples/book-snippets/src/part6.rs | 4 + 26 files changed, 2450 insertions(+), 214 deletions(-) create mode 100644 crates/id_effect/book/src/part6/ch30-09-i18n.md create mode 100644 crates/id_effect_i18n/examples/020_catalog.rs create mode 100644 crates/id_effect_i18n/src/args.rs create mode 100644 crates/id_effect_i18n/src/catalog.rs create mode 100644 crates/id_effect_i18n/src/current.rs create mode 100644 crates/id_effect_i18n/src/locale.rs create mode 100644 crates/id_effect_i18n/tests/catalog.rs create mode 100644 crates/id_effect_i18n/tests/current.rs create mode 100644 crates/id_effect_i18n/tests/fixtures/locales/README.md create mode 100644 crates/id_effect_i18n/tests/fixtures/locales/ar/plurals.ftl create mode 100644 crates/id_effect_i18n/tests/fixtures/locales/de-AT/app.ftl create mode 100644 crates/id_effect_i18n/tests/fixtures/locales/de/app.ftl create mode 100644 crates/id_effect_i18n/tests/fixtures/locales/de/plurals.ftl create mode 100644 crates/id_effect_i18n/tests/fixtures/locales/en/app.ftl create mode 100644 crates/id_effect_i18n/tests/fixtures/locales/en/notes.txt create mode 100644 crates/id_effect_i18n/tests/fixtures/locales/en/plurals.ftl create mode 100644 crates/id_effect_i18n/tests/fixtures/locales/ja/plurals.ftl create mode 100644 crates/id_effect_i18n/tests/fixtures/locales/pl/plurals.ftl diff --git a/CHANGELOG.md b/CHANGELOG.md index 04e360786..1f1d5ca2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ replacing the `Effect::from_ctx(|ctx| async move { ctx.bind(e).await })` workaround. A ready effect is returned as is; a program gets one `Provide` frame over the shared empty environment. Book: "Env-free effects under any environment" in part 2, chapter 4.3. +- `id_effect_i18n` core (r13-e9-4-i18n-core, D5; eco.i18n #1 #2 #7) over Project Fluent + (`fluent-bundle` =0.16.0, `fluent-syntax` =0.12.0, `fluent-langneg` =0.13.1, `unic-langid` + =0.9.6): `Catalog` of Fluent resources per locale, embedded (`include_str!`) or loaded from + allowlisted directories once at startup (paths resolved, `.ftl` only, ≤ 1 MiB; parse errors, + duplicate messages and a missing default locale fail `build`); fallback chain requested → + language → default; `Catalog::check` reports every key a locale lacks (`require_complete` makes + it fatal); CLDR plural categories through Fluent selectors; bidi isolation of arguments on by + default; `CurrentLocale` capability (fiber-local) with `with_locale`, `current_locale` and + `translate`. Features `axum`, `maud` and `schema` are declared (off) for the web step. Book: + chapter 30.9 "Internationalization". - `id-effect rpc check --allow-retired Service:N` (r13-f-rpc-retire, 0.12 follow-up F46), repeatable: the removal of that service version passes and is printed as `retired`; every other breaking change still exits 1, and naming a version that was not removed exits 2 (typo guard). diff --git a/Cargo.lock b/Cargo.lock index 6a1d507d4..7f7651069 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -682,6 +682,7 @@ dependencies = [ "id_effect_events", "id_effect_fp", "id_effect_fsm", + "id_effect_i18n", "id_effect_jobs", "id_effect_kit", "id_effect_logger", @@ -1806,6 +1807,41 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.21", +] + [[package]] name = "fluent-uri" version = "0.4.1" @@ -2837,6 +2873,18 @@ dependencies = [ [[package]] name = "id_effect_i18n" version = "0.12.0" +dependencies = [ + "axum", + "fluent-bundle", + "fluent-langneg", + "fluent-syntax", + "id_effect", + "id_effect_axum", + "maud", + "tower-layer", + "tower-service", + "unic-langid", +] [[package]] name = "id_effect_jobs" @@ -3369,6 +3417,25 @@ dependencies = [ "tokio", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -3754,6 +3821,28 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maud" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e" +dependencies = [ + "itoa", + "maud_macros", +] + +[[package]] +name = "maud_macros" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + [[package]] name = "md-5" version = "0.10.6" @@ -5456,6 +5545,12 @@ dependencies = [ "trybuild", ] +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -6183,7 +6278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -6315,6 +6410,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -6743,6 +6839,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash", +] + [[package]] name = "typenum" version = "1.20.1" @@ -6764,6 +6869,24 @@ dependencies = [ "version_check", ] +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + [[package]] name = "unicase" version = "2.10.0" @@ -7632,6 +7755,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/crates/id_effect/book/src/SUMMARY.md b/crates/id_effect/book/src/SUMMARY.md index 16f8d9a39..bbd46b1a2 100644 --- a/crates/id_effect/book/src/SUMMARY.md +++ b/crates/id_effect/book/src/SUMMARY.md @@ -182,6 +182,7 @@ - [Single sign-on with OpenID Connect (`id_effect_auth`)](./part6/ch30-05-oidc.md) - [Two-factor authentication with TOTP (`id_effect_auth`)](./part6/ch30-06-totp.md) - [Tenancy and the audit log](./part6/ch30-07-audit-tenancy.md) + - [Internationalization (`id_effect_i18n`)](./part6/ch30-09-i18n.md) - [Async messaging and jobs](./part6/ch31-00-async-messaging.md) - [Background jobs (`id_effect_jobs`)](./part6/ch31-01-jobs.md) - [Messaging (`id_effect_messaging`)](./part6/ch31-02-messaging.md) diff --git a/crates/id_effect/book/src/part6/ch30-09-i18n.md b/crates/id_effect/book/src/part6/ch30-09-i18n.md new file mode 100644 index 000000000..cf87e435a --- /dev/null +++ b/crates/id_effect/book/src/part6/ch30-09-i18n.md @@ -0,0 +1,218 @@ +# Internationalization (`id_effect_i18n`) + +A service that answers people in their own language needs three things: translated messages with +the grammar of each language (plurals above all), a rule for what happens when a translation is +missing, and a way to know, deep inside a handler, which language this request wants. +`id_effect_i18n` provides them over [Project Fluent](https://projectfluent.org): + +1. **`Catalog`**: Fluent resources per locale, parsed once at startup, with a fallback chain and a + completeness check. +2. **CLDR plurals** through Fluent selectors, with the rules of the locale a message comes from. +3. **`CurrentLocale`**: a capability in the environment whose value is per fiber, switched for one + request with `with_locale`; `translate` formats a message in it. + +```toml +[dependencies] +id_effect_i18n = "0.13" +``` + +## Messages and plurals + +A Fluent resource is a list of messages. Placeables (`{ $name }`) insert arguments; a selector +(`{ $count -> … }`) picks a variant, and for a number the variant keys are the +[CLDR plural categories](https://cldr.unicode.org/index/cldr-spec/plural-rules) of the language: +English has `one` and `other`, Polish `one`, `few`, `many` and `other`, Arabic all six, Japanese +only `other`. The translator writes the variants the language needs; the code passes a number. + +```rust +use id_effect_i18n::{Args, Catalog, Locale}; + +let en = Locale::parse("en").unwrap(); +let pl = Locale::parse("pl").unwrap(); +let ar = Locale::parse("ar").unwrap(); +let catalog = Catalog::builder(en.clone()) + .embedded(en.clone(), "en/app.ftl", " +files = { $count -> + [one] { $count } file + *[other] { $count } files +} +") + .embedded(pl.clone(), "pl/app.ftl", " +files = { $count -> + [one] { $count } plik + [few] { $count } pliki + *[many] { $count } plików +} +") + .embedded(ar.clone(), "ar/app.ftl", " +files = { $count -> + [zero] لا ملفات + [one] ملف واحد + [two] ملفان + [few] { $count } ملفات + *[many] { $count } ملفًا + [other] { $count } ملف +} +") + .use_isolating(false) // plain strings for the asserts below; keep it on for UI text + .build() + .expect("every resource parses"); + +let files = |locale: &Locale, n: u32| catalog.format(locale, "files", &Args::new().with("count", n)); +assert_eq!(files(&en, 1), "1 file"); +assert_eq!(files(&en, 5), "5 files"); +assert_eq!(files(&pl, 3), "3 pliki"); +assert_eq!(files(&pl, 5), "5 plików"); +assert_eq!(files(&pl, 22), "22 pliki"); +assert_eq!(files(&ar, 2), "ملفان"); +assert_eq!(files(&ar, 11), "11 ملفًا"); +assert_eq!(files(&ar, 100), "100 ملف"); +``` + +`Args` takes text and numbers (`with("count", n)` for any integer or float). Attributes are keys +too: `catalog.format(&locale, "form.title", &args)` formats the `.title` attribute of `form`. + +Arguments are wrapped in Unicode bidi isolation marks (U+2068 … U+2069) by default, so a +right-to-left name cannot reorder the sentence around it. Turn it off (`use_isolating(false)`) only +for output that is never displayed in a bidirectional context, such as logs or tests. + +## Loading a catalog + +Resources come from the binary or from disk, and both are read **once, at startup**: + +- `embedded(locale, origin, source)` adds a `&'static str`, usually + `include_str!("../locales/de/app.ftl")`. Nothing is read at run time, and the binary is the + deployment unit. +- `allow_dir(dir)?` allowlists a directory; `dir(dir)?` then loads every `/*.ftl` below it + (each subdirectory is named after its locale: `en`, `de`, `pt-BR`), and `file(locale, path)?` + one file. A path is resolved first (`..` and symbolic links), and a file outside every + allowlisted directory is refused with `CatalogError::PathOutsideAllowlist`. Only `.ftl` files of + at most `MAX_RESOURCE_BYTES` (1 MiB) are read. + +`build()` parses everything. A Fluent syntax error (`CatalogError::Parse`), a message defined twice +in one locale (`CatalogError::Conflict`) or a default locale without resources +(`CatalogError::MissingDefault`) fails the build, so a broken translation stops the deploy instead +of showing up in a response. + +```rust +use id_effect_i18n::{Catalog, CatalogError, Locale}; + +let en = Locale::parse("en").unwrap(); +let broken = Catalog::builder(en.clone()) + .embedded(en.clone(), "en/app.ftl", "greeting = Hello, { $name") + .build(); +assert!(matches!(broken, Err(CatalogError::Parse { .. }))); + +// Nothing allowlisted: no path is read at all. +let refused = Catalog::builder(en).file(Locale::parse("de").unwrap(), "/etc/passwd.ftl"); +assert!(refused.is_err()); +``` + +The locale a request asks for never becomes a path: catalogs are keyed by parsed `Locale` values, +`Locale::parse` accepts at most 16 bytes of ASCII subtags (`language[-Script][-REGION]`), and the +files were read before the first request arrived (ADR 0013 R4). + +## Fallback and the startup check + +A lookup tries, in order, the requested locale, its language and the default locale: +`de-AT` → `de` → `en`. A regional resource only needs the messages that differ. A key that no +locale of the chain defines is returned as the key itself by `format` (and as `None` by `lookup`, +which also tells which locale answered). + +`Catalog::check()` is the startup check: it reports every key of the default locale that a +non-default locale lacks, where neither the locale nor its language defines it, which is exactly +the text a user of that locale would see in the default language. Log it at startup, fail CI on it, +or call `require_complete()` on the builder to make an incomplete catalog fail `build`. + +```rust +use id_effect_i18n::{Args, Catalog, Locale}; + +let en = Locale::parse("en").unwrap(); +let de = Locale::parse("de").unwrap(); +let de_at = Locale::parse("de-AT").unwrap(); +let catalog = Catalog::builder(en.clone()) + .embedded(en.clone(), "en/app.ftl", "greeting = Hello!\nfarewell = Goodbye!\nhelp = Help") + .embedded(de.clone(), "de/app.ftl", "greeting = Hallo!\nhelp = Hilfe") + .embedded(de_at.clone(), "de-AT/app.ftl", "greeting = Servus!") + .build() + .unwrap(); + +let none = Args::new(); +assert_eq!(catalog.format(&de_at, "greeting", &none), "Servus!"); // de-AT +assert_eq!(catalog.format(&de_at, "help", &none), "Hilfe"); // de +let farewell = catalog.lookup(&de_at, "farewell", &none).unwrap(); // en +assert_eq!((farewell.text.as_str(), farewell.locale.as_str()), ("Goodbye!", "en")); +assert_eq!(catalog.format(&de_at, "no-such-key", &none), "no-such-key"); + +let report = catalog.check(); +assert_eq!(report.missing_in(&de).collect::>(), ["farewell"]); +assert_eq!(report.missing_in(&de_at).collect::>(), ["farewell"]); // help comes from de +println!("{report}"); // 2 missing key(s): de: farewell; de-AT: farewell; +``` + +## The locale of a request: `CurrentLocale` and `with_locale` + +Passing a locale argument through every function that might format a message does not scale. +`CurrentLocale` is a capability: provide it once in the application environment with the default +locale, next to the `Catalog`. Its value is **fiber-local** (an id_effect `FiberRef`, never a +thread-local): `with_locale(locale, effect)` runs one effect, typically a request handler, with the +locale switched, restores the previous value however the effect ends, and every fiber the effect +forks inherits the switch. Two requests running at the same time each see their own locale. + +`current_locale()` reads it; `translate(key, args)` formats a message from the `Catalog` in it. + +```rust +use id_effect::{Effect, Exit, HCons, HNil, Never, hlist, run_test, zip_par}; +use id_effect_i18n::{Args, Catalog, CurrentLocale, Locale, current_locale, translate, with_locale}; + +type AppEnv = HCons>; + +/// Deep in the handler: no locale parameter, it comes from the environment. +fn welcome(name: &str) -> Effect { + translate("welcome", Args::new().with("name", name.to_owned())) +} + +/// One request in the locale the edge chose for it. +fn handle(locale: Locale, name: &'static str) -> Effect { + with_locale(locale, welcome(name)) +} + +let en = Locale::parse("en").unwrap(); +let de = Locale::parse("de").unwrap(); +let catalog = Catalog::builder(en.clone()) + .embedded(en.clone(), "en/app.ftl", "welcome = Welcome, { $name }!") + .embedded(de.clone(), "de/app.ftl", "welcome = Willkommen, { $name }!") + .use_isolating(false) + .build() + .unwrap(); +let env = hlist![catalog, CurrentLocale::new(en.clone())]; + +// Two concurrent requests, two locales; the parent fiber keeps the default. +let requests = zip_par(handle(de, "Ada"), handle(en.clone(), "Grace")) + .flat_map(|pair| current_locale().map(move |after| (pair, after))); +match run_test(requests.provide_all(env)) { + Exit::Success(((de_text, en_text), after)) => { + assert_eq!(de_text, "Willkommen, Ada!"); + assert_eq!(en_text, "Welcome, Grace!"); + assert_eq!(after, en); + } + other => panic!("{other:?}"), +} +``` + +Where the locale comes from is the edge's decision: a user setting, a cookie, or the +`Accept-Language` header negotiated against `Catalog::supports` (the `axum` feature adds a layer +that does this and calls `with_locale` for each request). Choose from the catalog's locales rather +than echoing input: an unsupported locale is harmless for lookups (they fall back), but it is what +`current_locale()` reports to the rest of the request. + +## Summary + +| Piece | API | Notes | +|---|---|---| +| Catalog | `Catalog::builder(default)` · `embedded` / `allow_dir` + `dir` / `file` · `build()` | parsed once; parse errors fail startup | +| Plurals | Fluent selectors `{ $n -> [one] … *[other] … }` | CLDR rules of the message's locale | +| Fallback | `lookup` / `format` / `fallback_chain` | requested → language → default | +| Startup check | `check()` → `CatalogReport` · `require_complete()` | keys a locale shows in the default language | +| Locale of a request | `CurrentLocale::new(default)` · `with_locale` · `current_locale` · `translate` | fiber-local, inherited by forks | +| Input | `Locale::parse` | ≤ 16 bytes, ASCII subtags; never a path | diff --git a/crates/id_effect_i18n/Cargo.toml b/crates/id_effect_i18n/Cargo.toml index eab0709ab..9270be1f9 100644 --- a/crates/id_effect_i18n/Cargo.toml +++ b/crates/id_effect_i18n/Cargo.toml @@ -17,14 +17,49 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } [lib] name = "id_effect_i18n" -# r13-e9-4-i18n-core declares the 0.13 features and dependencies (Project Fluent, `axum`, `maud`, -# `schema`); the skeleton (r13-t0-skeletons) has none. +# Every 0.13 feature is declared here once (r13-e9-4-i18n-core), so r13-e9-4-i18n-web adds modules +# without editing this manifest. All are off by default. +[features] +default = [] +# `Accept-Language` negotiation for axum: a layer that provides `CurrentLocale` per request. +axum = [ + "dep:axum", + "dep:id_effect_axum", + "dep:tower-layer", + "dep:tower-service", +] +# `maud::Render` for translated messages (escaped arguments, Unicode bidi isolation). +maud = ["dep:maud"] +# Translated `Schema` validation messages (error codes of `id_effect::schema` → message ids). +schema = [] + [dependencies] +# No `compute` (rayon, sysinfo): catalogs and the locale capability need only the kernel. +id_effect = { path = "../id_effect", version = "0.12.0", default-features = false } +# Project Fluent (0.13 decision D5), exact pins: the crates are pre-1.0. fluent-bundle 0.16.0 +# requires fluent-langneg ^0.13, fluent-syntax ^0.12.0 and unic-langid ^0.9, so these pins are the +# versions it resolves to and the tree keeps one copy of each. CLDR plural rules come through +# fluent-bundle's intl_pluralrules 7. +fluent-bundle = "=0.16.0" +fluent-langneg = "=0.13.1" +fluent-syntax = "=0.12.0" +unic-langid = "=0.9.6" +# Feature `axum` (r13-e9-4-i18n-web); the versions id_effect_axum already uses. +axum = { version = "0.8.9", optional = true } +id_effect_axum = { path = "../id_effect_axum", version = "0.12.0", optional = true } +tower-layer = { version = "0.3.3", optional = true } +tower-service = { version = "0.3.3", optional = true } +# Feature `maud` (r13-e9-4-i18n-web), exact pin (pre-1.0); only the `Render` trait. +maud = { version = "=0.27.0", optional = true } [[example]] name = "010_locale" path = "examples/010_locale.rs" +[[example]] +name = "020_catalog" +path = "examples/020_catalog.rs" + [package.metadata.docs.rs] all-features = true diff --git a/crates/id_effect_i18n/README.md b/crates/id_effect_i18n/README.md index 29cfbe1e8..13409c0c6 100644 --- a/crates/id_effect_i18n/README.md +++ b/crates/id_effect_i18n/README.md @@ -1,35 +1,89 @@ # id_effect_i18n -Internationalization for [`id_effect`](../id_effect) services. +Internationalization for [`id_effect`](../id_effect) services, over +[Project Fluent](https://projectfluent.org). -0.13 builds this crate in steps (ADR 0010 Amendment 9): Project Fluent message catalogs with CLDR -plural rules, a `CurrentLocale` capability switched per request or fiber, `Accept-Language` -negotiation for `id_effect_axum`, and translated `Schema` validation messages. The first step is -`Locale`, a validated, case-normalized language identifier. +- **`Catalog`**: Fluent resources per locale, embedded with `include_str!` or loaded from + allowlisted directories, parsed once at startup. Lookups fall back from the requested locale to + its language and then to the default locale; `Catalog::check` reports every key a locale lacks. +- **CLDR plurals** through Fluent selectors (`{ $n -> [one] … [few] … *[other] … }`), with the + rules of the locale a message comes from. +- **`CurrentLocale`**: the locale capability in the environment, switched per request or per fiber + with `with_locale`; `translate` formats a message in it. +- **`Locale`**: a validated, case-normalized language identifier (`de_at` → `de-AT`). ## Quick start ```rust -use id_effect_i18n::Locale; +use id_effect::{Effect, Exit, HCons, HNil, Never, hlist, run_test}; +use id_effect_i18n::{Args, Catalog, CurrentLocale, Locale, translate, with_locale}; -let locale: Locale = "de_at".parse().unwrap(); -assert_eq!(locale.as_str(), "de-AT"); -assert_eq!(locale.language_only().as_str(), "de"); +let en: Locale = "en".parse().unwrap(); +let de: Locale = "de".parse().unwrap(); +let catalog = Catalog::builder(en.clone()) + .embedded(en.clone(), "en/app.ftl", "inbox = { $n -> + [one] one message + *[other] { $n } messages + }") + .embedded(de.clone(), "de/app.ftl", "inbox = { $n -> + [one] eine Nachricht + *[other] { $n } Nachrichten + }") + .use_isolating(false) + .build() + .expect("every resource parses"); // a syntax error fails startup +assert!(catalog.check().is_complete()); + +// Per request: switch the locale for the handler's effect (and every fiber it forks). +type Env = HCons>; +let handler: Effect = + with_locale("de-AT".parse().unwrap(), translate("inbox", Args::new().with("n", 2))); +let exit = run_test(handler.provide_all(hlist![catalog, CurrentLocale::new(en)])); +assert!(matches!(exit, Exit::Success(text) if text == "2 Nachrichten")); // de-AT → de // Untrusted input never becomes a path or an unbounded string. assert!(Locale::parse("../../etc/passwd").is_err()); ``` +## Loading from a directory + +```text +locales/ + en/app.ftl en/errors.ftl + de/app.ftl + pt-BR/app.ftl +``` + +```rust,no_run +use id_effect_i18n::{Catalog, Locale}; + +let catalog = Catalog::builder(Locale::parse("en").unwrap()) + .allow_dir("locales")? // only files below here are read (`..` and symlinks resolved) + .dir("locales")? // every /*.ftl + .require_complete() // optional: a missing key fails startup too + .build()?; +# Ok::<(), id_effect_i18n::CatalogError>(()) +``` + ## Secure by default `Locale::parse` reads at most `Locale::MAX_LEN` bytes and accepts only ASCII subtags of the shapes -`language[-Script][-REGION]` (ADR 0013 R4), so a locale taken from a header or a query parameter is -safe to use as a catalog key. +`language[-Script][-REGION]` (ADR 0013 R4), and catalogs are keyed by parsed locales, so a locale +taken from a header or a query parameter never reaches the file system. Resources are read once at +startup, only `.ftl` files of at most 1 MiB below allowlisted directories; parse errors and +duplicate messages fail `build`. Arguments are wrapped in Unicode bidi isolation marks by default. + +## Features + +All off by default; filled in by the 0.13 web step: `axum` (Accept-Language negotiation and a +layer providing `CurrentLocale`), `maud` (rendering messages into HTML), `schema` (translated +`Schema` validation messages). ## Examples ```bash cargo run -p id_effect_i18n --example 010_locale +cargo run -p id_effect_i18n --example 020_catalog ``` ## Tier diff --git a/crates/id_effect_i18n/examples/020_catalog.rs b/crates/id_effect_i18n/examples/020_catalog.rs new file mode 100644 index 000000000..d07704249 --- /dev/null +++ b/crates/id_effect_i18n/examples/020_catalog.rs @@ -0,0 +1,50 @@ +//! Build a Fluent catalog from embedded resources, check it at startup, and format plurals in +//! the locale a fiber selected with `with_locale`. +//! +//! ```bash +//! cargo run -p id_effect_i18n --example 020_catalog +//! ``` + +use id_effect::{Effect, Exit, HCons, HNil, Never, hlist, run_test}; +use id_effect_i18n::{Args, Catalog, CurrentLocale, Locale, translate, with_locale}; + +const EN: &str = "\ +inbox = { $count -> + [one] You have one new message. + *[other] You have { $count } new messages. +} +signed-in = Signed in as { $name }. +"; + +const DE: &str = "\ +inbox = { $count -> + [one] Sie haben eine neue Nachricht. + *[other] Sie haben { $count } neue Nachrichten. +} +"; + +type Env = HCons>; + +fn main() { + let en = Locale::parse("en").expect("en"); + let catalog = Catalog::builder(en.clone()) + .embedded(en.clone(), "en/app.ftl", EN) + .embedded(Locale::parse("de").expect("de"), "de/app.ftl", DE) + .use_isolating(false) // plain terminal output + .build() + .expect("every resource parses"); + + // The startup check: `signed-in` is missing from de and would show in English. + println!("check: {}", catalog.check()); + + for (tag, count) in [("en", 1), ("en", 5), ("de-AT", 1), ("de", 5)] { + let locale = Locale::parse(tag).expect("locale"); + let eff: Effect = + with_locale(locale, translate("inbox", Args::new().with("count", count))); + let env = hlist![catalog.clone(), CurrentLocale::new(en.clone())]; + match run_test(eff.provide_all(env)) { + Exit::Success(text) => println!("{tag:>5} {count}: {text}"), + Exit::Failure(cause) => println!("{tag:>5} {count}: failed: {cause:?}"), + } + } +} diff --git a/crates/id_effect_i18n/src/args.rs b/crates/id_effect_i18n/src/args.rs new file mode 100644 index 000000000..aa9bb9c2f --- /dev/null +++ b/crates/id_effect_i18n/src/args.rs @@ -0,0 +1,180 @@ +//! [`Args`]: the named arguments of a message (`{ $count }`, `{ $name }`). + +use std::borrow::Cow; + +use fluent_bundle::{FluentArgs, FluentValue}; + +/// One argument value: text or a number. +/// +/// Numbers drive Fluent selectors such as `{ $count -> [one] ... *[other] ... }`, which pick the +/// CLDR plural category of the message's locale. +#[derive(Clone, Debug, PartialEq)] +pub enum ArgValue { + /// A string, inserted as is (Fluent does not escape; HTML escaping is the renderer's job). + Text(String), + /// An integer. + Int(i64), + /// A floating-point number. + Float(f64), +} + +impl From<&str> for ArgValue { + fn from(v: &str) -> Self { + Self::Text(v.to_owned()) + } +} + +impl From for ArgValue { + fn from(v: String) -> Self { + Self::Text(v) + } +} + +impl From for ArgValue { + fn from(v: f64) -> Self { + Self::Float(v) + } +} + +impl From for ArgValue { + fn from(v: f32) -> Self { + Self::Float(f64::from(v)) + } +} + +macro_rules! int_arg { + ($($t:ty),*) => { + $(impl From<$t> for ArgValue { + fn from(v: $t) -> Self { + Self::Int(i64::from(v)) + } + })* + }; +} + +int_arg!(i8, i16, i32, i64, u8, u16, u32); + +macro_rules! wide_arg { + ($($t:ty),*) => { + $(impl From<$t> for ArgValue { + /// An integer; a value above `i64::MAX` becomes the nearest `Float`. + fn from(v: $t) -> Self { + i64::try_from(v).map_or(Self::Float(v as f64), Self::Int) + } + })* + }; +} + +wide_arg!(u64, usize); + +/// The named arguments of one message, in insertion order (a later value for the same name wins). +/// +/// ``` +/// use id_effect_i18n::{ArgValue, Args}; +/// +/// let args = Args::new().with("name", "Ada").with("count", 3); +/// assert_eq!(args.get("count"), Some(&ArgValue::Int(3))); +/// assert_eq!(args.len(), 2); +/// ``` +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Args { + values: Vec<(Cow<'static, str>, ArgValue)>, +} + +impl Args { + /// No arguments. + pub fn new() -> Self { + Self::default() + } + + /// These arguments with `name` set to `value`. + #[must_use] + pub fn with(mut self, name: impl Into>, value: impl Into) -> Self { + self.set(name, value); + self + } + + /// Sets `name` to `value`, replacing an earlier value of the same name. + pub fn set(&mut self, name: impl Into>, value: impl Into) { + let name = name.into(); + let value = value.into(); + match self.values.iter_mut().find(|(n, _)| *n == name) { + Some(slot) => slot.1 = value, + None => self.values.push((name, value)), + } + } + + /// The value of `name`, if set. + pub fn get(&self, name: &str) -> Option<&ArgValue> { + self + .values + .iter() + .find_map(|(n, v)| (n == name).then_some(v)) + } + + /// The number of arguments. + pub fn len(&self) -> usize { + self.values.len() + } + + /// Whether there are no arguments. + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } + + /// The arguments as Fluent sees them. + pub(crate) fn to_fluent(&self) -> FluentArgs<'_> { + let mut out = FluentArgs::with_capacity(self.values.len()); + for (name, value) in &self.values { + let value = match value { + ArgValue::Text(s) => FluentValue::from(s.as_str()), + ArgValue::Int(n) => FluentValue::from(*n), + ArgValue::Float(n) => FluentValue::from(*n), + }; + out.set(name.as_ref(), value); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arg_values_convert_from_strings_and_numbers() { + assert_eq!(ArgValue::from("a"), ArgValue::Text("a".into())); + assert_eq!( + ArgValue::from(String::from("b")), + ArgValue::Text("b".into()) + ); + assert_eq!(ArgValue::from(1.5_f64), ArgValue::Float(1.5)); + assert_eq!(ArgValue::from(0.5_f32), ArgValue::Float(0.5)); + assert_eq!(ArgValue::from(-3_i8), ArgValue::Int(-3)); + assert_eq!(ArgValue::from(-3_i16), ArgValue::Int(-3)); + assert_eq!(ArgValue::from(-3_i32), ArgValue::Int(-3)); + assert_eq!(ArgValue::from(-3_i64), ArgValue::Int(-3)); + assert_eq!(ArgValue::from(3_u8), ArgValue::Int(3)); + assert_eq!(ArgValue::from(3_u16), ArgValue::Int(3)); + assert_eq!(ArgValue::from(3_u32), ArgValue::Int(3)); + assert_eq!(ArgValue::from(3_u64), ArgValue::Int(3)); + assert_eq!(ArgValue::from(3_usize), ArgValue::Int(3)); + assert_eq!(ArgValue::from(u64::MAX), ArgValue::Float(u64::MAX as f64)); + } + + #[test] + fn args_set_replaces_and_converts_to_fluent() { + let mut args = Args::new(); + assert!(args.is_empty()); + args.set("n", 1); + args.set(String::from("n"), 2); + let args = args.with("who", "Ada").with("ratio", 0.5); + assert_eq!(args.len(), 3); + assert_eq!(args.get("n"), Some(&ArgValue::Int(2))); + assert_eq!(args.get("missing"), None); + let fluent = args.to_fluent(); + assert_eq!(fluent.get("n"), Some(&FluentValue::from(2_i64))); + assert_eq!(fluent.get("who"), Some(&FluentValue::from("Ada"))); + assert_eq!(fluent.get("ratio"), Some(&FluentValue::from(0.5_f64))); + } +} diff --git a/crates/id_effect_i18n/src/catalog.rs b/crates/id_effect_i18n/src/catalog.rs new file mode 100644 index 000000000..e44ae3a02 --- /dev/null +++ b/crates/id_effect_i18n/src/catalog.rs @@ -0,0 +1,646 @@ +//! [`Catalog`]: Project Fluent resources per locale, loaded once at startup. +//! +//! A catalog is built by a [`CatalogBuilder`] from resources embedded in the binary +//! ([`CatalogBuilder::embedded`], usually with `include_str!`) or read from directories the +//! application allowlists first ([`CatalogBuilder::allow_dir`]). Every resource is parsed while the +//! catalog is built: a syntax error, a duplicate message or a missing default locale fails +//! [`build`](CatalogBuilder::build), so a broken translation stops the service at startup instead +//! of surfacing in a response. +//! +//! Lookups walk a fallback chain: the requested locale, its language (`de` for `de-AT`), then the +//! default locale. [`Catalog::check`] reports every key of the default locale that a non-default +//! locale lacks (and would therefore show in the default language). + +use std::borrow::Cow; +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsStr; +use std::fmt; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use fluent_bundle::FluentResource; +use fluent_bundle::concurrent::FluentBundle; +use fluent_syntax::ast; +use unic_langid::LanguageIdentifier; + +use crate::{Args, Locale}; + +/// The largest Fluent resource file a catalog reads, in bytes (1 MiB). A larger file fails the +/// build with [`CatalogError::TooLarge`]. +pub const MAX_RESOURCE_BYTES: u64 = 1 << 20; + +type Bundle = FluentBundle; + +/// One locale's messages: its Fluent bundle and the keys (`id`, `id.attribute`) it defines. +struct LocaleCatalog { + bundle: Bundle, + keys: BTreeSet, +} + +impl LocaleCatalog { + fn new(locale: &Locale, use_isolating: bool) -> Self { + let mut bundle = Bundle::new_concurrent(vec![LanguageIdentifier::from(locale)]); + bundle.set_use_isolating(use_isolating); + Self { + bundle, + keys: BTreeSet::new(), + } + } + + fn add(&mut self, source: Source) -> Result<(), CatalogError> { + let Source { + locale, + origin, + text, + } = source; + let resource = match FluentResource::try_new(text.into_owned()) { + Ok(resource) => resource, + Err((_, errors)) => { + return Err(CatalogError::Parse { + locale, + origin, + errors: errors.iter().map(ToString::to_string).collect(), + }); + } + }; + let keys = message_keys(&resource); + if let Err(errors) = self.bundle.add_resource(resource) { + return Err(CatalogError::Conflict { + locale, + origin, + errors: errors.iter().map(ToString::to_string).collect(), + }); + } + self.keys.extend(keys); + Ok(()) + } +} + +/// The keys a resource defines: `id` for a message with a value, `id.attribute` per attribute. +/// Terms (`-brand`) are private to the resource and are not keys. +fn message_keys(resource: &FluentResource) -> Vec { + let mut keys = Vec::new(); + for entry in resource.entries() { + if let ast::Entry::Message(message) = entry { + let id = message.id.name; + if message.value.is_some() { + keys.push(id.to_owned()); + } + keys.extend( + message + .attributes + .iter() + .map(|attribute| format!("{id}.{}", attribute.id.name)), + ); + } + } + keys +} + +/// One resource waiting to be parsed by [`CatalogBuilder::build`]. +#[derive(Debug)] +struct Source { + locale: Locale, + origin: String, + text: Cow<'static, str>, +} + +struct Inner { + default: Locale, + locales: BTreeMap, +} + +/// Fluent messages per locale, with a fallback chain and a completeness check. +/// +/// Cheap to clone (one `Arc`), `Send + Sync`: provide it once in the application environment and +/// read it with `need::()` or [`translate`](crate::translate). +/// +/// ``` +/// use id_effect_i18n::{Args, Catalog, Locale}; +/// +/// let en = Locale::parse("en").unwrap(); +/// let de = Locale::parse("de").unwrap(); +/// let catalog = Catalog::builder(en.clone()) +/// .embedded(en.clone(), "app.ftl", "hello = Hello, { $name }!\nbye = Goodbye") +/// .embedded(de.clone(), "app.ftl", "hello = Hallo, { $name }!") +/// .use_isolating(false) +/// .build() +/// .unwrap(); +/// +/// let args = Args::new().with("name", "Ada"); +/// let de_at = Locale::parse("de-AT").unwrap(); +/// assert_eq!(catalog.format(&de_at, "hello", &args), "Hallo, Ada!"); // de-AT → de +/// assert_eq!(catalog.format(&de, "bye", &Args::new()), "Goodbye"); // de → en +/// assert_eq!(catalog.check().missing().len(), 1); // `bye` is missing from de +/// ``` +#[derive(Clone)] +pub struct Catalog { + inner: Arc, +} + +impl fmt::Debug for Catalog { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Catalog") + .field("default", &self.inner.default) + .field("locales", &self.inner.locales.keys().collect::>()) + .finish() + } +} + +/// A formatted message and the locale whose resource produced it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Translation { + /// The formatted text. + pub text: String, + /// The locale the message came from: the requested one, its language, or the default. + pub locale: Locale, +} + +impl Catalog { + /// A builder for a catalog whose fallback of last resort is `default`. + pub fn builder(default: Locale) -> CatalogBuilder { + CatalogBuilder { + default, + roots: Vec::new(), + sources: Vec::new(), + use_isolating: true, + require_complete: false, + } + } + + /// The default locale: the last step of every fallback chain. + pub fn default_locale(&self) -> &Locale { + &self.inner.default + } + + /// Every locale with at least one resource, in order. + pub fn locales(&self) -> impl Iterator { + self.inner.locales.keys() + } + + /// Whether `locale` has resources of its own (the allowlist of locales a request may select). + pub fn supports(&self, locale: &Locale) -> bool { + self.inner.locales.contains_key(locale) + } + + /// Whether `locale`'s own resources define `key` (`id` or `id.attribute`), without fallback. + pub fn has_key(&self, locale: &Locale, key: &str) -> bool { + self + .inner + .locales + .get(locale) + .is_some_and(|entry| entry.keys.contains(key)) + } + + /// The locales a lookup for `locale` tries, in order: `locale`, its language, the default; each + /// once, and only those with resources. + pub fn fallback_chain(&self, locale: &Locale) -> Vec<&Locale> { + let language = locale.language_only(); + let mut chain: Vec<&Locale> = Vec::with_capacity(3); + for candidate in [locale, &language, &self.inner.default] { + if let Some((known, _)) = self.inner.locales.get_key_value(candidate) + && !chain.contains(&known) + { + chain.push(known); + } + } + chain + } + + /// Formats `key` (`id` or `id.attribute`) with `args` in the first locale of + /// [`fallback_chain`](Self::fallback_chain) that defines it; `None` when none does. + /// + /// Plural and other selectors use the CLDR rules of the locale the message came from. A + /// placeable whose argument is missing renders as `{$name}` (Fluent's behavior). Arguments are + /// wrapped in Unicode bidi isolation marks (U+2068 … U+2069) unless the catalog was built with + /// [`use_isolating(false)`](CatalogBuilder::use_isolating). + pub fn lookup(&self, locale: &Locale, key: &str, args: &Args) -> Option { + let (id, attribute) = match key.split_once('.') { + Some((id, attribute)) => (id, Some(attribute)), + None => (key, None), + }; + let fluent_args = args.to_fluent(); + self.fallback_chain(locale).into_iter().find_map(|known| { + let bundle = &self.inner.locales[known].bundle; + let message = bundle.get_message(id)?; + let pattern = match attribute { + Some(name) => message.get_attribute(name)?.value(), + None => message.value()?, + }; + let mut errors = Vec::new(); + let text = bundle + .format_pattern(pattern, Some(&fluent_args), &mut errors) + .into_owned(); + Some(Translation { + text, + locale: known.clone(), + }) + }) + } + + /// Formats `key` like [`lookup`](Self::lookup), or returns `key` itself when no locale of the + /// chain defines it (so a missing message is visible, never empty). + pub fn format(&self, locale: &Locale, key: &str, args: &Args) -> String { + self + .lookup(locale, key, args) + .map_or_else(|| key.to_owned(), |translation| translation.text) + } + + /// The startup completeness check: every key of the default locale that a non-default locale + /// lacks, where neither the locale nor its language defines it (so it would show in the + /// default language). + pub fn check(&self) -> CatalogReport { + let mut missing = Vec::new(); + // `build` refuses a catalog without the default locale, so the index cannot fail. + let default = &self.inner.locales[&self.inner.default]; + for (locale, entry) in &self.inner.locales { + if *locale == self.inner.default { + continue; + } + let parent = self.inner.locales.get(&locale.language_only()); + for key in &default.keys { + let covered = entry.keys.contains(key) || parent.is_some_and(|p| p.keys.contains(key)); + if !covered { + missing.push(MissingKey { + locale: locale.clone(), + key: key.clone(), + }); + } + } + } + CatalogReport { missing } + } +} + +/// Builds a [`Catalog`]; see [`Catalog::builder`]. +/// +/// Files are read only below directories allowlisted with [`allow_dir`](Self::allow_dir) (after +/// resolving `..` and symbolic links), only with the `.ftl` extension and only up to +/// [`MAX_RESOURCE_BYTES`]. Locales are never taken from request input here: a locale directory's +/// name is parsed as a [`Locale`] at startup, and lookups go through the parsed catalog, never +/// the file system. +#[derive(Debug)] +pub struct CatalogBuilder { + default: Locale, + roots: Vec, + sources: Vec, + use_isolating: bool, + require_complete: bool, +} + +impl CatalogBuilder { + /// Adds a resource compiled into the binary, e.g. + /// `.embedded(de, "app.ftl", include_str!("../locales/de/app.ftl"))`. `origin` names it in + /// errors. It is parsed by [`build`](Self::build). + #[must_use] + pub fn embedded(mut self, locale: Locale, origin: &'static str, source: &'static str) -> Self { + self.sources.push(Source { + locale, + origin: origin.to_owned(), + text: Cow::Borrowed(source), + }); + self + } + + /// Adds a resource from a string the application owns (a trusted source, not request input). + #[must_use] + pub fn source( + mut self, + locale: Locale, + origin: impl Into, + text: impl Into, + ) -> Self { + self.sources.push(Source { + locale, + origin: origin.into(), + text: Cow::Owned(text.into()), + }); + self + } + + /// Allowlists `dir`: [`file`](Self::file) and [`dir`](Self::dir) read only below an allowlisted + /// directory. Without one, every path is refused. + /// + /// # Errors + /// + /// [`CatalogError::Io`] when `dir` cannot be resolved (it does not exist, for example). + pub fn allow_dir(mut self, dir: impl AsRef) -> Result { + let dir = dir.as_ref(); + let root = fs::canonicalize(dir).map_err(|e| CatalogError::io(dir, &e))?; + self.roots.push(root); + Ok(self) + } + + /// Adds the Fluent file at `path` as a resource of `locale`. + /// + /// # Errors + /// + /// [`CatalogError::PathOutsideAllowlist`] when the resolved path is not below an allowlisted + /// directory, [`CatalogError::NotFluentFile`] without the `.ftl` extension, + /// [`CatalogError::TooLarge`] above [`MAX_RESOURCE_BYTES`], [`CatalogError::Io`] when it + /// cannot be read as UTF-8. + pub fn file(mut self, locale: Locale, path: impl AsRef) -> Result { + let path = path.as_ref(); + let resolved = self.resolve(path)?; + if resolved.extension() != Some(OsStr::new("ftl")) { + return Err(CatalogError::NotFluentFile { + path: path.to_path_buf(), + }); + } + let mut text = String::new(); + File::open(&resolved) + .and_then(|file| file.take(MAX_RESOURCE_BYTES + 1).read_to_string(&mut text)) + .map_err(|e| CatalogError::io(path, &e))?; + if text.len() as u64 > MAX_RESOURCE_BYTES { + return Err(CatalogError::TooLarge { + path: path.to_path_buf(), + limit: MAX_RESOURCE_BYTES, + }); + } + self.sources.push(Source { + locale, + origin: path.display().to_string(), + text: Cow::Owned(text), + }); + Ok(self) + } + + /// Adds every `//*.ftl` file, in name order: each subdirectory of `dir` is one + /// locale (`en`, `de`, `pt-BR` or `pt_BR`). Files directly in `dir`, other extensions and + /// nested directories are ignored. + /// + /// # Errors + /// + /// [`CatalogError::InvalidLocaleDir`] for a subdirectory whose name is not a locale, and every + /// error of [`file`](Self::file) for the files. + pub fn dir(mut self, dir: impl AsRef) -> Result { + let root = self.resolve(dir.as_ref())?; + for locale_dir in sorted_entries(&root)? { + if !locale_dir.is_dir() { + continue; + } + let locale = locale_dir + .file_name() + .and_then(OsStr::to_str) + .and_then(|name| Locale::parse(name).ok()) + .ok_or_else(|| CatalogError::InvalidLocaleDir { + path: locale_dir.clone(), + })?; + for file in sorted_entries(&locale_dir)? { + if file.is_file() && file.extension() == Some(OsStr::new("ftl")) { + self = self.file(locale.clone(), &file)?; + } + } + } + Ok(self) + } + + /// Whether arguments are wrapped in Unicode bidi isolation marks (U+2068 … U+2069) so a + /// right-to-left name cannot reorder the surrounding text. On by default; turn it off only for + /// output that is never shown in a bidirectional context (logs, tests). + #[must_use] + pub fn use_isolating(mut self, on: bool) -> Self { + self.use_isolating = on; + self + } + + /// Makes [`build`](Self::build) fail with [`CatalogError::Incomplete`] when + /// [`Catalog::check`] reports a missing key (for CI, or a strict production profile). + #[must_use] + pub fn require_complete(mut self) -> Self { + self.require_complete = true; + self + } + + /// Parses every resource and builds the catalog. + /// + /// # Errors + /// + /// [`CatalogError::Parse`] for a Fluent syntax error, [`CatalogError::Conflict`] for a message + /// defined twice in one locale, [`CatalogError::MissingDefault`] when the default locale has no + /// resource, [`CatalogError::Incomplete`] under [`require_complete`](Self::require_complete). + pub fn build(self) -> Result { + let Self { + default, + sources, + use_isolating, + require_complete, + .. + } = self; + let mut locales: BTreeMap = BTreeMap::new(); + for source in sources { + locales + .entry(source.locale.clone()) + .or_insert_with(|| LocaleCatalog::new(&source.locale, use_isolating)) + .add(source)?; + } + if !locales.contains_key(&default) { + return Err(CatalogError::MissingDefault { locale: default }); + } + let catalog = Catalog { + inner: Arc::new(Inner { default, locales }), + }; + if require_complete { + let report = catalog.check(); + if !report.is_complete() { + return Err(CatalogError::Incomplete(report)); + } + } + Ok(catalog) + } + + /// `path` resolved (`..` and symbolic links), if it is below an allowlisted directory. + fn resolve(&self, path: &Path) -> Result { + let resolved = fs::canonicalize(path).map_err(|e| CatalogError::io(path, &e))?; + if self.roots.iter().any(|root| resolved.starts_with(root)) { + Ok(resolved) + } else { + Err(CatalogError::PathOutsideAllowlist { + path: path.to_path_buf(), + }) + } + } +} + +/// The entries of `dir`, sorted by path. +fn sorted_entries(dir: &Path) -> Result, CatalogError> { + let mut entries = fs::read_dir(dir) + .and_then(|entries| { + entries + .map(|entry| entry.map(|entry| entry.path())) + .collect::, _>>() + }) + .map_err(|e| CatalogError::io(dir, &e))?; + entries.sort(); + Ok(entries) +} + +/// One key of the default locale that a locale lacks. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MissingKey { + /// The locale without the key. + pub locale: Locale, + /// The key (`id` or `id.attribute`). + pub key: String, +} + +impl fmt::Display for MissingKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.locale, self.key) + } +} + +/// What [`Catalog::check`] found. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CatalogReport { + missing: Vec, +} + +impl CatalogReport { + /// Every missing key, by locale then key. + pub fn missing(&self) -> &[MissingKey] { + &self.missing + } + + /// The keys `locale` lacks. + pub fn missing_in<'a>(&'a self, locale: &'a Locale) -> impl Iterator { + self + .missing + .iter() + .filter(move |m| m.locale == *locale) + .map(|m| m.key.as_str()) + } + + /// Whether every locale has every key of the default locale. + pub fn is_complete(&self) -> bool { + self.missing.is_empty() + } +} + +impl fmt::Display for CatalogReport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.missing.is_empty() { + return f.write_str("every locale has every key of the default locale"); + } + write!(f, "{} missing key(s):", self.missing.len())?; + for missing in &self.missing { + write!(f, " {missing};")?; + } + Ok(()) + } +} + +/// Why a [`CatalogBuilder`] refused a path or failed to build. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum CatalogError { + /// A file or directory could not be resolved or read. + Io { + /// The path as given. + path: PathBuf, + /// The operating system's message. + message: String, + }, + /// The resolved path is not below an allowlisted directory. + PathOutsideAllowlist { + /// The path as given. + path: PathBuf, + }, + /// The file does not have the `.ftl` extension. + NotFluentFile { + /// The path as given. + path: PathBuf, + }, + /// The file is larger than [`MAX_RESOURCE_BYTES`]. + TooLarge { + /// The path as given. + path: PathBuf, + /// The limit in bytes. + limit: u64, + }, + /// A subdirectory of a [`CatalogBuilder::dir`] is not named after a locale. + InvalidLocaleDir { + /// The subdirectory. + path: PathBuf, + }, + /// A resource is not valid Fluent syntax. + Parse { + /// The resource's locale. + locale: Locale, + /// The resource (file path or embedded name). + origin: String, + /// The parser's messages. + errors: Vec, + }, + /// A resource defines a message or term that another resource of the locale already defines. + Conflict { + /// The resource's locale. + locale: Locale, + /// The resource (file path or embedded name). + origin: String, + /// Fluent's messages. + errors: Vec, + }, + /// The default locale has no resource. + MissingDefault { + /// The default locale. + locale: Locale, + }, + /// [`CatalogBuilder::require_complete`] is on and a locale lacks keys. + Incomplete(CatalogReport), +} + +impl CatalogError { + fn io(path: &Path, error: &std::io::Error) -> Self { + Self::Io { + path: path.to_path_buf(), + message: error.to_string(), + } + } +} + +impl fmt::Display for CatalogError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { path, message } => write!(f, "cannot read {}: {message}", path.display()), + Self::PathOutsideAllowlist { path } => write!( + f, + "{} is outside the allowlisted catalog directories", + path.display() + ), + Self::NotFluentFile { path } => write!(f, "{} is not a .ftl file", path.display()), + Self::TooLarge { path, limit } => { + write!(f, "{} is larger than {limit} bytes", path.display()) + } + Self::InvalidLocaleDir { path } => { + write!(f, "{} is not named after a locale", path.display()) + } + Self::Parse { + locale, + origin, + errors, + } => write!( + f, + "Fluent syntax error in {origin} ({locale}): {}", + errors.join("; ") + ), + Self::Conflict { + locale, + origin, + errors, + } => write!( + f, + "{origin} ({locale}) redefines messages: {}", + errors.join("; ") + ), + Self::MissingDefault { locale } => { + write!(f, "the default locale {locale} has no resource") + } + Self::Incomplete(report) => write!(f, "incomplete catalog: {report}"), + } + } +} + +impl std::error::Error for CatalogError {} diff --git a/crates/id_effect_i18n/src/current.rs b/crates/id_effect_i18n/src/current.rs new file mode 100644 index 000000000..ffb196720 --- /dev/null +++ b/crates/id_effect_i18n/src/current.rs @@ -0,0 +1,121 @@ +//! [`CurrentLocale`]: the locale capability, switched per request or fiber with [`with_locale`]. + +use id_effect::{Effect, FiberRef, Selector, need}; + +use crate::{Args, Catalog, Locale}; + +/// Capability: the locale effects format messages in. +/// +/// Provide one `CurrentLocale` in the application environment, built with the default locale. +/// Its value is fiber-local (an id_effect [`FiberRef`]): [`with_locale`] switches it for one +/// effect, such as a request handler, and every fiber that effect forks inherits the switch, while +/// other fibers (other requests) keep their own. Nothing is stored in thread-locals, so the value +/// follows the fiber across executor threads. +/// +/// ``` +/// use id_effect::{Effect, HCons, HNil, Never, Exit, hlist, run_test}; +/// use id_effect_i18n::{CurrentLocale, Locale, current_locale, with_locale}; +/// +/// type Env = HCons; +/// +/// let en = Locale::parse("en").unwrap(); +/// let de = Locale::parse("de").unwrap(); +/// let both: Effect<(Locale, Locale), Never, Env> = with_locale(de.clone(), current_locale()) +/// .flat_map(|inside| current_locale().map(move |after| (inside, after))); +/// let exit = run_test(both.provide_all(hlist![CurrentLocale::new(en.clone())])); +/// assert!(matches!(exit, Exit::Success((inside, after)) if inside == de && after == en)); +/// ``` +#[derive(Clone, Debug)] +pub struct CurrentLocale { + cell: FiberRef, +} + +impl CurrentLocale { + /// A locale capability whose value is `default` in every fiber until switched. + pub fn new(default: Locale) -> Self { + Self { + cell: FiberRef::new(default), + } + } + + /// The value fibers start with. + pub fn default_locale(&self) -> &Locale { + self.cell.initial() + } + + /// The current fiber's locale. + pub fn get(&self) -> Effect + where + E: Send + 'static, + R: Send + Sync + 'static, + { + self.cell.get() + } + + /// Runs `eff` with the locale set to `locale`, then restores the previous value however `eff` + /// ends. Fibers `eff` forks inherit `locale`. + pub fn locally(&self, locale: Locale, eff: Effect) -> Effect + where + A: Send + 'static, + E: Send + 'static, + R: Send + Sync + 'static, + { + self.cell.locally(locale, eff) + } +} + +/// The current fiber's locale, read from the [`CurrentLocale`] in the environment. +pub fn current_locale() -> Effect +where + E: Send + 'static, + R: Selector + Send + Sync + 'static, +{ + need::().flat_map(|current| current.get()) +} + +/// Runs `eff` with the [`CurrentLocale`] switched to `locale` (per request or per fiber); the +/// previous locale is restored when `eff` ends, and fibers `eff` forks inherit `locale`. +/// +/// `locale` should come from the catalog's supported locales (or a negotiation against them), not +/// straight from a header; an unsupported locale is harmless (lookups fall back to the default) +/// but is what `current_locale` reports. +pub fn with_locale(locale: Locale, eff: Effect) -> Effect +where + A: Send + 'static, + E: Send + 'static, + R: Selector + Send + Sync + 'static, +{ + need::().flat_map(move |current| current.locally(locale, eff)) +} + +/// Formats `key` with `args` in the current fiber's locale, from the [`Catalog`] in the +/// environment ([`Catalog::format`]: fallback chain, and the key itself when no locale has it). +/// +/// ``` +/// use id_effect::{Effect, Exit, HCons, HNil, Never, hlist, run_test}; +/// use id_effect_i18n::{Args, Catalog, CurrentLocale, Locale, translate, with_locale}; +/// +/// let en = Locale::parse("en").unwrap(); +/// let de = Locale::parse("de").unwrap(); +/// let catalog = Catalog::builder(en.clone()) +/// .embedded(en.clone(), "app.ftl", "items = { $n ->\n [one] one item\n *[other] { $n } items\n}") +/// .embedded(de.clone(), "app.ftl", "items = { $n ->\n [one] ein Artikel\n *[other] { $n } Artikel\n}") +/// .use_isolating(false) +/// .build() +/// .unwrap(); +/// +/// let eff: Effect>> = +/// with_locale(de, translate("items", Args::new().with("n", 3))); +/// let exit = run_test(eff.provide_all(hlist![catalog, CurrentLocale::new(en)])); +/// assert!(matches!(exit, Exit::Success(text) if text == "3 Artikel")); +/// ``` +pub fn translate(key: impl Into, args: Args) -> Effect +where + E: Send + 'static, + R: Selector + Selector + Send + Sync + 'static, +{ + let key = key.into(); + need::().flat_map(move |catalog| { + current_locale::().map(move |locale| catalog.format(&locale, &key, &args)) + }) +} diff --git a/crates/id_effect_i18n/src/lib.rs b/crates/id_effect_i18n/src/lib.rs index 6e0d2cac5..162d87862 100644 --- a/crates/id_effect_i18n/src/lib.rs +++ b/crates/id_effect_i18n/src/lib.rs @@ -1,16 +1,50 @@ -//! Internationalization for id_effect services. +//! Internationalization for id_effect services, over [Project Fluent](https://projectfluent.org) +//! (ADR 0010 Amendment 9, release spec 0.13.0 decision D5). //! -//! 0.13 builds this crate in steps (ADR 0010 Amendment 9, release spec 0.13.0 decision D5): Project -//! Fluent message catalogs with CLDR plural rules, a `CurrentLocale` capability switched per -//! request or fiber, `Accept-Language` negotiation and translated `Schema` errors. This first -//! release step provides [`Locale`], a validated language identifier. +//! - [`Locale`]: a validated, case-normalized language identifier (`de-AT`, `sr-Latn-RS`). +//! - [`Catalog`]: Fluent resources per locale, embedded with `include_str!` or loaded from +//! allowlisted directories, parsed once at startup; lookups fall back from the requested locale +//! to its language and then to the default locale; [`Catalog::check`] reports every key a +//! locale lacks. +//! - CLDR plural categories through Fluent selectors (`{ $n -> [one] … [few] … *[other] … }`), +//! with the rules of the locale a message comes from. +//! - [`CurrentLocale`]: the locale capability in the environment, switched per request or per +//! fiber with [`with_locale`]; [`translate`] formats a message in it. //! -//! ## Secure by default +//! ``` +//! use id_effect::{Effect, Exit, HCons, HNil, Never, hlist, run_test}; +//! use id_effect_i18n::{Args, Catalog, CurrentLocale, Locale, translate, with_locale}; +//! +//! let en = Locale::parse("en").unwrap(); +//! let pl = Locale::parse("pl").unwrap(); +//! let catalog = Catalog::builder(en.clone()) +//! .embedded(en.clone(), "app.ftl", "files = { $n ->\n [one] one file\n *[other] { $n } files\n}") +//! .embedded(pl.clone(), "app.ftl", "files = { $n -> +//! [one] jeden plik +//! [few] { $n } pliki +//! *[many] { $n } plików +//! }") +//! .use_isolating(false) +//! .build() +//! .unwrap(); +//! assert!(catalog.check().is_complete()); +//! +//! type Env = HCons>; +//! let eff: Effect = with_locale(pl, translate("files", Args::new().with("n", 3))); +//! let exit = run_test(eff.provide_all(hlist![catalog, CurrentLocale::new(en)])); +//! assert!(matches!(exit, Exit::Success(text) if text == "3 pliki")); +//! ``` +//! +//! ## Secure by default (ADR 0013 R4) //! //! A locale usually comes from untrusted input (an `Accept-Language` header, a query parameter, a -//! user setting). [`Locale::parse`] reads at most [`Locale::MAX_LEN`] bytes, accepts only ASCII -//! subtags of the documented shapes and normalizes their case, so a parsed locale can never carry a -//! path separator, a control character or an unbounded string into a catalog lookup. +//! user setting). [`Locale::parse`] reads at most [`Locale::MAX_LEN`] bytes and accepts only ASCII +//! subtags of the documented shapes, and catalogs are keyed by parsed locales: no locale string +//! from input ever reaches a file path. Resource files are read once, at startup, only below +//! directories the application allowlists ([`CatalogBuilder::allow_dir`]; `..` and symbolic +//! links are resolved first), only `.ftl` files of at most [`MAX_RESOURCE_BYTES`]; a parse error +//! fails [`CatalogBuilder::build`]. Message arguments are wrapped in Unicode bidi isolation marks +//! by default. //! //! ``` //! use id_effect_i18n::Locale; @@ -25,199 +59,22 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] -use std::fmt; +mod args; +mod catalog; +mod current; +mod locale; + +pub use args::{ArgValue, Args}; +pub use catalog::{ + Catalog, CatalogBuilder, CatalogError, CatalogReport, MAX_RESOURCE_BYTES, MissingKey, Translation, +}; +pub use current::{CurrentLocale, current_locale, translate, with_locale}; +pub use locale::{Locale, LocaleError}; +/// The Unicode language identifier of `unic-langid` (what Fluent and `fluent-langneg` use); +/// convert with `LanguageIdentifier::from(&locale)` and `Locale::try_from(&id)`. +pub use unic_langid::LanguageIdentifier; /// Compiles and runs the README quick start as a doctest. #[cfg(doctest)] #[doc = include_str!("../README.md")] struct ReadmeDoctests; - -/// A validated language identifier: `language[-Script][-REGION]`. -/// -/// The language subtag is 2–3 ASCII letters (stored lowercase), the optional script subtag 4 ASCII -/// letters (stored titlecase) and the optional region subtag 2 ASCII letters (stored uppercase) or -/// 3 ASCII digits. Subtags are separated by `-` or `_`; the canonical form uses `-`. -#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct Locale { - tag: String, - language_len: usize, - script: Option<(usize, usize)>, - region: Option<(usize, usize)>, -} - -/// Why [`Locale::parse`] rejected its input. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LocaleError { - /// The input was empty. - Empty, - /// The input was longer than [`Locale::MAX_LEN`] bytes. - TooLong, - /// The language subtag is not 2–3 ASCII letters. - InvalidLanguage, - /// A subtag after the language is neither a script (4 letters) nor a region (2 letters or 3 - /// digits), or appears out of order. - InvalidSubtag, -} - -impl fmt::Display for LocaleError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match self { - Self::Empty => "locale is empty", - Self::TooLong => "locale is longer than the limit", - Self::InvalidLanguage => "locale language subtag must be 2-3 ASCII letters", - Self::InvalidSubtag => "locale subtag is not a script or region", - }) - } -} - -impl std::error::Error for LocaleError {} - -impl Locale { - /// The longest input [`Locale::parse`] reads, in bytes. - pub const MAX_LEN: usize = 16; - - /// Parses and normalizes a language identifier such as `en`, `de-AT`, `sr_Latn_RS` or `es-419`. - /// - /// # Errors - /// - /// Returns a [`LocaleError`] when the input is empty, longer than [`Locale::MAX_LEN`] bytes, or - /// not of the shape `language[-Script][-REGION]`. - pub fn parse(input: &str) -> Result { - if input.is_empty() { - return Err(LocaleError::Empty); - } - if input.len() > Self::MAX_LEN { - return Err(LocaleError::TooLong); - } - let mut parts = input.split(['-', '_']); - let language = parts.next().unwrap_or_default(); - if !(2..=3).contains(&language.len()) || !language.bytes().all(|b| b.is_ascii_alphabetic()) { - return Err(LocaleError::InvalidLanguage); - } - let mut tag = language.to_ascii_lowercase(); - let language_len = tag.len(); - let mut script = None; - let mut region = None; - for part in parts { - let start = tag.len() + 1; - if script.is_none() && region.is_none() && is_script(part) { - tag.push('-'); - tag.push_str(&part[..1].to_ascii_uppercase()); - tag.push_str(&part[1..].to_ascii_lowercase()); - script = Some((start, tag.len())); - } else if region.is_none() && is_region(part) { - tag.push('-'); - tag.push_str(&part.to_ascii_uppercase()); - region = Some((start, tag.len())); - } else { - return Err(LocaleError::InvalidSubtag); - } - } - Ok(Self { - tag, - language_len, - script, - region, - }) - } - - /// The canonical form, for example `de-AT`. - pub fn as_str(&self) -> &str { - &self.tag - } - - /// The language subtag, lowercase (`de` for `de-AT`). - pub fn language(&self) -> &str { - &self.tag[..self.language_len] - } - - /// The script subtag, titlecase (`Latn` for `sr-Latn-RS`), if present. - pub fn script(&self) -> Option<&str> { - self.script.map(|(start, end)| &self.tag[start..end]) - } - - /// The region subtag, uppercase or digits (`AT` for `de-AT`, `419` for `es-419`), if present. - pub fn region(&self) -> Option<&str> { - self.region.map(|(start, end)| &self.tag[start..end]) - } - - /// The locale with only its language subtag (`de` for `de-AT`): the next step of a fallback - /// chain. - pub fn language_only(&self) -> Self { - Self { - tag: self.language().to_owned(), - language_len: self.language_len, - script: None, - region: None, - } - } -} - -impl fmt::Display for Locale { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.tag) - } -} - -impl std::str::FromStr for Locale { - type Err = LocaleError; - - fn from_str(s: &str) -> Result { - Self::parse(s) - } -} - -fn is_script(part: &str) -> bool { - part.len() == 4 && part.bytes().all(|b| b.is_ascii_alphabetic()) -} - -fn is_region(part: &str) -> bool { - (part.len() == 2 && part.bytes().all(|b| b.is_ascii_alphabetic())) - || (part.len() == 3 && part.bytes().all(|b| b.is_ascii_digit())) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn locale_parse_normalizes_case_and_separator() { - let locale = Locale::parse("SR_latn_rs").unwrap(); - assert_eq!(locale.as_str(), "sr-Latn-RS"); - assert_eq!(locale.language(), "sr"); - assert_eq!(locale.script(), Some("Latn")); - assert_eq!(locale.region(), Some("RS")); - assert_eq!(locale.to_string(), "sr-Latn-RS"); - assert_eq!(locale.language_only().as_str(), "sr"); - } - - #[test] - fn locale_parse_accepts_numeric_region_and_language_only() { - let es = "es-419".parse::().unwrap(); - assert_eq!(es.region(), Some("419")); - assert_eq!(es.script(), None); - let en = Locale::parse("en").unwrap(); - assert_eq!((en.script(), en.region()), (None, None)); - assert_eq!(en.language_only(), en); - } - - #[test] - fn r4_locale_parse_rejects_malformed_and_oversize_input() { - assert_eq!(Locale::parse(""), Err(LocaleError::Empty)); - assert_eq!(Locale::parse(&"a".repeat(17)), Err(LocaleError::TooLong)); - assert_eq!(Locale::parse("e"), Err(LocaleError::InvalidLanguage)); - assert_eq!(Locale::parse("e1"), Err(LocaleError::InvalidLanguage)); - assert_eq!(Locale::parse("../etc"), Err(LocaleError::InvalidLanguage)); - assert_eq!(Locale::parse("de-AT-Latn"), Err(LocaleError::InvalidSubtag)); - assert_eq!(Locale::parse("de-"), Err(LocaleError::InvalidSubtag)); - assert_eq!(Locale::parse("de-AT-DE"), Err(LocaleError::InvalidSubtag)); - for err in [ - LocaleError::Empty, - LocaleError::TooLong, - LocaleError::InvalidLanguage, - LocaleError::InvalidSubtag, - ] { - assert!(!err.to_string().is_empty()); - } - } -} diff --git a/crates/id_effect_i18n/src/locale.rs b/crates/id_effect_i18n/src/locale.rs new file mode 100644 index 000000000..8422b392c --- /dev/null +++ b/crates/id_effect_i18n/src/locale.rs @@ -0,0 +1,243 @@ +//! [`Locale`]: a validated, case-normalized language identifier. + +use std::fmt; + +use unic_langid::LanguageIdentifier; + +/// A validated language identifier: `language[-Script][-REGION]`. +/// +/// The language subtag is 2–3 ASCII letters (stored lowercase), the optional script subtag 4 ASCII +/// letters (stored titlecase) and the optional region subtag 2 ASCII letters (stored uppercase) or +/// 3 ASCII digits. Subtags are separated by `-` or `_`; the canonical form uses `-`. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Locale { + tag: String, + // Byte offsets into `tag`, which is at most `MAX_LEN` bytes long. + language_len: u8, + script: Option<(u8, u8)>, + region: Option<(u8, u8)>, +} + +/// The `start..end` subtag of `tag`. +fn subtag(tag: &str, (start, end): (u8, u8)) -> &str { + &tag[usize::from(start)..usize::from(end)] +} + +/// Why [`Locale::parse`] rejected its input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LocaleError { + /// The input was empty. + Empty, + /// The input was longer than [`Locale::MAX_LEN`] bytes. + TooLong, + /// The language subtag is not 2–3 ASCII letters. + InvalidLanguage, + /// A subtag after the language is neither a script (4 letters) nor a region (2 letters or 3 + /// digits), or appears out of order. + InvalidSubtag, +} + +impl fmt::Display for LocaleError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Empty => "locale is empty", + Self::TooLong => "locale is longer than the limit", + Self::InvalidLanguage => "locale language subtag must be 2-3 ASCII letters", + Self::InvalidSubtag => "locale subtag is not a script or region", + }) + } +} + +impl std::error::Error for LocaleError {} + +impl Locale { + /// The longest input [`Locale::parse`] reads, in bytes. + pub const MAX_LEN: usize = 16; + + /// Parses and normalizes a language identifier such as `en`, `de-AT`, `sr_Latn_RS` or `es-419`. + /// + /// # Errors + /// + /// Returns a [`LocaleError`] when the input is empty, longer than [`Locale::MAX_LEN`] bytes, or + /// not of the shape `language[-Script][-REGION]`. + pub fn parse(input: &str) -> Result { + if input.is_empty() { + return Err(LocaleError::Empty); + } + if input.len() > Self::MAX_LEN { + return Err(LocaleError::TooLong); + } + let mut parts = input.split(['-', '_']); + let language = parts.next().unwrap_or_default(); + if !(2..=3).contains(&language.len()) || !language.bytes().all(|b| b.is_ascii_alphabetic()) { + return Err(LocaleError::InvalidLanguage); + } + // The canonical tag is as long as the input (at most `MAX_LEN` bytes), so offsets fit a u8. + let mut tag = language.to_ascii_lowercase(); + let offset = |tag: &str| tag.len() as u8; + let language_len = offset(&tag); + let mut script = None; + let mut region = None; + for part in parts { + let start = offset(&tag) + 1; + if script.is_none() && region.is_none() && is_script(part) { + tag.push('-'); + tag.push_str(&part[..1].to_ascii_uppercase()); + tag.push_str(&part[1..].to_ascii_lowercase()); + script = Some((start, offset(&tag))); + } else if region.is_none() && is_region(part) { + tag.push('-'); + tag.push_str(&part.to_ascii_uppercase()); + region = Some((start, offset(&tag))); + } else { + return Err(LocaleError::InvalidSubtag); + } + } + Ok(Self { + tag, + language_len, + script, + region, + }) + } + + /// The canonical form, for example `de-AT`. + pub fn as_str(&self) -> &str { + &self.tag + } + + /// The language subtag, lowercase (`de` for `de-AT`). + pub fn language(&self) -> &str { + subtag(&self.tag, (0, self.language_len)) + } + + /// The script subtag, titlecase (`Latn` for `sr-Latn-RS`), if present. + pub fn script(&self) -> Option<&str> { + self.script.map(|range| subtag(&self.tag, range)) + } + + /// The region subtag, uppercase or digits (`AT` for `de-AT`, `419` for `es-419`), if present. + pub fn region(&self) -> Option<&str> { + self.region.map(|range| subtag(&self.tag, range)) + } + + /// The locale with only its language subtag (`de` for `de-AT`): the next step of a fallback + /// chain. + pub fn language_only(&self) -> Self { + Self { + tag: self.language().to_owned(), + language_len: self.language_len, + script: None, + region: None, + } + } + + /// Whether this locale is only a language subtag (`de`, not `de-AT`). + pub fn is_language_only(&self) -> bool { + self.script.is_none() && self.region.is_none() + } +} + +impl fmt::Display for Locale { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.tag) + } +} + +impl std::str::FromStr for Locale { + type Err = LocaleError; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +/// The Unicode language identifier Fluent and `fluent-langneg` work with. +impl From<&Locale> for LanguageIdentifier { + fn from(locale: &Locale) -> Self { + // Every `Locale` is a valid identifier (a subset of the BCP 47 shapes unic-langid accepts). + locale.as_str().parse().unwrap_or_default() + } +} + +/// A [`Locale`] from a Unicode language identifier; identifiers with variants +/// (`de-DE-1901`) or longer than [`Locale::MAX_LEN`] are rejected. +impl TryFrom<&LanguageIdentifier> for Locale { + type Error = LocaleError; + + fn try_from(id: &LanguageIdentifier) -> Result { + Self::parse(&id.to_string()) + } +} + +fn is_script(part: &str) -> bool { + part.len() == 4 && part.bytes().all(|b| b.is_ascii_alphabetic()) +} + +fn is_region(part: &str) -> bool { + (part.len() == 2 && part.bytes().all(|b| b.is_ascii_alphabetic())) + || (part.len() == 3 && part.bytes().all(|b| b.is_ascii_digit())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn locale_parse_normalizes_case_and_separator() { + let locale = Locale::parse("SR_latn_rs").unwrap(); + assert_eq!(locale.as_str(), "sr-Latn-RS"); + assert_eq!(locale.language(), "sr"); + assert_eq!(locale.script(), Some("Latn")); + assert_eq!(locale.region(), Some("RS")); + assert_eq!(locale.to_string(), "sr-Latn-RS"); + assert_eq!(locale.language_only().as_str(), "sr"); + assert!(!locale.is_language_only()); + assert!(locale.language_only().is_language_only()); + } + + #[test] + fn locale_parse_accepts_numeric_region_and_language_only() { + let es = "es-419".parse::().unwrap(); + assert_eq!(es.region(), Some("419")); + assert_eq!(es.script(), None); + let en = Locale::parse("en").unwrap(); + assert_eq!((en.script(), en.region()), (None, None)); + assert_eq!(en.language_only(), en); + } + + #[test] + fn r4_locale_parse_rejects_malformed_and_oversize_input() { + assert_eq!(Locale::parse(""), Err(LocaleError::Empty)); + assert_eq!(Locale::parse(&"a".repeat(17)), Err(LocaleError::TooLong)); + assert_eq!(Locale::parse("e"), Err(LocaleError::InvalidLanguage)); + assert_eq!(Locale::parse("e1"), Err(LocaleError::InvalidLanguage)); + assert_eq!(Locale::parse("../etc"), Err(LocaleError::InvalidLanguage)); + assert_eq!(Locale::parse("de-AT-Latn"), Err(LocaleError::InvalidSubtag)); + assert_eq!(Locale::parse("de-"), Err(LocaleError::InvalidSubtag)); + assert_eq!(Locale::parse("de-AT-DE"), Err(LocaleError::InvalidSubtag)); + for err in [ + LocaleError::Empty, + LocaleError::TooLong, + LocaleError::InvalidLanguage, + LocaleError::InvalidSubtag, + ] { + assert!(!err.to_string().is_empty()); + } + } + + #[test] + fn locale_converts_to_and_from_language_identifier() { + for tag in ["en", "de-AT", "sr-Latn-RS", "es-419"] { + let locale = Locale::parse(tag).unwrap(); + let id = LanguageIdentifier::from(&locale); + assert_eq!(id.to_string(), tag); + assert_eq!(Locale::try_from(&id), Ok(locale)); + } + let with_variant: LanguageIdentifier = "de-DE-1901".parse().unwrap(); + assert_eq!( + Locale::try_from(&with_variant), + Err(LocaleError::InvalidSubtag) + ); + } +} diff --git a/crates/id_effect_i18n/tests/catalog.rs b/crates/id_effect_i18n/tests/catalog.rs new file mode 100644 index 000000000..9d92ec1de --- /dev/null +++ b/crates/id_effect_i18n/tests/catalog.rs @@ -0,0 +1,519 @@ +//! `Catalog` acceptance tests (r13-e9-4-i18n-core): eco.i18n #1 (catalogs, fallback chain, +//! startup check) and #2 (CLDR plural categories through Fluent selectors), and the R4 loading +//! rules (allowlisted directories only, parse errors fail startup). + +use std::path::{Path, PathBuf}; + +use id_effect_i18n::{ + Args, Catalog, CatalogError, LanguageIdentifier, Locale, MAX_RESOURCE_BYTES, MissingKey, + Translation, +}; + +fn loc(tag: &str) -> Locale { + Locale::parse(tag).expect("test locale") +} + +fn fixtures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/locales") +} + +/// The fixture catalog (`tests/fixtures/locales`), default `en`, plain output. +fn fixture_catalog() -> Catalog { + Catalog::builder(loc("en")) + .allow_dir(fixtures()) + .and_then(|b| b.dir(fixtures())) + .expect("fixtures load") + .use_isolating(false) + .build() + .expect("fixtures parse") +} + +/// A fresh scratch directory under cargo's per-test-target temp dir. +fn scratch(name: &str) -> PathBuf { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")) + .join("id_effect_i18n") + .join(format!("{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("scratch dir"); + dir +} + +fn count(n: impl Into) -> Args { + Args::new().with("count", n) +} + +#[test] +fn plural_categories_en_de_pl_ar_ja() { + let catalog = fixture_catalog(); + let category = |tag: &str, n: f64| catalog.format(&loc(tag), "items", &count(n)); + let cases: &[(&str, &[(f64, &str)])] = &[ + ( + "en", + &[(0.0, "other"), (1.0, "one"), (2.0, "other"), (1.5, "other")], + ), + ( + "de", + &[ + (0.0, "other"), + (1.0, "one"), + (2.0, "other"), + (21.0, "other"), + ], + ), + ( + "pl", + &[ + (1.0, "one"), + (2.0, "few"), + (4.0, "few"), + (5.0, "many"), + (12.0, "many"), + (22.0, "few"), + (25.0, "many"), + (1.5, "other"), + ], + ), + ( + "ar", + &[ + (0.0, "zero"), + (1.0, "one"), + (2.0, "two"), + (3.0, "few"), + (10.0, "few"), + (11.0, "many"), + (99.0, "many"), + (100.0, "other"), + (102.0, "other"), + ], + ), + ("ja", &[(0.0, "other"), (1.0, "other"), (2.0, "other")]), + ]; + for (tag, expected) in cases { + for (n, want) in *expected { + assert_eq!(category(tag, *n), *want, "{tag} {n}"); + } + } + // Integer arguments select the same way, and the selected variant interpolates the number. + assert_eq!(catalog.format(&loc("en"), "files", &count(1)), "1 file"); + assert_eq!( + catalog.format(&loc("en"), "files", &count(7_u64)), + "7 files" + ); + assert_eq!(catalog.format(&loc("de"), "files", &count(1)), "1 Datei"); + assert_eq!(catalog.format(&loc("pl"), "files", &count(3)), "3 pliki"); + assert_eq!(catalog.format(&loc("pl"), "files", &count(5)), "5 plików"); + // A regional locale uses its language's rules: de-AT has no `items`, de's selects. + assert_eq!(catalog.format(&loc("de-AT"), "items", &count(1)), "one"); +} + +#[test] +fn missing_key_falls_back_to_default_locale() { + let catalog = fixture_catalog(); + let name = Args::new().with("name", "Ada"); + let de_at = loc("de-AT"); + + // requested → language → default + assert_eq!( + catalog.fallback_chain(&de_at), + [&loc("de-AT"), &loc("de"), &loc("en")] + ); + assert_eq!( + catalog.lookup(&de_at, "greeting", &name), + Some(Translation { + text: "Servus, Ada!".into(), + locale: loc("de-AT"), + }) + ); + let from_de = catalog + .lookup(&de_at, "files", &count(2)) + .expect("de has files"); + assert_eq!( + (from_de.text.as_str(), from_de.locale), + ("2 Dateien", loc("de")) + ); + let from_en = catalog + .lookup(&de_at, "farewell", &Args::new()) + .expect("en has farewell"); + assert_eq!( + (from_en.text.as_str(), from_en.locale), + ("Goodbye from Pipeline.", loc("en")) + ); + + // Attributes fall back separately from their message. + assert_eq!(catalog.format(&loc("de"), "form", &Args::new()), "Formular"); + assert_eq!( + catalog.format(&loc("de"), "form.title", &Args::new()), + "Order form" + ); + assert_eq!( + catalog.format(&loc("de"), "form.nope", &Args::new()), + "form.nope" + ); + + // A locale the catalog does not have starts the chain at its language or the default. + assert_eq!(catalog.fallback_chain(&loc("fr-CA")), [&loc("en")]); + assert_eq!( + catalog.format(&loc("fr-CA"), "greeting", &name), + "Hello, Ada!" + ); + assert_eq!(catalog.fallback_chain(&loc("en")), [&loc("en")]); + + // A key no locale has: `lookup` says so, `format` shows the key. + assert_eq!(catalog.lookup(&de_at, "nope", &Args::new()), None); + assert_eq!(catalog.format(&de_at, "nope", &Args::new()), "nope"); + // A message without a value (attributes only) has no text of its own. + let attrs_only = Catalog::builder(loc("en")) + .source(loc("en"), "inline", "menu =\n .label = Menu\n") + .build() + .expect("parses"); + assert_eq!(attrs_only.lookup(&loc("en"), "menu", &Args::new()), None); + assert_eq!( + attrs_only.format(&loc("en"), "menu.label", &Args::new()), + "Menu" + ); + // A missing argument renders as Fluent's placeholder instead of failing. + assert_eq!( + catalog.format(&loc("en"), "greeting", &Args::new()), + "Hello, {$name}!" + ); +} + +#[test] +fn catalog_check_reports_missing_keys() { + let catalog = fixture_catalog(); + let report = catalog.check(); + assert!(!report.is_complete()); + let missing = |tag: &str| { + let locale = loc(tag); + report + .missing_in(&locale) + .map(str::to_owned) + .collect::>() + }; + assert_eq!(missing("de"), ["farewell", "form.title"]); + // de-AT is covered by de where de has the key; what de lacks, de-AT lacks too. + assert_eq!(missing("de-AT"), ["farewell", "form.title"]); + assert_eq!( + missing("pl"), + ["farewell", "form", "form.title", "greeting"] + ); + assert_eq!( + missing("ar"), + ["farewell", "files", "form", "form.title", "greeting"] + ); + assert_eq!( + missing("ja"), + ["farewell", "files", "form", "form.title", "greeting"] + ); + assert!( + missing("en").is_empty(), + "the default locale is the reference" + ); + assert_eq!(report.missing().len(), 18); + assert_eq!( + report.missing()[0], + MissingKey { + locale: loc("ar"), + key: "farewell".into(), + } + ); + assert_eq!(report.missing()[0].to_string(), "ar: farewell"); + let text = report.to_string(); + assert!( + text.starts_with("18 missing key(s): ar: farewell;"), + "{text}" + ); + + // `require_complete` turns the report into a startup failure. + let strict = Catalog::builder(loc("en")) + .allow_dir(fixtures()) + .and_then(|b| b.dir(fixtures())) + .expect("fixtures load") + .require_complete() + .build(); + match strict { + Err(CatalogError::Incomplete(r)) => assert_eq!(r, report), + other => panic!("expected Incomplete, got {other:?}"), + } + + // A complete catalog passes both. + let complete = Catalog::builder(loc("en")) + .embedded(loc("en"), "en.ftl", "a = A\nb = B\n .x = X") + .embedded(loc("de"), "de.ftl", "a = A\nb = B\n .x = X") + .embedded(loc("de-CH"), "de-CH.ftl", "a = A") + .require_complete() + .build() + .expect("complete"); + let report = complete.check(); + assert!(report.is_complete()); + assert!(report.missing().is_empty()); + assert_eq!( + report.to_string(), + "every locale has every key of the default locale" + ); + assert!(complete.has_key(&loc("de"), "b.x")); + assert!(!complete.has_key(&loc("de-CH"), "b.x")); + assert!(!complete.has_key(&loc("fr"), "a")); +} + +#[test] +fn catalog_rejects_path_outside_allowlist() { + let root = scratch("allowlist"); + let allowed = root.join("locales"); + let outside = root.join("secrets"); + std::fs::create_dir_all(allowed.join("en")).expect("mkdir"); + std::fs::create_dir_all(&outside).expect("mkdir"); + std::fs::write(allowed.join("en/app.ftl"), "hello = Hello").expect("write"); + std::fs::write(outside.join("leak.ftl"), "secret = s3cr3t").expect("write"); + + let builder = || { + Catalog::builder(loc("en")) + .allow_dir(&allowed) + .expect("allow") + }; + let refused = |r: Result<_, CatalogError>| match r { + Err(CatalogError::PathOutsideAllowlist { path }) => path, + Err(other) => panic!("expected PathOutsideAllowlist, got {other}"), + Ok(_) => panic!("expected PathOutsideAllowlist, got a builder"), + }; + + // A file outside every allowlisted directory, directly or through `..`. + let direct = outside.join("leak.ftl"); + assert_eq!(refused(builder().file(loc("en"), &direct)), direct); + let dotdot = allowed.join("../secrets/leak.ftl"); + assert_eq!(refused(builder().file(loc("en"), &dotdot)), dotdot); + // A directory outside. + assert_eq!(refused(builder().dir(&outside)), outside); + // Nothing allowlisted: every path is refused, even an existing catalog file. + let inside = allowed.join("en/app.ftl"); + assert_eq!( + refused(Catalog::builder(loc("en")).file(loc("en"), &inside)), + inside + ); + + // A symbolic link inside the allowlisted directory that points outside it. + #[cfg(unix)] + { + std::os::unix::fs::symlink(&direct, allowed.join("en/link.ftl")).expect("symlink"); + assert!(matches!( + builder().dir(&allowed), + Err(CatalogError::PathOutsideAllowlist { .. }) + )); + std::fs::remove_file(allowed.join("en/link.ftl")).expect("unlink"); + } + + // The allowlisted file itself loads. + let catalog = builder() + .file(loc("en"), &inside) + .expect("inside") + .build() + .expect("parses"); + assert_eq!(catalog.format(&loc("en"), "hello", &Args::new()), "Hello"); + let error = CatalogError::PathOutsideAllowlist { path: direct }; + assert!(error.to_string().contains("outside the allowlisted")); +} + +#[test] +fn catalog_build_fails_on_parse_error_conflict_and_missing_default() { + let parse = Catalog::builder(loc("en")) + .embedded(loc("en"), "broken.ftl", "ok = fine\nbroken = { $\n") + .build(); + match parse { + Err(CatalogError::Parse { + locale, + origin, + errors, + }) => { + assert_eq!((locale, origin.as_str()), (loc("en"), "broken.ftl")); + assert!(!errors.is_empty()); + } + other => panic!("expected Parse, got {other:?}"), + } + + let conflict = Catalog::builder(loc("en")) + .embedded(loc("en"), "a.ftl", "hello = A") + .embedded(loc("en"), "b.ftl", "hello = B") + .build(); + match conflict { + Err(CatalogError::Conflict { + locale, + origin, + errors, + }) => { + assert_eq!((locale, origin.as_str()), (loc("en"), "b.ftl")); + assert_eq!(errors.len(), 1); + } + other => panic!("expected Conflict, got {other:?}"), + } + + let no_default = Catalog::builder(loc("en")) + .embedded(loc("de"), "de.ftl", "hello = Hallo") + .build(); + assert_eq!( + no_default.map(|_| ()), + Err(CatalogError::MissingDefault { locale: loc("en") }) + ); +} + +#[test] +fn catalog_refuses_unreadable_oversize_and_foreign_files() { + let root = scratch("files"); + std::fs::create_dir_all(root.join("en")).expect("mkdir"); + let builder = || Catalog::builder(loc("en")).allow_dir(&root).expect("allow"); + + // Missing allowlist directory and missing file: Io, naming the path as given. + match Catalog::builder(loc("en")).allow_dir(root.join("nope")) { + Err(CatalogError::Io { path, message }) => { + assert_eq!(path, root.join("nope")); + assert!(!message.is_empty()); + } + other => panic!("expected Io, got {other:?}"), + } + assert!(matches!( + builder().file(loc("en"), root.join("en/missing.ftl")), + Err(CatalogError::Io { .. }) + )); + + // Wrong extension. + std::fs::write(root.join("en/app.txt"), "hello = Hello").expect("write"); + assert_eq!( + builder() + .file(loc("en"), root.join("en/app.txt")) + .map(|_| ()), + Err(CatalogError::NotFluentFile { + path: root.join("en/app.txt"), + }) + ); + + // Larger than the limit. + let big = root.join("en/big.ftl"); + let line = "k = v\n"; + let lines = usize::try_from(MAX_RESOURCE_BYTES).expect("fits") / line.len() + 1; + std::fs::write(&big, line.repeat(lines)).expect("write"); + assert_eq!( + builder().file(loc("en"), &big).map(|_| ()), + Err(CatalogError::TooLarge { + path: big.clone(), + limit: MAX_RESOURCE_BYTES, + }) + ); + std::fs::remove_file(&big).expect("rm"); + + // Not UTF-8. + let latin1 = root.join("en/latin1.ftl"); + std::fs::write(&latin1, b"hello = gr\xfc\xdf").expect("write"); + assert!(matches!( + builder().file(loc("en"), &latin1), + Err(CatalogError::Io { .. }) + )); + std::fs::remove_file(&latin1).expect("rm"); + + // A directory with the .ftl extension cannot be read as a resource. + std::fs::create_dir_all(root.join("en/dir.ftl")).expect("mkdir"); + assert!(matches!( + builder().file(loc("en"), root.join("en/dir.ftl")), + Err(CatalogError::Io { .. }) + )); + std::fs::remove_dir(root.join("en/dir.ftl")).expect("rmdir"); + + // A subdirectory that is not a locale. + std::fs::create_dir_all(root.join("not a locale")).expect("mkdir"); + assert_eq!( + builder().dir(&root).map(|_| ()), + Err(CatalogError::InvalidLocaleDir { + path: std::fs::canonicalize(&root) + .expect("canon") + .join("not a locale"), + }) + ); + + // An allowlisted path that is a file, not a directory, cannot be listed. + std::fs::write(root.join("en/app.ftl"), "hello = Hello").expect("write"); + assert!(matches!( + builder().dir(root.join("en/app.ftl")), + Err(CatalogError::Io { .. }) + )); +} + +#[test] +fn catalog_errors_display_their_cause() { + let errors = [ + CatalogError::Io { + path: "a.ftl".into(), + message: "denied".into(), + }, + CatalogError::PathOutsideAllowlist { + path: "/etc/x.ftl".into(), + }, + CatalogError::NotFluentFile { + path: "a.txt".into(), + }, + CatalogError::TooLarge { + path: "big.ftl".into(), + limit: 9, + }, + CatalogError::InvalidLocaleDir { path: "x y".into() }, + CatalogError::Parse { + locale: loc("en"), + origin: "a.ftl".into(), + errors: vec!["e1".into(), "e2".into()], + }, + CatalogError::Conflict { + locale: loc("en"), + origin: "b.ftl".into(), + errors: vec!["dup".into()], + }, + CatalogError::MissingDefault { locale: loc("en") }, + CatalogError::Incomplete(Default::default()), + ]; + let expected = [ + "cannot read a.ftl: denied", + "/etc/x.ftl is outside the allowlisted catalog directories", + "a.txt is not a .ftl file", + "big.ftl is larger than 9 bytes", + "x y is not named after a locale", + "Fluent syntax error in a.ftl (en): e1; e2", + "b.ftl (en) redefines messages: dup", + "the default locale en has no resource", + "incomplete catalog: every locale has every key of the default locale", + ]; + for (error, want) in errors.iter().zip(expected) { + assert_eq!(error.to_string(), want); + let _: &dyn std::error::Error = error; + } +} + +#[test] +fn catalog_isolates_arguments_by_default_and_reports_its_locales() { + let catalog = Catalog::builder(loc("en")) + .embedded(loc("en"), "en.ftl", "hello = Hello, { $name }!") + .embedded(loc("ar"), "ar.ftl", "hello = مرحبا { $name }") + .build() + .expect("parses"); + let args = Args::new().with("name", "Ada"); + assert_eq!( + catalog.format(&loc("en"), "hello", &args), + "Hello, \u{2068}Ada\u{2069}!" + ); + assert_eq!(catalog.default_locale(), &loc("en")); + assert_eq!( + catalog.locales().collect::>(), + [&loc("ar"), &loc("en")] + ); + assert!(catalog.supports(&loc("ar"))); + assert!(!catalog.supports(&loc("ar-EG"))); + let debug = format!("{catalog:?}"); + assert!(debug.contains("Catalog") && debug.contains("ar"), "{debug}"); + let id = LanguageIdentifier::from(&loc("ar")); + assert_eq!(id.language.as_str(), "ar"); + + // Shared across fibers and threads. + fn send_sync(_: &T) {} + send_sync(&catalog); + let clone = catalog.clone(); + let text = std::thread::spawn(move || clone.format(&loc("ar"), "hello", &Args::new())) + .join() + .expect("thread"); + assert!(text.starts_with("مرحبا")); +} diff --git a/crates/id_effect_i18n/tests/current.rs b/crates/id_effect_i18n/tests/current.rs new file mode 100644 index 000000000..e5a6c7c29 --- /dev/null +++ b/crates/id_effect_i18n/tests/current.rs @@ -0,0 +1,118 @@ +//! `CurrentLocale` acceptance tests (r13-e9-4-i18n-core): eco.i18n #7, the locale capability in +//! the environment and `with_locale` switching per request or fiber. + +use id_effect::{Effect, Exit, HCons, HNil, fail, hlist, run_test, yield_fiber, zip_par}; +use id_effect_i18n::{ + Args, Catalog, CurrentLocale, Locale, current_locale, translate, with_locale, +}; + +type Env = HCons>; +type Eff = Effect; + +fn loc(tag: &str) -> Locale { + Locale::parse(tag).expect("test locale") +} + +fn env() -> Env { + let catalog = Catalog::builder(loc("en")) + .embedded(loc("en"), "en.ftl", "greeting = Hello, { $name }!") + .embedded(loc("de"), "de.ftl", "greeting = Hallo, { $name }!") + .embedded(loc("pl"), "pl.ftl", "greeting = Cześć, { $name }!") + .use_isolating(false) + .build() + .expect("catalog"); + hlist![catalog, CurrentLocale::new(loc("en"))] +} + +fn run(eff: Eff) -> Exit { + run_test(eff.provide_all(env())) +} + +/// One "request": greet, yield to other fibers, then report the locale it still sees. +fn request(tag: &'static str) -> Eff<(String, Locale)> { + with_locale( + loc(tag), + yield_fiber() + .then(translate("greeting", Args::new().with("name", "Ada"))) + .flat_map(|text| { + yield_fiber() + .then(current_locale()) + .map(move |locale| (text, locale)) + }), + ) +} + +#[test] +fn with_locale_switches_per_fiber() { + // Two concurrent requests interleave on the executor; each keeps its own locale. + let both: Eff<_> = zip_par(request("de"), request("pl")) + .flat_map(|pair| current_locale().map(move |after| (pair, after))); + match run(both) { + Exit::Success((((de_text, de), (pl_text, pl)), after)) => { + assert_eq!((de_text.as_str(), de), ("Hallo, Ada!", loc("de"))); + assert_eq!((pl_text.as_str(), pl), ("Cześć, Ada!", loc("pl"))); + assert_eq!(after, loc("en"), "the parent fiber keeps the default"); + } + other => panic!("{other:?}"), + } + + // A fiber forked inside `with_locale` inherits the locale; its own switch stays its own. + let nested: Eff<_> = with_locale( + loc("de"), + current_locale() + .fork() + .widen() + .flat_map(|inherited| inherited.join()) + .flat_map(|inherited| { + with_locale(loc("pl"), current_locale()) + .fork() + .widen() + .flat_map(|h| h.join()) + .flat_map(move |child| current_locale().map(move |own| (inherited, child, own))) + }), + ); + match run(nested) { + Exit::Success((inherited, child, own)) => { + assert_eq!(inherited, loc("de")); + assert_eq!(child, loc("pl")); + assert_eq!(own, loc("de")); + } + other => panic!("{other:?}"), + } + + // The previous locale is restored however the effect ends. + let failed: Eff<_> = with_locale(loc("de"), fail::<(), _, _>("boom")) + .exit() + .widen() + .flat_map(|exit| current_locale().map(move |after| (exit, after))); + match run(failed) { + Exit::Success((Exit::Failure(_), after)) => assert_eq!(after, loc("en")), + other => panic!("{other:?}"), + } +} + +#[test] +fn current_locale_defaults_and_translates_in_the_default() { + let eff: Eff<_> = current_locale().zip(translate("greeting", Args::new().with("name", "Bo"))); + match run(eff) { + Exit::Success((locale, text)) => { + assert_eq!(locale, loc("en")); + assert_eq!(text, "Hello, Bo!"); + } + other => panic!("{other:?}"), + } + + // The capability's own methods, without the environment. + let current = CurrentLocale::new(loc("de")); + assert_eq!(current.default_locale(), &loc("de")); + let pair: Effect<(Locale, Locale), &'static str> = + current.locally(loc("pl"), current.get()).zip(current.get()); + match run_test(pair) { + Exit::Success((inside, after)) => { + assert_eq!(inside, loc("pl")); + assert_eq!(after, loc("de")); + } + other => panic!("{other:?}"), + } + assert!(format!("{current:?}").contains("CurrentLocale")); +} diff --git a/crates/id_effect_i18n/tests/fixtures/locales/README.md b/crates/id_effect_i18n/tests/fixtures/locales/README.md new file mode 100644 index 000000000..805968573 --- /dev/null +++ b/crates/id_effect_i18n/tests/fixtures/locales/README.md @@ -0,0 +1,4 @@ +# Test catalog + +One directory per locale, `*.ftl` files inside (`CatalogBuilder::dir`). Files directly in this +directory, like this one, are ignored. diff --git a/crates/id_effect_i18n/tests/fixtures/locales/ar/plurals.ftl b/crates/id_effect_i18n/tests/fixtures/locales/ar/plurals.ftl new file mode 100644 index 000000000..4d15a83a3 --- /dev/null +++ b/crates/id_effect_i18n/tests/fixtures/locales/ar/plurals.ftl @@ -0,0 +1,8 @@ +items = { $count -> + [zero] zero + [one] one + [two] two + [few] few + [many] many + *[other] other +} diff --git a/crates/id_effect_i18n/tests/fixtures/locales/de-AT/app.ftl b/crates/id_effect_i18n/tests/fixtures/locales/de-AT/app.ftl new file mode 100644 index 000000000..68791ecc6 --- /dev/null +++ b/crates/id_effect_i18n/tests/fixtures/locales/de-AT/app.ftl @@ -0,0 +1,2 @@ +# Regional override: everything else comes from de, then en. +greeting = Servus, { $name }! diff --git a/crates/id_effect_i18n/tests/fixtures/locales/de/app.ftl b/crates/id_effect_i18n/tests/fixtures/locales/de/app.ftl new file mode 100644 index 000000000..b2bb1fd98 --- /dev/null +++ b/crates/id_effect_i18n/tests/fixtures/locales/de/app.ftl @@ -0,0 +1,2 @@ +greeting = Hallo, { $name }! +form = Formular diff --git a/crates/id_effect_i18n/tests/fixtures/locales/de/plurals.ftl b/crates/id_effect_i18n/tests/fixtures/locales/de/plurals.ftl new file mode 100644 index 000000000..cea9fa01f --- /dev/null +++ b/crates/id_effect_i18n/tests/fixtures/locales/de/plurals.ftl @@ -0,0 +1,8 @@ +items = { $count -> + [one] one + *[other] other +} +files = { $count -> + [one] { $count } Datei + *[other] { $count } Dateien +} diff --git a/crates/id_effect_i18n/tests/fixtures/locales/en/app.ftl b/crates/id_effect_i18n/tests/fixtures/locales/en/app.ftl new file mode 100644 index 000000000..14eadb3e1 --- /dev/null +++ b/crates/id_effect_i18n/tests/fixtures/locales/en/app.ftl @@ -0,0 +1,6 @@ +# Default locale: every key the other locales are checked against. +-brand = Pipeline +greeting = Hello, { $name }! +farewell = Goodbye from { -brand }. +form = Form + .title = Order form diff --git a/crates/id_effect_i18n/tests/fixtures/locales/en/notes.txt b/crates/id_effect_i18n/tests/fixtures/locales/en/notes.txt new file mode 100644 index 000000000..33d05b03e --- /dev/null +++ b/crates/id_effect_i18n/tests/fixtures/locales/en/notes.txt @@ -0,0 +1 @@ +Not a Fluent file: CatalogBuilder::dir skips it. diff --git a/crates/id_effect_i18n/tests/fixtures/locales/en/plurals.ftl b/crates/id_effect_i18n/tests/fixtures/locales/en/plurals.ftl new file mode 100644 index 000000000..c3cfbadf8 --- /dev/null +++ b/crates/id_effect_i18n/tests/fixtures/locales/en/plurals.ftl @@ -0,0 +1,9 @@ +# The CLDR plural category Fluent selects, by name. +items = { $count -> + [one] one + *[other] other +} +files = { $count -> + [one] { $count } file + *[other] { $count } files +} diff --git a/crates/id_effect_i18n/tests/fixtures/locales/ja/plurals.ftl b/crates/id_effect_i18n/tests/fixtures/locales/ja/plurals.ftl new file mode 100644 index 000000000..bedb8a2e4 --- /dev/null +++ b/crates/id_effect_i18n/tests/fixtures/locales/ja/plurals.ftl @@ -0,0 +1,4 @@ +# Japanese has a single plural category. +items = { $count -> + *[other] other +} diff --git a/crates/id_effect_i18n/tests/fixtures/locales/pl/plurals.ftl b/crates/id_effect_i18n/tests/fixtures/locales/pl/plurals.ftl new file mode 100644 index 000000000..4cbd659c6 --- /dev/null +++ b/crates/id_effect_i18n/tests/fixtures/locales/pl/plurals.ftl @@ -0,0 +1,11 @@ +items = { $count -> + [one] one + [few] few + [many] many + *[other] other +} +files = { $count -> + [one] { $count } plik + [few] { $count } pliki + *[many] { $count } plików +} diff --git a/examples/book-snippets/Cargo.toml b/examples/book-snippets/Cargo.toml index b9d2b0a53..6cbcca468 100644 --- a/examples/book-snippets/Cargo.toml +++ b/examples/book-snippets/Cargo.toml @@ -51,6 +51,7 @@ id_effect_sql = { path = "../../crates/id_effect_sql", version = "0.12.0", featu "audit", ] } id_effect_fsm = { path = "../../crates/id_effect_fsm", version = "0.12.0" } +id_effect_i18n = { path = "../../crates/id_effect_i18n", version = "0.12.0" } id_effect_kit = { path = "../../crates/id_effect_kit", version = "0.12.0", features = [ "full", ] } diff --git a/examples/book-snippets/src/part6.rs b/examples/book-snippets/src/part6.rs index 3aa177fa6..485315f94 100644 --- a/examples/book-snippets/src/part6.rs +++ b/examples/book-snippets/src/part6.rs @@ -104,6 +104,10 @@ struct Ch3006Totp; #[doc = include_str!("../../../crates/id_effect/book/src/part6/ch30-07-audit-tenancy.md")] struct Ch3007AuditTenancy; +#[cfg(doctest)] +#[doc = include_str!("../../../crates/id_effect/book/src/part6/ch30-09-i18n.md")] +struct Ch3009I18n; + #[cfg(doctest)] #[doc = include_str!("../../../crates/id_effect/book/src/part6/ch27-01-metrics.md")] struct Ch2701Metrics; From f20d90fe1927686554bd4859b9a6eb5183abcbfc Mon Sep 17 00:00:00 2001 From: Tom Wieland Date: Wed, 21 Oct 2026 08:26:15 +0200 Subject: [PATCH 11/87] fix(stream): live unfold_effect streams deliver partial chunks instead of stalling (r13-f-stream-chunking) F57: a Stream::unfold_effect pull waits for its first element only, then keeps the steps that complete without waiting (ready values, synchronous steps, futures ready on their first poll) and stops at the first step that would wait, which the next pull resumes. run_for_each on a live client stream sees each element as it arrives; pure and bounded sources keep full chunks. Pull path only, no signature change. Tests: crates/id_effect/tests/stream_live_chunks.rs. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Wieland --- CHANGELOG.md | 8 + crates/id_effect/src/streaming/stream.rs | 223 +++++++- crates/id_effect/tests/stream_live_chunks.rs | 531 +++++++++++++++++++ 3 files changed, 748 insertions(+), 14 deletions(-) create mode 100644 crates/id_effect/tests/stream_live_chunks.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f1d5ca2b..65da23b07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,14 @@ 0.14 (the version instant-acme already brought), so `cargo tree -d` lists rcgen once and the `yasna` 0.5 copy rcgen 0.13 pulled is gone from `Cargo.lock`. No public signature exposes rcgen types; certificates keep ECDSA P-256 and the `id-effect dev` leaf keeps its 13-day lifetime. +- Live streams deliver partial chunks (r13-f-stream-chunking, 0.12 follow-up F57): + `Stream::unfold_effect` waits for the first element of a pull only, then keeps the steps that + complete without waiting (ready values, synchronous steps, futures ready on their first poll) + and stops at the first step that would wait, which the next pull resumes. `run_for_each` on a + live source (an RPC client or server stream, a subscription, a queue) now sees each element as + it arrives instead of stalling until 64 have; pure and bounded sources keep full chunks. No + signature changes; backpressure is unchanged (a pull starts no step beyond the one it stops + on, and the generator runs only while the consumer pulls). ### Documentation diff --git a/crates/id_effect/src/streaming/stream.rs b/crates/id_effect/src/streaming/stream.rs index 3088e65db..28b9dfaf5 100644 --- a/crates/id_effect/src/streaming/stream.rs +++ b/crates/id_effect/src/streaming/stream.rs @@ -12,15 +12,21 @@ use std::collections::VecDeque; use std::fmt; +use std::future::poll_fn; use std::ops::ControlFlow; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::task::{Context, Poll}; use crate::capability::HNil; use crate::concurrency::{Concurrency, fork_all}; use crate::coordination::{Queue, QueueError}; -use crate::failure::Never; -use crate::kernel::{Effect, fail, succeed, suspend, yield_fiber}; +use crate::failure::{Cause, Never}; +use crate::kernel::{ + AnyEnv, Effect, Instr, Node, erase_exit, fail, settle_instr, succeed, suspend, unerase_exit, + yield_fiber, +}; +use crate::runtime::FiberContext; use crate::streaming::chunk::Chunk; /// The chunk size a consumer asks for when it does not choose one ([`Stream::next_chunk`] and @@ -212,30 +218,94 @@ where /// Expands `init` with the effectful generator `f` until it returns `None`. Each pull runs `f` /// at most `max` times, one after the other. - pub fn unfold_effect(init: S, f: F) -> Self + /// + /// A pull waits for its **first** element only. After that it keeps the elements whose steps + /// complete without waiting, and stops at the first step that would wait (a timer, I/O, an + /// empty queue, any step that needs the fiber), handing over what it has; the next pull + /// resumes that step where it stopped. So a generator whose steps are always ready (pure, + /// synchronous or already-complete futures) fills whole chunks, and a live one (a socket, a + /// subscription, a client stream) delivers each element as it arrives instead of stalling + /// until `max` of them have. A step that fails without waiting fails the pull. + /// + /// ``` + /// use id_effect::{HNil, Never, Queue, Stream, succeed}; + /// + /// let q = Queue::::unbounded(); + /// let _ = q.try_offer_all::([1, 2, 3]).block_on(); + /// // The queue stays open, so a fourth `take` would wait: the pull hands over the three + /// // elements that are there instead of waiting for 64. + /// let take = q.clone(); + /// let live = Stream::::unfold_effect((), move |()| { + /// take.take().map(|n| Some((n, ()))).catch(|_| succeed(None)) + /// }); + /// let (chunk, _rest) = live.next_chunk().block_on().into_result().ok().flatten().unwrap(); + /// assert_eq!(chunk.into_vec(), vec![1, 2, 3]); + /// ``` + pub fn unfold_effect(init: S, mut f: F) -> Self where S: Send + 'static, F: FnMut(S) -> Effect, E, R> + Send + 'static, { - Self::from_step(move |n| Self::unfold_gather(init, f, n, Vec::new())) + Self::from_step(move |n| { + let first = f(init); + Self::unfold_pull(first, f, n) + }) } - fn unfold_gather(s: S, mut f: F, n: usize, mut out: Vec) -> Effect, E, R> + /// The rest of an [`unfold_effect`](Self::unfold_effect) stream whose next step `next` is + /// already built (and possibly started: a pull stopped on it because it would wait). + fn unfold_resume(next: Effect, E, R>, f: F) -> Self where S: Send + 'static, F: FnMut(S) -> Effect, E, R> + Send + 'static, { - f(s).flat_map(move |next| match next { - None => succeed((!out.is_empty()).then(|| (out, Self::empty()))), - Some((a, s)) => { - out.push(a); - if out.len() >= n { - succeed(Some((out, Self::unfold_effect(s, f)))) - } else { - Self::unfold_gather(s, f, n, out) + Self::from_step(move |n| Self::unfold_pull(next, f, n)) + } + + /// One `unfold_effect` pull of at most `n` elements: waits for `next`, then gathers the steps + /// that are ready without waiting. + fn unfold_pull( + next: Effect, E, R>, + f: F, + n: usize, + ) -> Effect, E, R> + where + S: Send + 'static, + F: FnMut(S) -> Effect, E, R> + Send + 'static, + { + next.flat_map(move |next| match next { + None => succeed(None), + Some((a, s)) => Self::unfold_gather(s, f, n, vec![a]), + }) + } + + /// Adds the generator's ready steps to `out` (non-empty) until it holds `n` elements, the + /// generator ends, or a step would wait. Ready values are taken here without any allocation; + /// the first step that is not a ready value moves the gathering into [`unfold_probe`]. + fn unfold_gather( + mut s: S, + mut f: F, + n: usize, + mut out: Vec, + ) -> Effect, E, R> + where + S: Send + 'static, + F: FnMut(S) -> Effect, E, R> + Send + 'static, + { + loop { + if out.len() >= n { + return succeed(Some((out, Self::unfold_effect(s, f)))); + } + match f(s).node { + Node::Pure(Ok(Some((a, next)))) => { + out.push(a); + s = next; } + Node::Pure(Ok(None)) => return succeed(Some((out, Self::empty()))), + Node::Pure(Err(c)) => return Effect::pure(Err(c)), + node => return unfold_probe(Effect { node }, f, n, out), } - }) + } } /// An incremental source driven by a pull function: each pull runs `f(max)` once; `Ok(None)` @@ -828,6 +898,131 @@ where }) } +/// The gathering part of an [`Stream::unfold_effect`] pull once a step is not a ready value: +/// one never-waiting async step that runs `next`, and the generator's following steps, as far +/// as each goes without waiting (see [`run_ready`]), on the pulling fiber's environment and +/// waker. The first step that would wait ends the pull with `out`, and the rest of the stream +/// resumes that step. +fn unfold_probe( + next: Effect, E, R>, + f: F, + n: usize, + out: Vec, +) -> Effect, E, R> +where + A: Send + 'static, + S: Send + 'static, + E: Send + 'static, + R: Send + Sync + 'static, + F: FnMut(S) -> Effect, E, R> + Send + 'static, +{ + Effect::from_instr(Instr::Async(Box::new(move |env, fiber| { + let env = Arc::clone(env); + let fiber = Arc::clone(fiber); + let mut state = Some((next, f, out)); + Box::pin(poll_fn(move |cx| { + let (next, f, out) = state + .take() + .expect("id_effect: unfold probe polled after completion (kernel bug)"); + let ready = ProbeCx { + env: &env, + fiber: &fiber, + cx, + }; + Poll::Ready(erase_exit(unfold_ready(next, f, n, out, ready))) + })) + }))) +} + +/// What [`run_ready`] runs a step with: the pulling fiber's environment, the fiber (for its +/// interrupt flag) and the waker of the current poll. +struct ProbeCx<'a, 'b> { + env: &'a AnyEnv, + fiber: &'a FiberContext, + cx: &'a mut Context<'b>, +} + +/// The loop of [`unfold_probe`]. +fn unfold_ready( + mut next: Effect, E, R>, + mut f: F, + n: usize, + mut out: Vec, + mut ready: ProbeCx<'_, '_>, +) -> Result, Cause> +where + A: Send + 'static, + S: Send + 'static, + E: Send + 'static, + R: Send + Sync + 'static, + F: FnMut(S) -> Effect, E, R> + Send + 'static, +{ + loop { + match run_ready(next, &mut ready) { + Err(waiting) => return Ok(Some((out, Stream::unfold_resume(waiting, f)))), + Ok(Ok(None)) => return Ok(Some((out, Stream::empty()))), + Ok(Err(c)) => return Err(c), + Ok(Ok(Some((a, s)))) => { + out.push(a); + if out.len() >= n { + return Ok(Some((out, Stream::unfold_effect(s, f)))); + } + next = f(s); + } + } + } +} + +/// Runs `eff` as far as it goes **without waiting**: its ready values, synchronous steps and +/// `map`/`flat_map`/`catch` frames under the environment, and each plain future it reaches (a +/// [`from_async`](crate::from_async) step) polled once with the current waker. `Ok` is its +/// outcome; `Err` is the rest of `eff` from the step that would wait (a future that returned +/// `Pending`, kept with its progress, or a step that needs the fiber, such as a timer, a fork or +/// a mask region, not started), to run later on any fiber. +/// +/// Like the runloop, it polls no future once the fiber is asked to stop: the future is kept +/// unpolled in the rest, and the runloop observes the interrupt when this step returns. +fn run_ready( + eff: Effect, + ready: &mut ProbeCx<'_, '_>, +) -> Result>, Effect> +where + T: Send + 'static, + E: Send + 'static, + R: Send + Sync + 'static, +{ + let mut instr = match eff.node { + Node::Pure(r) => return Ok(r), + node => Effect { node }.into_instr(), + }; + loop { + let rest = match settle_instr(instr, ready.env) { + Ok(exit) => return Ok(unerase_exit(exit)), + Err(rest) => rest, + }; + if ready.fiber.should_interrupt() { + return Err(Effect::from_instr(rest)); + } + instr = match rest { + Instr::Future(mut fut) => match fut.as_mut().poll(ready.cx) { + Poll::Ready(exit) => Instr::Pure(exit), + Poll::Pending => return Err(Effect::from_instr(Instr::Future(fut))), + }, + Instr::Seq(mut body) => { + let Instr::Future(fut) = &mut body.inner else { + return Err(Effect::from_instr(Instr::Seq(body))); + }; + match fut.as_mut().poll(ready.cx) { + Poll::Ready(exit) => body.inner = Instr::Pure(exit), + Poll::Pending => return Err(Effect::from_instr(Instr::Seq(body))), + } + Instr::Seq(body) + } + other => return Err(Effect::from_instr(other)), + }; + } +} + /// Runs `f` over `items` one effect after the other, collecting the outputs; hands `f` back. fn seq_map( mut items: std::vec::IntoIter, diff --git a/crates/id_effect/tests/stream_live_chunks.rs b/crates/id_effect/tests/stream_live_chunks.rs new file mode 100644 index 000000000..1af39370e --- /dev/null +++ b/crates/id_effect/tests/stream_live_chunks.rs @@ -0,0 +1,531 @@ +//! 0.13 F57 (`r13-f-stream-chunking`): a pull on a live stream hands over the elements that are +//! already there instead of waiting for a full chunk. +//! +//! [`Stream::unfold_effect`] waits for the first element of a pull only; it then keeps the steps +//! that complete without waiting and stops at the first one that would wait (a timer, an empty +//! queue, a pending future), which the next pull resumes. So `run_for_each` on a live client +//! stream sees each element as it arrives, while bounded and pure sources keep full chunks. + +use std::collections::VecDeque; +use std::future::Future; +use std::pin::{Pin, pin}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, Waker}; +use std::time::Duration; + +use id_effect::{ + Cause, Chunk, DEFAULT_CHUNK_SIZE, Effect, Exit, FiberHandle, HNil, Never, Queue, Stream, + TestExecutor, die, fail, from_async, sleep, succeed, suspend, sync, yield_fiber, +}; + +// ── helpers ───────────────────────────────────────────────────────────────────────────────── + +fn run(eff: Effect) -> Result { + match eff.block_on() { + Exit::Success(a) => Ok(a), + Exit::Failure(Cause::Fail(e)) => Err(e), + Exit::Failure(c) => panic!("effect died: {c:?}"), + } +} + +fn ok(eff: Effect) -> A { + match run(eff) { + Ok(a) => a, + Err(n) => match n {}, + } +} + +/// One pulled chunk (as a vector) and the rest of the stream. +type Pulled = (Vec, Stream); + +/// One pull of at most `max` elements: the chunk as a vector and the rest, or `None` at the end. +fn pull( + s: Stream, + max: usize, +) -> Result>, E> { + run(s.next_chunk_with_size(max)).map(|o| o.map(|(c, rest)| (c.into_vec(), rest))) +} + +/// Every chunk of `s`, pulling `max` at a time. +fn chunks(mut s: Stream, max: usize) -> Vec> { + let mut out = Vec::new(); + while let Some((chunk, rest)) = ok(s.next_chunk_with_size(max)) { + out.push(Chunk::into_vec(chunk)); + s = rest; + } + out +} + +fn sizes(chunks: &[Vec]) -> Vec { + chunks.iter().map(Vec::len).collect() +} + +/// The sizes a source of `len` elements must keep when every element is ready at once. +fn full_chunks(len: usize, max: usize) -> Vec { + let mut out = vec![max; len / max]; + if !len.is_multiple_of(max) { + out.push(len % max); + } + out +} + +/// `Pending` once (after waking its waker), then ready: a step that would wait. +struct PendingOnce(bool); + +impl Future for PendingOnce { + type Output = (); + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { + if self.0 { + Poll::Ready(()) + } else { + self.0 = true; + cx.waker().wake_by_ref(); + Poll::Pending + } + } +} + +/// The buffered frames and the waker of a `next` waiting for one. +type FrameState = (VecDeque>, Option); + +/// A frame source shaped like an RPC client stream's transport (`id_effect_rpc` decodes each +/// frame in a `from_async` step): a frame is `Some(item)` or the end (`None`); `next` waits +/// while no frame is buffered. +#[derive(Clone, Default)] +struct Frames { + inner: Arc>, +} + +impl Frames { + fn push(&self, frame: Option) { + let waker = { + let mut g = self.inner.lock().unwrap(); + g.0.push_back(frame); + g.1.take() + }; + if let Some(w) = waker { + w.wake(); + } + } + + fn next(&self) -> NextFrame { + NextFrame(self.clone()) + } +} + +struct NextFrame(Frames); + +impl Future for NextFrame { + type Output = Option; + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut g = self.0.inner.lock().unwrap(); + match g.0.pop_front() { + Some(frame) => Poll::Ready(frame), + None => { + g.1 = Some(cx.waker().clone()); + Poll::Pending + } + } + } +} + +/// The client-stream shape: one `from_async` step per frame, decoded by a `map`. +fn client_stream(frames: Frames) -> Stream { + Stream::unfold_effect(frames, |frames| { + let next = frames.next(); + from_async(move || async move { Ok::<_, Never>(next.await) }) + .map(move |frame| frame.map(|item| (item * 10, frames))) + }) +} + +/// Runs `s` collecting into a shared vector, so a test can watch the output as it arrives. +fn watch(s: Stream) -> (Arc>>, Effect<(), Never>) { + let seen = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&seen); + let eff = s.run_for_each(move |x| { + let sink = Arc::clone(&sink); + sync(move || sink.lock().unwrap().push(x)) + }); + (seen, eff) +} + +/// Forks `eff` on a fresh `TestExecutor` and runs it until it stalls. +fn spawn_test( + eff: Effect, +) -> (TestExecutor, FiberHandle) { + let exec = TestExecutor::new(); + let h = eff.fork_root(Arc::new(exec.clone())); + exec.run_until_stalled(); + (exec, h) +} + +fn seen_now(seen: &Arc>>) -> Vec { + seen.lock().unwrap().clone() +} + +fn joined(h: FiberHandle) -> A { + assert!(h.is_done(), "fiber still running"); + let mut fut = pin!(h.join_future()); + match fut.as_mut().poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Exit::Success(a)) => a, + Poll::Ready(Exit::Failure(c)) => panic!("fiber failed: {c:?}"), + Poll::Pending => panic!("join is not ready"), + } +} + +/// The queue's elements until it is shut down and drained, one `take` per step. +fn from_takes(q: &Queue) -> Stream { + let q = q.clone(); + Stream::unfold_effect((), move |()| { + q.take::() + .map(|n| Some((n, ()))) + .catch(|_| succeed(None)) + }) +} + +fn offer(q: &Queue, items: impl IntoIterator) { + let left = ok(q.try_offer_all::(items)); + assert!(left.is_empty()); +} + +// ── the leaf's acceptance tests ──────────────────────────────────────────────────────────── + +/// A pull on a live source hands over what is there (between 1 and the requested size) instead +/// of waiting for a full chunk, and the next pull resumes the step that would have waited. +#[test] +fn live_stream_delivers_partial_chunk() { + let q = Queue::::unbounded(); + offer(&q, [1, 2, 3]); + let s = from_takes(&q); + + let (first, s) = ok(s.next_chunk()).expect("a chunk"); + assert_eq!( + first.into_vec(), + vec![1, 2, 3], + "no wait for {DEFAULT_CHUNK_SIZE}" + ); + + // The fourth take was left waiting in the rest: it receives the next offer. + offer(&q, [4, 5]); + let (second, s) = ok(s.next_chunk()).expect("a chunk"); + assert_eq!(second.into_vec(), vec![4, 5]); + + ok(q.shutdown::()); + assert!(ok(s.next_chunk()).is_none()); + + // The RPC client-stream shape: frames that are buffered come in one pull, then it stops at + // the frame that is not there yet. + let frames = Frames::default(); + for n in 1..=3 { + frames.push(Some(n)); + } + let (chunk, rest) = pull(client_stream(frames.clone()), DEFAULT_CHUNK_SIZE) + .unwrap() + .expect("a chunk"); + assert_eq!(chunk, vec![10, 20, 30]); + frames.push(Some(4)); + frames.push(None); + let (chunk, rest) = pull(rest, DEFAULT_CHUNK_SIZE).unwrap().expect("a chunk"); + assert_eq!(chunk, vec![40]); + assert!(pull(rest, DEFAULT_CHUNK_SIZE).unwrap().is_none()); +} + +/// `run_for_each` on a live stream sees each element as it arrives (TestClock: one element per +/// 10 ms of virtual time) instead of stalling until 64 have. +#[test] +fn run_for_each_live_no_stall() { + let ticks: Stream = Stream::unfold_effect(0_u32, |n| { + sleep(Duration::from_millis(10)).map(move |()| Some((n, n + 1))) + }); + let (seen, eff) = watch(ticks); + let (exec, h) = spawn_test(eff); + assert!(seen_now(&seen).is_empty()); + for k in 1..=5_u32 { + exec.advance(Duration::from_millis(10)); + assert_eq!( + seen_now(&seen), + (0..k).collect::>(), + "after {k} ticks" + ); + } + h.interrupt_now(); + exec.run_until_stalled(); + + // The client-stream shape, driven by frames instead of the clock. + let frames = Frames::default(); + let (seen, eff) = watch(client_stream(frames.clone())); + let (exec, h) = spawn_test(eff); + for n in 1..=4_u32 { + frames.push(Some(n)); + exec.run_until_stalled(); + assert_eq!(seen_now(&seen), (1..=n).map(|x| x * 10).collect::>()); + } + frames.push(None); + exec.run_until_stalled(); + joined(h); +} + +/// Bounded and pure sources, and generators whose steps never wait, keep full chunks. +#[test] +fn bounded_stream_chunking_unchanged() { + const LEN: usize = 150; + let expect = full_chunks(LEN, DEFAULT_CHUNK_SIZE); + let len = LEN as u32; + + let vec_source: Stream = Stream::from_vec((0..len).collect()); + let iter_source: Stream = Stream::from_iterable(0..len); + let unfold: Stream = Stream::unfold(0_u32, move |n| (n < len).then_some((n, n + 1))); + let unfold_pure: Stream = + Stream::unfold_effect(0_u32, move |n| succeed((n < len).then_some((n, n + 1)))); + let unfold_sync: Stream = Stream::unfold_effect(0_u32, move |n| { + sync(move || (n < len).then_some((n, n + 1))) + }); + let unfold_ready_future: Stream = Stream::unfold_effect(0_u32, move |n| { + from_async(move || async move { Ok::<_, Never>((n < len).then_some((n, n + 1))) }) + }); + let unfold_mapped: Stream = Stream::unfold_effect(0_u32, move |n| { + from_async(move || async move { Ok::<_, Never>(n) }) + .map(move |n| (n < len).then_some((n, n + 1))) + }); + let from_effect: Stream = Stream::from_effect(succeed((0..len).collect())); + + for (name, s) in [ + ("from_vec", vec_source), + ("from_iterable", iter_source), + ("unfold", unfold), + ("unfold_effect(succeed)", unfold_pure), + ("unfold_effect(sync)", unfold_sync), + ("unfold_effect(ready future)", unfold_ready_future), + ("unfold_effect(ready future + map)", unfold_mapped), + ("from_effect", from_effect), + ] { + let got = chunks(s, DEFAULT_CHUNK_SIZE); + assert_eq!(sizes(&got), expect, "{name}"); + assert_eq!(got.concat(), (0..len).collect::>(), "{name}"); + } + + // Other requested sizes too, and one element per pull. + let s: Stream = + Stream::unfold_effect(0_u32, move |n| sync(move || (n < 25).then_some((n, n + 1)))); + assert_eq!(sizes(&chunks(s, 10)), vec![10, 10, 5]); + let s: Stream = Stream::unfold_effect(0_u32, |n| succeed((n < 3).then_some((n, n + 1)))); + assert_eq!(chunks(s, 1), vec![vec![0], vec![1], vec![2]]); + + // `run_for_each` over a pure source sees every element once. + let s: Stream = + Stream::unfold_effect(0_u32, move |n| succeed((n < len).then_some((n, n + 1)))); + let counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let items = Arc::new(AtomicUsize::new(0)); + let i = Arc::clone(&items); + ok( + s.with_throughput_counter(Arc::clone(&counter)) + .run_for_each(move |_| { + i.fetch_add(1, Ordering::SeqCst); + succeed(()) + }), + ); + assert_eq!(items.load(Ordering::SeqCst), LEN); + assert_eq!(counter.load(Ordering::SeqCst), LEN as u64); +} + +/// `unfold_effect` splits a pull at the first step that would wait, whatever makes it wait, and +/// the next pull resumes that step; ends and failures keep their place in the stream. +#[test] +fn unfold_effect_partial_chunks() { + // A step that needs the fiber (a yield): not started by the pull that stops on it. + let started = Arc::new(AtomicUsize::new(0)); + let st = Arc::clone(&started); + let s: Stream = Stream::unfold_effect(0_u32, move |n| match n { + 3 => { + let st = Arc::clone(&st); + sync(move || st.fetch_add(1, Ordering::SeqCst)) + .then(yield_fiber()) + .map(move |()| Some((n, n + 1))) + } + n if n < 5 => succeed(Some((n, n + 1))), + _ => succeed(None), + }); + let (first, rest) = pull(s, DEFAULT_CHUNK_SIZE).unwrap().expect("a chunk"); + assert_eq!(first, vec![0, 1, 2]); + // The synchronous part before the yield ran once and is not run again by the next pull. + assert_eq!(started.load(Ordering::SeqCst), 1); + assert_eq!(chunks(rest, DEFAULT_CHUNK_SIZE), vec![vec![3, 4]]); + assert_eq!(started.load(Ordering::SeqCst), 1); + + // A plain future that is pending, with and without a `map` after it. + let plain: Stream = Stream::unfold_effect(0_u32, |n| { + if n == 2 { + from_async(move || async move { + PendingOnce(false).await; + Ok::<_, Never>(Some((n, n + 1))) + }) + } else { + succeed((n < 4).then_some((n, n + 1))) + } + }); + assert_eq!( + chunks(plain, DEFAULT_CHUNK_SIZE), + vec![vec![0, 1], vec![2, 3]] + ); + let mapped: Stream = Stream::unfold_effect(0_u32, |n| { + if n % 3 == 2 { + from_async(move || async move { + PendingOnce(false).await; + Ok::<_, Never>(n) + }) + .map(move |n| (n < 7).then_some((n, n + 1))) + } else { + sync(move || (n < 7).then_some((n, n + 1))) + } + }); + assert_eq!( + chunks(mapped, DEFAULT_CHUNK_SIZE), + vec![vec![0, 1], vec![2, 3, 4], vec![5, 6]] + ); + + // The generator ends after a step that waited, and while gathering ready steps. + let ends: Stream = Stream::unfold_effect(0_u32, |n| match n { + 0 => succeed(Some((0, 1))), + 1 => yield_fiber().map(|()| None), + _ => unreachable!("pulled past the end"), + }); + assert_eq!(chunks(ends, DEFAULT_CHUNK_SIZE), vec![vec![0]]); + let ends_sync: Stream = + Stream::unfold_effect(0_u32, |n| sync(move || (n < 2).then_some((n, n + 1)))); + assert_eq!(chunks(ends_sync, DEFAULT_CHUNK_SIZE), vec![vec![0, 1]]); + + // A failure after a step that waited arrives on the next pull, after the elements before it. + let late: Stream = Stream::unfold_effect(0_u32, |n| match n { + 0 => succeed(Some((0, 1))), + _ => yield_fiber().then(fail("late")), + }); + let (first, rest) = pull(late, DEFAULT_CHUNK_SIZE).unwrap().expect("a chunk"); + assert_eq!(first, vec![0]); + assert_eq!(pull(rest, DEFAULT_CHUNK_SIZE).err(), Some("late")); + + // A step that fails without waiting fails the pull, as before (ready and synchronous steps). + let pure_fail: Stream = Stream::unfold_effect(0_u32, |n| { + if n == 0 { + succeed(Some((0, 1))) + } else { + fail("now") + } + }); + assert_eq!(pull(pure_fail, DEFAULT_CHUNK_SIZE).err(), Some("now")); + let sync_fail: Stream = Stream::unfold_effect(0_u32, |n| { + if n == 0 { + succeed(Some((0, 1))) + } else { + suspend(|| fail("sync")) + } + }); + assert_eq!(pull(sync_fail, DEFAULT_CHUNK_SIZE).err(), Some("sync")); + let died: Stream = Stream::unfold_effect(0_u32, |n| { + if n == 0 { + succeed(Some((0, 1))) + } else { + suspend(|| die("boom")) + } + }); + assert!(matches!( + died.next_chunk().block_on(), + Exit::Failure(Cause::Die(_)) + )); + + // A pull that fills up while gathering stops there, leaving the next step unbuilt. + let built = Arc::new(AtomicUsize::new(0)); + let b = Arc::clone(&built); + let s: Stream = Stream::unfold_effect(0_u32, move |n| { + b.fetch_add(1, Ordering::SeqCst); + sync(move || Some((n, n + 1))) + }); + let (first, _rest) = pull(s, 3).unwrap().expect("a chunk"); + assert_eq!(first, vec![0, 1, 2]); + assert_eq!(built.load(Ordering::SeqCst), 3); + + // Ready values after a synchronous step are gathered too. + let mixed: Stream = Stream::unfold_effect(0_u32, |n| { + let next = (n < 6).then_some((n, n + 1)); + if n % 2 == 0 { + sync(move || next) + } else { + succeed(next) + } + }); + assert_eq!( + chunks(mixed, DEFAULT_CHUNK_SIZE), + vec![vec![0, 1, 2, 3, 4, 5]] + ); + + // An async body that needs the fiber (`from_ctx`, the `effect!` target) is not started by the + // pull that stops on it. + let bodies = Arc::new(AtomicUsize::new(0)); + let b = Arc::clone(&bodies); + let ctx_steps: Stream = Stream::unfold_effect(0_u32, move |n| { + if n == 2 { + let b = Arc::clone(&b); + Effect::from_ctx(move |_ctx| async move { + b.fetch_add(1, Ordering::SeqCst); + Ok(Some((n, n + 1))) + }) + } else { + succeed((n < 4).then_some((n, n + 1))) + } + }); + let (first, rest) = pull(ctx_steps, DEFAULT_CHUNK_SIZE) + .unwrap() + .expect("a chunk"); + assert_eq!(first, vec![0, 1]); + assert_eq!(bodies.load(Ordering::SeqCst), 0); + assert_eq!(chunks(rest, DEFAULT_CHUNK_SIZE), vec![vec![2, 3]]); + assert_eq!(bodies.load(Ordering::SeqCst), 1); +} + +/// Like the runloop, a pull polls no future once its fiber is asked to stop: the elements it +/// gathered are dropped with the interrupted pull, and the waiting step never ran. +#[test] +fn unfold_effect_polls_no_future_once_interrupted() { + let slot: Arc>>> = Arc::default(); + let polled = Arc::new(AtomicUsize::new(0)); + let (to_stop, p) = (Arc::clone(&slot), Arc::clone(&polled)); + let s: Stream = Stream::unfold_effect(0_u32, move |n| match n { + 0 => succeed(Some((0, 1))), + 1 => { + let to_stop = Arc::clone(&to_stop); + sync(move || { + if let Some(h) = to_stop.lock().unwrap().as_ref() { + h.interrupt_now(); + } + Some((1, 2)) + }) + } + _ => { + let p = Arc::clone(&p); + from_async(move || async move { + p.fetch_add(1, Ordering::SeqCst); + Ok::<_, Never>(Some((n, n + 1))) + }) + } + }); + let exec = TestExecutor::new(); + let h = s.run_drain().fork_root(Arc::new(exec.clone())); + *slot.lock().unwrap() = Some(h); + exec.run_until_stalled(); + let h = slot.lock().unwrap().take().expect("the handle"); + assert!(h.is_done(), "the interrupted fiber ended"); + let mut join = pin!(h.join_future()); + let exit = match join.as_mut().poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(exit) => exit, + Poll::Pending => panic!("join is not ready"), + }; + assert!( + matches!(exit, Exit::Failure(Cause::Interrupt(_))), + "{exit:?}" + ); + assert_eq!( + polled.load(Ordering::SeqCst), + 0, + "no poll after the interrupt" + ); +} From 4830ee247db3d1bd2511a9a4975409bb45006ae8 Mon Sep 17 00:00:00 2001 From: Tom Wieland Date: Wed, 21 Oct 2026 08:36:17 +0200 Subject: [PATCH 12/87] feat(rpc): generated clients batch same-tick calls; HttpTransport headers and bearer (r13-f-client-batching) Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Wieland --- CHANGELOG.md | 16 + Cargo.lock | 1 + crates/id_effect_rpc/Cargo.toml | 5 +- crates/id_effect_rpc/src/client/http.rs | 315 +++++++++++++++++--- crates/id_effect_rpc/src/service.rs | 192 ++++++++++-- crates/id_effect_rpc/tests/service_macro.rs | 246 +++++++++++++++ docs/MIGRATION-0.13.md | 18 +- 7 files changed, 733 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65da23b07..8bb98046e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,22 @@ Pipeline reference app (`examples/reference/pipeline`, tier `example`, its stage model); CI job `breadth`; `examples/recipes/` is outside the root workspace. The unused `crates/id_effect_cli/templates/admin-stub/` is removed ([MIGRATION-0.13.md](docs/MIGRATION-0.13.md)). +- `id_effect_rpc` generated clients batch over HTTP (r13-f-client-batching, 0.12 follow-up F27): + the calls a `#[rpc::service]` client makes over `HttpTransport` in the same tick go out as one + batch, by the rules of `RpcClient::call` (at most 50 calls / 1 MiB per request; a call alone is + a plain envelope), each call carrying its service version `v`. They share `RpcClient::call`'s + batch window, joined from any caller environment through `Effect::any_env`. + `RpcTransport::unary_effect`, a defaulted hook the generated client runs from the caller's fiber + (the default awaits `unary`; `HttpTransport` overrides it); `HttpTransport`'s `unary` still + sends one call on its own. +- `HttpTransport::with_header(name, value)` and `HttpTransport::with_bearer(secret)` + (r13-f-client-batching, 0.12 follow-up F56; ADR 0013 R2, R4, R9): headers on every request the + transport sends, batched or not. Names and values are validated (`HttpHeaderError`: no CR, LF + or other control bytes); `x-rpc`, `content-type` and the framing headers (`content-length`, + `transfer-encoding`, `host`, `connection`) are reserved, so the CSRF rule (`x-rpc: 1` on every + `POST`) cannot be overridden. The bearer token is an `id_effect_config::SecretString`, and + `Debug` of the transport shows header names only. Calls with different headers never share a + batch request. ### Changed diff --git a/Cargo.lock b/Cargo.lock index 7f7651069..efdd80033 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3160,6 +3160,7 @@ dependencies = [ "id_effect", "id_effect_authz", "id_effect_axum", + "id_effect_config", "id_effect_events", "id_effect_node", "id_effect_opentelemetry", diff --git a/crates/id_effect_rpc/Cargo.toml b/crates/id_effect_rpc/Cargo.toml index 1e878350b..3593dfcac 100644 --- a/crates/id_effect_rpc/Cargo.toml +++ b/crates/id_effect_rpc/Cargo.toml @@ -22,7 +22,7 @@ name = "id_effect_rpc" default = ["client"] # `client`: `RpcClient` over id_effect_platform's `HttpClientService` (batching adds same-tick # batches in `client::http`). -client = ["dep:id_effect_platform"] +client = ["dep:id_effect_platform", "dep:id_effect_config"] # `postcard`: the binary codec negotiated per connection (D13; r12-e12-1-postcard). postcard = ["dep:postcard"] # `ws`: every kind over WebSocket frames, server (`transport::ws`, on id_effect_axum's realtime @@ -83,6 +83,9 @@ tracing = "0.1" uuid = { version = "1", features = ["v4"] } # Optional dependencies of the features above. id_effect_authz = { path = "../id_effect_authz", version = "0.12.0", optional = true } +# `Secret` bearer tokens of `HttpTransport::with_bearer` (feature `client`); already in +# id_effect_axum's tree. +id_effect_config = { path = "../id_effect_config", version = "0.12.0", optional = true, default-features = false } id_effect_events = { path = "../id_effect_events", version = "0.12.0", optional = true } id_effect_node = { path = "../id_effect_node", version = "0.12.0", optional = true } id_effect_process = { path = "../id_effect_process", version = "0.12.0", optional = true } diff --git a/crates/id_effect_rpc/src/client/http.rs b/crates/id_effect_rpc/src/client/http.rs index feaa2635e..87c119eba 100644 --- a/crates/id_effect_rpc/src/client/http.rs +++ b/crates/id_effect_rpc/src/client/http.rs @@ -21,6 +21,7 @@ use id_effect::{ fail, need, succeed, }; use id_effect_axum::route::Problem; +use id_effect_config::SecretString; use id_effect_platform::error::HttpError; use id_effect_platform::http::{HttpClient, HttpClientService, HttpMethod, HttpRequest}; use serde_json::Value; @@ -29,6 +30,7 @@ use super::{RPC_HEADER, RpcClient, RpcClientError}; use crate::serialization::{ RpcSerializationError, StructWireJson, decode_payload, encode_struct_fields, }; +use crate::service::TransportError; use crate::wire::{CallId, MAX_BATCH_BYTES, MAX_BATCH_CALLS, WireCall, WireExit, WireResult}; /// Most calls the client puts in one batch request (the server default). @@ -76,7 +78,9 @@ impl RpcClient { url, client: client.0.clone(), tag, + version: None, payload: Arc::new(payload), + headers: ExtraHeaders::default(), }; // Every call is distinct (its `seq`): a request cache would only fill up. id_effect::request(pending, batcher()) @@ -90,6 +94,39 @@ impl RpcClient { }) } + /// Call `tag` of service version `version` with an encoded `payload` through `http`, in the + /// batch window of the calling fiber like [`call`](Self::call): the generated clients over + /// [`HttpTransport`](crate::service::HttpTransport) go through here, each batched call + /// carrying its `v` and every request the transport's `headers`. The exit is left undecoded. + pub(crate) fn call_value( + &self, + http: &HttpClientService, + headers: &ExtraHeaders, + tag: String, + version: u32, + payload: Value, + ) -> Effect { + let pending = Pending { + seq: NEXT_SEQ.fetch_add(1, Ordering::Relaxed), + url: Arc::from(self.config().url()), + client: http.0.clone(), + tag: Arc::from(tag), + version: Some(version), + payload: Arc::new(payload), + headers: headers.clone(), + }; + // The window is the env-free one of `call`: the request joins it from the caller's fiber + // whatever that fiber's environment, and the flush runs without one. + id_effect::request(pending, batcher()) + .with_request_caching(false) + .map_err(|never: Infallible| match never {}) + .flat_map(|answer| match answer.into_transport() { + Ok(exit) => succeed(exit), + Err(e) => fail(e), + }) + .any_env() + } + /// Call `tag` over postcard (feature `postcard`, design note §7): `payload` and the output /// cross the wire as postcard bytes of their serde form, which the server reads with the /// procedure's typed codec (`WireSchema::with_serde`); `A` and `B` serialize like the @@ -191,6 +228,56 @@ fn post_as(url: String, body: Vec, content_type: &str) -> HttpRequest { } } +/// A header value as a transport keeps it: a bearer stays a [`SecretString`] (zeroized on drop, +/// redacted in `Debug`) until a request is built. +#[derive(Clone, PartialEq)] +enum HeaderSlot { + Plain(String), + Secret(SecretString), +} + +/// The headers an [`HttpTransport`](crate::service::HttpTransport) adds to every request it +/// sends, validated by it (never `x-rpc` or `content-type`: the CSRF header and the media type +/// are always the client's own). Names are lower case; one value per name. +#[derive(Clone, Default, PartialEq)] +pub(crate) struct ExtraHeaders(Arc>); + +impl ExtraHeaders { + /// These headers with `name` set to `value` (replacing an earlier value of `name`). + pub(crate) fn with_plain(&self, name: String, value: String) -> Self { + self.with(name, HeaderSlot::Plain(value)) + } + + /// These headers with `name` set to the secret `value`. + pub(crate) fn with_secret(&self, name: String, value: SecretString) -> Self { + self.with(name, HeaderSlot::Secret(value)) + } + + fn with(&self, name: String, value: HeaderSlot) -> Self { + let mut headers: Vec<_> = self.0.iter().filter(|(n, _)| *n != name).cloned().collect(); + headers.push((name, value)); + Self(Arc::new(headers)) + } + + /// The header names (what `Debug` shows: values may be credentials). + pub(crate) fn names(&self) -> Vec<&str> { + self.0.iter().map(|(n, _)| n.as_str()).collect() + } + + /// A JSON `POST` of `body` to `url` with these headers and the client's own. + pub(crate) fn post(&self, url: String, body: Vec) -> HttpRequest { + let mut req = post(url, body); + req.headers.extend(self.0.iter().map(|(name, value)| { + let value = match value { + HeaderSlot::Plain(v) => v.clone(), + HeaderSlot::Secret(v) => v.expose().clone(), + }; + (name.clone(), value) + })); + req + } +} + /// Decode a response: a [`WireResult`] (success against `schema`, failure as /// [`RpcClientError::Failure`]), a Problem as [`RpcClientError::Refused`], anything else by /// status. @@ -292,6 +379,23 @@ impl Answer { Self::Http(err) => Err(RpcClientError::Http(err)), } } + + /// The exit, or why the transport did not deliver it (as `HttpTransport::unary` reads it). + fn into_transport(self) -> Result { + match self { + Self::Exit(exit) => Ok(exit), + Self::Refused(problem) => Err(TransportError::Refused { + status: problem.status.as_u16(), + problem: Some(problem), + }), + Self::Status(status) => Err(TransportError::Refused { + status, + problem: None, + }), + Self::Protocol(why) => Err(TransportError::Protocol(why)), + Self::Http(err) => Err(TransportError::Io(err.to_string())), + } + } } static NEXT_SEQ: AtomicU64 = AtomicU64::new(0); @@ -303,7 +407,11 @@ struct Pending { url: Arc, client: Arc, tag: Arc, + /// The service version (`v`), sent when set. + version: Option, payload: Arc, + /// The transport's own headers (none for [`RpcClient::call`]). + headers: ExtraHeaders, } impl PartialEq for Pending { @@ -335,33 +443,47 @@ fn batcher() -> &'static RequestResolver { }) } -/// One request's calls: their index in the window, tag and payload. -type Chunk = Vec<(usize, Arc, Arc)>; +/// One request's calls: their index in the window, tag, version and payload. +type Chunk = Vec<(usize, Arc, Option, Arc)>; -/// A client, a URL and the calls of one request to it. -type Outgoing = (Arc, Arc, Chunk); +/// A client, a URL, the headers and the calls of one request to it. +type Outgoing = (Arc, Arc, ExtraHeaders, Chunk); -/// Per client and URL while splitting: its requests so far and the bytes of the last one. -type Group = (Arc, Arc, Vec, usize); +/// Per client, URL and headers while splitting: its requests so far and the bytes of the last +/// one. +type Group = ( + Arc, + Arc, + ExtraHeaders, + Vec, + usize, +); -/// Split a window by client and URL, then into requests within the caps. +/// Split a window by client, URL and headers (calls with other headers, another bearer for +/// example, never share a request), then into requests within the caps. fn chunks(calls: &[Pending], max_calls: usize, max_bytes: usize) -> Vec { let mut groups: Vec = Vec::new(); for (index, call) in calls.iter().enumerate() { // A call's share of the array: its encoding, an id and a comma. - let size = - serde_json::to_vec(&WireCall::new(&*call.tag, &*call.payload)).map_or(0, |v| v.len()) + 16; - let at = match groups - .iter() - .position(|(client, url, ..)| Arc::ptr_eq(client, &call.client) && *url == call.url) - { + let size = serde_json::to_vec(&wire_call(&call.tag, call.version, &call.payload)) + .map_or(0, |v| v.len()) + + 16; + let at = match groups.iter().position(|(client, url, headers, ..)| { + Arc::ptr_eq(client, &call.client) && *url == call.url && *headers == call.headers + }) { Some(at) => at, None => { - groups.push((call.client.clone(), call.url.clone(), vec![Vec::new()], 2)); + groups.push(( + call.client.clone(), + call.url.clone(), + call.headers.clone(), + vec![Vec::new()], + 2, + )); groups.len() - 1 } }; - let (_, _, requests, bytes) = &mut groups[at]; + let (_, _, _, requests, bytes) = &mut groups[at]; let full = requests .last() .is_some_and(|c| !c.is_empty() && (c.len() >= max_calls || *bytes + size > max_bytes)); @@ -371,15 +493,15 @@ fn chunks(calls: &[Pending], max_calls: usize, max_bytes: usize) -> Vec = chunks(&calls, max_calls, max_bytes) .into_iter() - .map(|(client, url, chunk)| send_chunk(&*client, url.to_string(), chunk)) + .map(|(client, url, headers, chunk)| send_chunk(&*client, url.to_string(), &headers, chunk)) .collect(); all(sends, Concurrency::Unbounded).map(move |answered| { let mut answers: Vec> = vec![None; n]; @@ -426,22 +548,32 @@ fn exits_by_position(results: Vec, len: usize) -> Vec { .collect() } +/// The envelope of one call, with its `v` when it has one. +fn wire_call<'a>(tag: &'a str, version: Option, payload: &'a Value) -> WireCall<&'a Value> { + let call = WireCall::new(tag, payload); + match version { + Some(v) => call.with_version(v), + None => call, + } +} + /// Send one request: a plain envelope for one call, a batch array for more. fn send_chunk( client: &dyn HttpClient, url: String, + headers: &ExtraHeaders, chunk: Chunk, ) -> Effect, Infallible, HNil> { let single = chunk.len() == 1; let encoded = if single { - let (_, tag, payload) = &chunk[0]; - serde_json::to_vec(&WireCall::new(&**tag, &**payload)) + let (_, tag, version, payload) = &chunk[0]; + serde_json::to_vec(&wire_call(tag, *version, payload)) } else { let calls: Vec> = chunk .iter() .zip(1_u64..) - .map(|((_, tag, payload), id)| { - let call = WireCall::new(&**tag, &**payload); + .map(|((_, tag, version, payload), id)| { + let call = wire_call(tag, *version, payload); // At most a few thousand calls: far below 2^53. match CallId::new(id) { Ok(id) => call.with_id(id), @@ -455,7 +587,7 @@ fn send_chunk( let body = encoded.unwrap_or_default(); let indexes: Vec = chunk.iter().map(|(index, ..)| *index).collect(); client - .execute(post(url, body)) + .execute(headers.post(url, body)) .exit::() .map(move |exit| { let answers: Vec = match exit { @@ -628,7 +760,9 @@ mod tests { url: Arc::from(url), client: client.clone() as Arc, tag: Arc::from("A.b"), + version: None, payload: Arc::new(Value::String(text.to_owned())), + headers: ExtraHeaders::default(), } } @@ -646,7 +780,7 @@ mod tests { let sizes = |max_calls, max_bytes| -> Vec> { chunks(&calls, max_calls, max_bytes) .into_iter() - .map(|(_, _, chunk)| chunk.iter().map(|(i, ..)| *i).collect()) + .map(|(.., chunk)| chunk.iter().map(|(i, ..)| *i).collect()) .collect() }; assert_eq!(sizes(50, 1 << 20), [vec![0, 1, 2, 5], vec![3], vec![4]]); @@ -659,14 +793,59 @@ mod tests { assert!(Pending::eq(&calls[0], &calls[0].clone()) && calls[0] != calls[1]); } + #[test] + fn windows_split_by_headers_and_requests_carry_them() { + let a = mock(200, b""); + let bearer = |token: &str| { + ExtraHeaders::default() + .with_plain("x-tenant".to_owned(), "t".to_owned()) + .with_secret( + "authorization".to_owned(), + SecretString::new(format!("Bearer {token}")), + ) + }; + let with = |headers: ExtraHeaders| Pending { + headers, + ..pending(&a, "u1", "x") + }; + let calls = vec![ + with(bearer("one")), + with(bearer("two")), + with(bearer("one")), + pending(&a, "u1", "x"), + ]; + let split: Vec> = chunks(&calls, 50, 1 << 20) + .into_iter() + .map(|(.., chunk)| chunk.iter().map(|(i, ..)| *i).collect()) + .collect(); + assert_eq!(split, [vec![0, 2], vec![1], vec![3]]); + let headers = bearer("one").with_plain("x-tenant".to_owned(), "u".to_owned()); + assert_eq!(headers.names(), ["authorization", "x-tenant"]); + let req = headers.post("http://h/rpc".to_owned(), b"{}".to_vec()); + assert_eq!( + req.headers, + [ + ("content-type".to_owned(), "application/json".to_owned()), + (RPC_HEADER.to_owned(), "1".to_owned()), + ("authorization".to_owned(), "Bearer one".to_owned()), + ("x-tenant".to_owned(), "u".to_owned()), + ] + ); + } + fn answers(status: u16, body: &[u8], n: usize) -> Vec<(usize, Answer)> { let client = mock(status, body); let chunk: Chunk = (0..n) - .map(|i| (i + 10, Arc::from("A.b"), Arc::new(Value::from(i)))) + .map(|i| (i + 10, Arc::from("A.b"), None, Arc::new(Value::from(i)))) .collect(); - let out = send_chunk(&*client, "http://h/rpc".to_owned(), chunk) - .block_on() - .into_result(); + let out = send_chunk( + &*client, + "http://h/rpc".to_owned(), + &ExtraHeaders::default(), + chunk, + ) + .block_on() + .into_result(); let sent: Value = serde_json::from_slice( client .last @@ -683,6 +862,67 @@ mod tests { out.ok().unwrap() } + #[test] + fn answers_read_as_transport_results() { + let exit = Answer::of_response(200, br#"{"tag":"A.b","exit":{"_tag":"Success","value":1}}"#); + assert!(matches!( + exit.into_transport(), + Ok(WireExit::Success { .. }) + )); + let problem = Answer::of_response(429, br#"{"status":429,"title":"Too Many Requests"}"#); + assert!(matches!( + problem.into_transport(), + Err(TransportError::Refused { + status: 429, + problem: Some(_) + }) + )); + assert!(matches!( + Answer::Status(502).into_transport(), + Err(TransportError::Refused { + status: 502, + problem: None + }) + )); + assert!(matches!( + Answer::Protocol(NO_RESULT.to_owned()).into_transport(), + Err(TransportError::Protocol(_)) + )); + let http = Answer::Http(HttpError::InvalidRequest("no".to_owned())); + assert!(matches!(http.into_transport(), Err(TransportError::Io(_)))); + } + + #[test] + fn versioned_calls_carry_v_alone_and_batched() { + let client = mock(200, b"[]"); + let chunk: Chunk = vec![ + (0, Arc::from("A.b"), Some(2), Arc::new(Value::Null)), + (1, Arc::from("A.c"), None, Arc::new(Value::Null)), + ]; + let _ = send_chunk( + &*client, + "http://h/rpc".to_owned(), + &ExtraHeaders::default(), + chunk, + ) + .block_on(); + let req = client.last.lock().unwrap().clone().unwrap(); + let sent: Value = serde_json::from_slice(req.body.as_deref().unwrap()).unwrap(); + assert_eq!(sent[0]["v"], 2); + assert!(sent[1].get("v").is_none()); + let one: Chunk = vec![(0, Arc::from("A.b"), Some(3), Arc::new(Value::Null))]; + let _ = send_chunk( + &*client, + "http://h/rpc".to_owned(), + &ExtraHeaders::default(), + one, + ) + .block_on(); + let req = client.last.lock().unwrap().clone().unwrap(); + let sent: Value = serde_json::from_slice(req.body.as_deref().unwrap()).unwrap(); + assert_eq!(sent["v"], 3); + } + #[test] fn batch_responses_map_by_id_and_refusals_reach_every_call() { let ok = br#"[{"id":2,"tag":"A.b","exit":{"_tag":"Success","value":"two"}},{"id":9,"tag":"A.b","exit":{"_tag":"Success","value":"x"}},{"tag":"A.b","exit":{"_tag":"Success","value":"x"}},{"id":1,"tag":"A.b","exit":{"_tag":"Failure","error":{"_tag":"Nope"}}}]"#; @@ -734,12 +974,17 @@ mod tests { #[test] fn transport_failures_reach_every_call_of_the_window() { - let chunk: Chunk = vec![(0, Arc::from("A.b"), Arc::new(Value::Null)); 2]; - let out = send_chunk(&Broken, "http://h".to_owned(), chunk) - .block_on() - .into_result() - .ok() - .unwrap(); + let chunk: Chunk = vec![(0, Arc::from("A.b"), None, Arc::new(Value::Null)); 2]; + let out = send_chunk( + &Broken, + "http://h".to_owned(), + &ExtraHeaders::default(), + chunk, + ) + .block_on() + .into_result() + .ok() + .unwrap(); for (_, answer) in out { let err = answer.decode(&schema::string::<()>()).unwrap_err(); assert!(matches!( diff --git a/crates/id_effect_rpc/src/service.rs b/crates/id_effect_rpc/src/service.rs index 1bcde7b40..aed2b6c5b 100644 --- a/crates/id_effect_rpc/src/service.rs +++ b/crates/id_effect_rpc/src/service.rs @@ -77,7 +77,7 @@ use futures_util::future::{Either, select}; use http::HeaderMap; use http::request::Parts; use id_effect::schema::HasSchema; -use id_effect::{DynEnv, Effect, Never, Stream, fail, from_async}; +use id_effect::{DynEnv, Effect, Never, Stream, fail, from_async, succeed}; use id_effect_axum::route::Problem; use serde_json::Value; use tokio::sync::mpsc; @@ -451,6 +451,17 @@ pub trait RpcTransport: Clone + Send + Sync + 'static { /// A query or mutation. fn unary(&self, call: TransportCall) -> BoxFuture>; + /// [`unary`](Self::unary) as an effect of the calling fiber (what the generated client runs). + /// The default awaits `unary`; `HttpTransport` (feature `client`) overrides it so that the + /// calls a program makes in the same tick join one batch window and go out as one request. + fn unary_effect( + &self, + call: TransportCall, + ) -> Effect { + let this = self.clone(); + from_async(move || this.unary(call)) + } + /// A server stream. fn server_stream(&self, call: TransportCall) -> BoxFuture> { unsupported(call.kind) @@ -695,31 +706,111 @@ impl RpcTransport for LocalTransport { } #[cfg(feature = "client")] -pub use http_transport::HttpTransport; +pub use http_transport::{HttpHeaderError, HttpTransport}; #[cfg(feature = "client")] mod http_transport { - use id_effect::{Exit, HNil}; + use id_effect::{Effect, Exit, HNil}; use id_effect_axum::route::Problem; - use id_effect_platform::http::{HttpClientService, HttpMethod, HttpRequest}; + use id_effect_config::{Secret, SecretString}; + use id_effect_platform::http::HttpClientService; use super::{BoxFuture, RpcTransport, TransportCall, TransportError}; + use crate::client::http::ExtraHeaders; use crate::client::{RPC_HEADER, RpcClient}; use crate::wire::{WireCall, WireExit, WireResult}; + /// Headers an [`HttpTransport`] never lets a caller set: the CSRF header every `POST /rpc` + /// carries (`x-rpc: 1`, ADR 0013 R9), the media type, and the framing and routing headers + /// the HTTP client owns. + const RESERVED: [&str; 6] = [ + RPC_HEADER, + "content-type", + "content-length", + "transfer-encoding", + "host", + "connection", + ]; + + /// Why [`HttpTransport::with_header`] or [`HttpTransport::with_bearer`] refused a header. + /// Values never appear in the error (they may be credentials). + #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] + #[non_exhaustive] + pub enum HttpHeaderError { + /// The name is not an HTTP header name (RFC 9110 token). + #[error("invalid http header name {0:?}")] + InvalidName(String), + /// The value holds a byte a header value cannot (a control character such as CR or LF, + /// or a non-ASCII byte), or a bearer token is empty. + #[error("invalid value for http header {name:?}")] + InvalidValue { + /// The (lower-case) header name. + name: String, + }, + /// The header is the transport's own: `x-rpc` (the CSRF rule), `content-type`, or a framing + /// header (`content-length`, `transfer-encoding`, `host`, `connection`). + #[error("http header {0:?} is reserved by the rpc transport")] + Reserved(String), + } + + /// A header value's bytes: visible ASCII, space and tab (what `http::HeaderValue::from_str` + /// accepts), so no CR, LF or NUL can split the request. + fn valid_value(value: &str) -> bool { + value + .bytes() + .all(|b| b == b'\t' || (b' '..=b'~').contains(&b)) + } + + /// The lower-case form of a header name that is valid and not reserved. + fn header_name(name: &str) -> Result { + let parsed = http::HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| HttpHeaderError::InvalidName(name.to_owned()))?; + let name = parsed.as_str(); + if RESERVED.contains(&name) { + return Err(HttpHeaderError::Reserved(name.to_owned())); + } + Ok(name.to_owned()) + } + /// Queries and mutations over `POST /rpc` (the [`WireCall`] envelope, `v` = the service - /// version) through id_effect_platform's HTTP client. The stream kinds go over the WebSocket - /// and SSE transports. + /// version) through id_effect_platform's HTTP client. The calls a generated client makes in + /// the same tick go out as one batch request, by the rules of + /// [`RpcClient::call`](crate::client::RpcClient::call) (at most + /// [`CLIENT_BATCH_MAX_CALLS`](crate::client::http::CLIENT_BATCH_MAX_CALLS) calls and + /// [`CLIENT_BATCH_MAX_BYTES`](crate::client::http::CLIENT_BATCH_MAX_BYTES) bytes; a call alone + /// is a plain envelope); [`RpcTransport::unary`] sends one at once, on its own. The stream + /// kinds go over the WebSocket and SSE transports. + /// + /// [`with_header`](Self::with_header) and [`with_bearer`](Self::with_bearer) add headers to + /// every request (validated; `x-rpc` and `content-type` stay the transport's own, so the CSRF + /// rule holds). `Debug` shows header names only. + /// + /// ``` + /// use id_effect_config::Secret; + /// use id_effect_platform::http::HttpClientService; + /// use id_effect_rpc::client::RpcClient; + /// use id_effect_rpc::service::{HttpHeaderError, HttpTransport}; + /// + /// # fn transport(http: HttpClientService) -> Result { + /// let transport = HttpTransport::new(RpcClient::new("https://api.example.com"), http) + /// .with_header("x-tenant", "acme")? + /// .with_bearer(Secret::new("s3cr3t".to_owned()))?; + /// assert!(!format!("{transport:?}").contains("s3cr3t")); + /// # Ok(transport) + /// # } + /// ``` #[derive(Clone)] pub struct HttpTransport { client: RpcClient, http: HttpClientService, + headers: ExtraHeaders, } impl std::fmt::Debug for HttpTransport { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("HttpTransport") .field("url", &self.client.config().url()) + .field("headers", &self.headers.names()) .finish_non_exhaustive() } } @@ -727,7 +818,57 @@ mod http_transport { impl HttpTransport { /// Call the server `client` names through `http`. pub fn new(client: RpcClient, http: HttpClientService) -> Self { - Self { client, http } + Self { + client, + http, + headers: ExtraHeaders::default(), + } + } + + /// This transport with the header `name: value` on every request (replacing an earlier + /// value of `name`; names are case-insensitive). + /// + /// # Errors + /// + /// [`HttpHeaderError::InvalidName`] when `name` is not a header name, + /// [`HttpHeaderError::Reserved`] for `x-rpc`, `content-type` and the framing headers, and + /// [`HttpHeaderError::InvalidValue`] when `value` holds a control character (CR, LF, …) or + /// a non-ASCII byte. + pub fn with_header( + self, + name: impl AsRef, + value: impl Into, + ) -> Result { + let name = header_name(name.as_ref())?; + let value = value.into(); + if !valid_value(&value) { + return Err(HttpHeaderError::InvalidValue { name }); + } + let headers = self.headers.with_plain(name, value); + Ok(Self { headers, ..self }) + } + + /// This transport with `authorization: Bearer ` on every request. The token stays a + /// [`Secret`] (zeroized on drop; `Debug` of the transport shows only the header name). + /// + /// # Errors + /// + /// [`HttpHeaderError::InvalidValue`] when the token is empty or holds a byte a header value + /// cannot. + pub fn with_bearer(self, secret: SecretString) -> Result { + let token = secret.expose(); + if token.is_empty() || !valid_value(token) { + return Err(HttpHeaderError::InvalidValue { + name: "authorization".to_owned(), + }); + } + let mut value = String::with_capacity(7 + token.len()); + value.push_str("Bearer "); + value.push_str(token); + let headers = self + .headers + .with_secret("authorization".to_owned(), Secret::new(value)); + Ok(Self { headers, ..self }) } } @@ -754,21 +895,24 @@ mod http_transport { } impl RpcTransport for HttpTransport { + fn unary_effect( + &self, + call: TransportCall, + ) -> Effect { + self.client.call_value( + &self.http, + &self.headers, + call.tag, + call.version, + call.payload, + ) + } + fn unary(&self, call: TransportCall) -> BoxFuture> { let wire = WireCall::new(call.tag, call.payload).with_version(call.version); // A tag and a `serde_json::Value` always serialize. let body = serde_json::to_vec(&wire).unwrap_or_default(); - let req = HttpRequest { - method: HttpMethod::Post, - url: self.client.config().url(), - headers: vec![ - ("content-type".to_owned(), "application/json".to_owned()), - (RPC_HEADER.to_owned(), "1".to_owned()), - ], - body: Some(body), - timeout: None, - max_body_bytes: None, - }; + let req = self.headers.post(self.client.config().url(), body); let http = self.http.clone(); Box::pin(async move { let exit = id_effect_axum::run_with_env(HNil, move |_| http.execute(req)).await; @@ -820,11 +964,13 @@ where Err(why) => return fail(RpcCallError::Encode(why)), }; let call = transport_call(tag, version, kind, payload); - let transport = transport.clone(); - from_async(move || async move { - let exit = transport.unary(call).await?; - exit_output(exit, &output) - }) + transport + .unary_effect(call) + .map_err(RpcCallError::Transport) + .flat_map(move |exit| match exit_output(exit, &output) { + Ok(o) => succeed(o), + Err(e) => fail(e), + }) } /// The decoded items of `frames` with their cursors; the `exit` frame ends the stream or fails diff --git a/crates/id_effect_rpc/tests/service_macro.rs b/crates/id_effect_rpc/tests/service_macro.rs index b715d2bf6..c9c02107b 100644 --- a/crates/id_effect_rpc/tests/service_macro.rs +++ b/crates/id_effect_rpc/tests/service_macro.rs @@ -596,6 +596,252 @@ async fn http_transport_carries_queries_and_mutations() { assert!(err.to_string().contains("connection refused")); } +/// Every request body a [`Recording`] client sent. +type Bodies = Arc>>; + +/// Every request's headers a [`Recording`] client sent. +type SentHeaders = Arc>>>; + +/// [`AxumHttp`] that keeps every request body and header list it sends. +struct Recording { + inner: AxumHttp, + bodies: Bodies, + headers: SentHeaders, +} + +impl HttpClient for Recording { + fn execute(&self, req: HttpRequest) -> Effect { + let body = serde_json::from_slice(req.body.as_deref().unwrap_or_default()).expect("json"); + self.bodies.lock().unwrap().push(body); + self.headers.lock().unwrap().push(req.headers.clone()); + self.inner.execute(req) + } + + fn execute_stream(&self, req: HttpRequest) -> Effect { + self.inner.execute_stream(req) + } +} + +/// An [`HttpTransport`] over a recording client; `staff` adds `x-staff: yes` server side. +fn recording(staff: bool) -> (HttpTransport, Bodies, SentHeaders) { + let bodies = Bodies::default(); + let headers = SentHeaders::default(); + let router = TodosService::mount(id_effect_axum::route::Router::new(), Store::default()); + let app: axum::Router = router.with_state(DynEnv::new()).into_axum(); + let app = if staff { + app.layer(axum::middleware::from_fn( + |mut req: axum::extract::Request, next: axum::middleware::Next| async move { + req + .headers_mut() + .insert("x-staff", HeaderValue::from_static("yes")); + next.run(req).await + }, + )) + } else { + app + }; + let http = Recording { + inner: AxumHttp { app, down: false }, + bodies: bodies.clone(), + headers: headers.clone(), + }; + let transport = HttpTransport::new( + RpcClient::new("http://rpc.test"), + HttpClientService::new(http), + ); + (transport, bodies, headers) +} + +fn recorded() -> (TodosClient, Bodies) { + let (transport, bodies, _) = recording(true); + (TodosService::client(transport), bodies) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn generated_client_batches_same_tick_calls() { + let (client, bodies) = recorded(); + let both = id_effect::all( + vec![ + client.get(1).map(|t| t.title), + client.add(NewTodo { title: "z".into() }).map(|t| t.title), + ], + id_effect::Concurrency::Unbounded, + ); + assert_eq!(run(both).await.unwrap(), ["milk", "z"]); + let sent = bodies.lock().unwrap().clone(); + assert_eq!(sent.len(), 1, "one request: {sent:?}"); + let calls = sent[0].as_array().expect("a batch array"); + let mut tags: Vec<_> = calls + .iter() + .map(|c| (c["tag"].to_string(), c["v"].clone())) + .collect(); + tags.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!( + tags, + [ + ("\"Todos.add\"".to_owned(), json!(1)), + ("\"Todos.get\"".to_owned(), json!(1)) + ] + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn generated_client_lone_call_is_plain_envelope() { + let (client, bodies) = recorded(); + assert_eq!( + run(client.get(8)).await.unwrap_err().typed(), + Some(&TodoError::NotFound { id: 8 }) + ); + let sent = bodies.lock().unwrap().clone(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0]["tag"], "Todos.get"); + assert_eq!(sent[0]["v"], 1); + assert_eq!(sent[0]["payload"], 8); +} + +/// The values of `name` in one request's headers. +fn values<'a>(headers: &'a [(String, String)], name: &str) -> Vec<&'a str> { + headers + .iter() + .filter(|(n, _)| n.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + .collect() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn http_transport_attaches_bearer() { + // No staff middleware: the guard sees `x-staff` only because the transport sends it. + let (transport, bodies, headers) = recording(false); + let transport = transport + .with_header("X-Staff", "yes") + .unwrap() + .with_bearer(id_effect_config::Secret::new("tok-123".to_owned())) + .unwrap(); + let client = TodosService::client(transport.clone()); + let both = id_effect::all( + vec![ + client.get(1).map(|t| t.title), + client.add(NewTodo { title: "z".into() }).map(|t| t.title), + ], + id_effect::Concurrency::Unbounded, + ); + assert_eq!(run(both).await.unwrap(), ["milk", "z"]); + // The unbatched path (`RpcTransport::unary`) carries them too. + let call = TransportCall { + tag: "Todos.add".to_owned(), + version: 1, + kind: ProcedureKind::Mutation, + payload: json!({"title": "q"}), + }; + assert!(matches!( + transport.unary(call).await, + Ok(WireExit::Success { .. }) + )); + let sent = headers.lock().unwrap().clone(); + assert_eq!(bodies.lock().unwrap().len(), sent.len()); + assert!(!sent.is_empty()); + for request in &sent { + assert_eq!(values(request, "authorization"), ["Bearer tok-123"]); + assert_eq!(values(request, "x-staff"), ["yes"]); + assert_eq!(values(request, "x-rpc"), ["1"]); + assert_eq!(values(request, "content-type"), ["application/json"]); + } +} + +#[test] +fn http_transport_rejects_invalid_header() { + use rpc::service::HttpHeaderError; + let (transport, ..) = recording(false); + let header = |name: &str, value: &str| transport.clone().with_header(name, value).map(|_| ()); + for name in ["", "bad header", "x:y", "x\r\ny", "café"] { + assert_eq!( + header(name, "v"), + Err(HttpHeaderError::InvalidName(name.to_owned())), + "{name:?}" + ); + } + for value in ["a\r\nx-rpc: 0", "a\nb", "nul\0", "del\u{7f}", "café"] { + let err = header("X-Tenant", value).unwrap_err(); + assert_eq!( + err, + HttpHeaderError::InvalidValue { + name: "x-tenant".to_owned() + } + ); + assert!(!err.to_string().contains(value), "{err}"); + } + assert!(header("x-tenant", "acme corp\tA-1").is_ok()); + let bearer = |token: &str| { + transport + .clone() + .with_bearer(id_effect_config::Secret::new(token.to_owned())) + .map(|_| ()) + }; + for token in ["", "tok\r\nx-rpc: 0"] { + let err = bearer(token).unwrap_err(); + assert_eq!( + err, + HttpHeaderError::InvalidValue { + name: "authorization".to_owned() + } + ); + assert!(!err.to_string().contains("tok")); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn http_transport_cannot_override_rpc_header() { + use rpc::service::HttpHeaderError; + let (transport, _, headers) = recording(true); + for name in ["x-rpc", "X-RPC", "Content-Type", "content-length", "Host"] { + assert_eq!( + transport.clone().with_header(name, "0").map(|_| ()), + Err(HttpHeaderError::Reserved(name.to_ascii_lowercase())), + "{name}" + ); + } + assert!( + HttpHeaderError::Reserved("x-rpc".to_owned()) + .to_string() + .contains("reserved") + ); + // A later value of a name replaces the earlier one; the CSRF header stays the transport's. + let transport = transport + .with_header("x-tenant", "a") + .unwrap() + .with_header("X-Tenant", "b") + .unwrap(); + let client = TodosService::client(transport); + assert_eq!( + run(client.add(NewTodo { title: "y".into() })) + .await + .unwrap() + .id, + 9 + ); + let sent = headers.lock().unwrap().clone(); + assert_eq!(sent.len(), 1); + assert_eq!(values(&sent[0], "x-rpc"), ["1"]); + assert_eq!(values(&sent[0], "content-type"), ["application/json"]); + assert_eq!(values(&sent[0], "x-tenant"), ["b"]); +} + +#[test] +fn http_transport_bearer_redacted_in_debug() { + let (transport, ..) = recording(false); + let transport = transport + .with_header("x-api-key", "plain-key-42") + .unwrap() + .with_bearer(id_effect_config::Secret::new("tok-very-secret".to_owned())) + .unwrap(); + let shown = format!("{transport:?}"); + assert!(!shown.contains("tok-very-secret"), "{shown}"); + assert!(!shown.contains("plain-key-42"), "{shown}"); + assert!(shown.contains("authorization") && shown.contains("x-api-key")); + let client = TodosService::client(transport); + assert!(!format!("{:#?}", client.transport()).contains("tok-very-secret")); +} + // --------------------------------------------------------------------------------------------- // Wire faults on the client side // --------------------------------------------------------------------------------------------- diff --git a/docs/MIGRATION-0.13.md b/docs/MIGRATION-0.13.md index 3d4bbf469..6bc6048f7 100644 --- a/docs/MIGRATION-0.13.md +++ b/docs/MIGRATION-0.13.md @@ -39,7 +39,23 @@ Owner: r13-f-h3-settings. ## RPC: `HttpTransport::with_header` / `with_bearer` -Owner: r13-f-client-batching. +Owner: r13-f-client-batching. Additive: `HttpTransport::with_header`, `with_bearer`, +`service::HttpHeaderError` and the provided method `RpcTransport::unary_effect` are new; code that +implements `RpcTransport` or builds an `HttpTransport` compiles unchanged. A wrapper +`HttpClient` that only added an `authorization` header can go: + +```rust,ignore +// before (0.12): a custom HttpClient that pushed the header onto every HttpRequest +// after (0.13) +let transport = HttpTransport::new(RpcClient::new(url), http) + .with_bearer(id_effect_config::Secret::new(token))?; +``` + +Behavior: a generated client over `HttpTransport` now sends the calls it makes in the same tick +(the children of one `all`, for example) as one batch request, like `RpcClient::call`. A server +whose `BatchConfig` caps a batch below 50 calls or 1 MiB refuses a larger window with its +Problem, given to each call of it; raise the server caps or call through +`RpcTransport::unary`, which still sends one call per request. ## Tool: `rpc::check::CheckArgs::allow_retired` From a47616889472856184d1c2f323be7aa83bbbdccb Mon Sep 17 00:00:00 2001 From: Tom Wieland Date: Wed, 21 Oct 2026 08:56:26 +0200 Subject: [PATCH 13/87] feat(events): EventLog::head and EventSubscription::from_now (r13-f-eventlog-head) F21: EventLog gains a provided head() (the position of the last event appended; the default reads the log to its end), overridden in one step by MemoryEventStore and SqlEventJournal (MAX(position)). id_effect_rpc's EventSubscription::from_now() starts a call without a cursor after the head, read when its stream is first pulled; no cursor still means after starting_after (default 0). Tests head_is_last_global_position (memory and pg, own container), head_default_impl_matches_override, subscription_from_now_skips_history; book ch33-08 paragraph. Signed-off-by: Tom Wieland Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + Cargo.lock | 1 + .../book/src/part6/ch33-08-subscriptions.md | 10 +- crates/id_effect_events/Cargo.toml | 3 + crates/id_effect_events/src/event_store.rs | 25 +++ crates/id_effect_events/src/sql_journal.rs | 30 ++++ crates/id_effect_events/src/subscription.rs | 25 +++ crates/id_effect_events/tests/head.rs | 162 ++++++++++++++++++ .../id_effect_rpc/src/subscription/events.rs | 159 +++++++++++++++-- 9 files changed, 403 insertions(+), 18 deletions(-) create mode 100644 crates/id_effect_events/tests/head.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bb98046e..d65c1fdd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ replacing the `Effect::from_ctx(|ctx| async move { ctx.bind(e).await })` workaround. A ready effect is returned as is; a program gets one `Provide` frame over the shared empty environment. Book: "Env-free effects under any environment" in part 2, chapter 4.3. +- `EventLog::head()` (r13-f-eventlog-head, 0.12 follow-up F21): the position of the last event + appended (`0` for an empty log). A provided method, so existing `EventLog` implementors compile + unchanged (the default reads the log to its end); `MemoryEventStore` and `SqlEventJournal` + answer in one step (`MAX(position)`). `id_effect_rpc`'s `EventSubscription::from_now()` starts a + call without a cursor after the head, read when the stream is first pulled; without it a call + still starts after `starting_after` (default `0`). Book: chapter 33.8. - `id_effect_i18n` core (r13-e9-4-i18n-core, D5; eco.i18n #1 #2 #7) over Project Fluent (`fluent-bundle` =0.16.0, `fluent-syntax` =0.12.0, `fluent-langneg` =0.13.1, `unic-langid` =0.9.6): `Catalog` of Fluent resources per locale, embedded (`include_str!`) or loaded from diff --git a/Cargo.lock b/Cargo.lock index efdd80033..db9d9f87f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2840,6 +2840,7 @@ version = "0.12.0" dependencies = [ "id_effect", "id_effect_sql", + "id_effect_test", "id_effect_tokio", "serde", "serde_json", diff --git a/crates/id_effect/book/src/part6/ch33-08-subscriptions.md b/crates/id_effect/book/src/part6/ch33-08-subscriptions.md index 4c0088e0b..44ef8d5ab 100644 --- a/crates/id_effect/book/src/part6/ch33-08-subscriptions.md +++ b/crates/id_effect/book/src/part6/ch33-08-subscriptions.md @@ -49,8 +49,14 @@ group.subscription( falls between them, and a resume from the last cursor neither skips nor repeats an event. - **Projection.** The function you pass sees every event in order and keeps the ones the subscriber should get. Dropped events still move the position forward. -- **No cursor** starts after `starting_after(n)`: `0` (the default) replays the whole log; the - log's head means "only what happens from now on". +- **No cursor** starts after `starting_after(n)`: `0` (the default) replays the whole log. + `from_now()` starts after the log's head instead — `EventLog::head()`, the position of the + last event appended, read when the call's stream is first pulled — so the subscriber gets only + what happens from then on. Items still carry their positions, so a client that reconnects + with its last cursor replays what it missed meanwhile; a cursor always wins over either start. + The in-memory store and `SqlEventJournal` read the head in one step; a log of your own that + only implements `read_all` gets a provided `head` that reads the log to its end, so override + it when the log can answer directly. ## Rules that keep it safe diff --git a/crates/id_effect_events/Cargo.toml b/crates/id_effect_events/Cargo.toml index e200491b2..b9f3a3c39 100644 --- a/crates/id_effect_events/Cargo.toml +++ b/crates/id_effect_events/Cargo.toml @@ -33,6 +33,9 @@ uuid = { version = "1", features = ["v4"] } [dev-dependencies] id_effect_tokio = { path = "../id_effect_tokio" } +# tests/head.rs: PostgreSQL in a container the test starts itself (`Shared`), never the +# ambient `DATABASE_URL`. Path-only: cargo publish drops path dev-deps without a version. +id_effect_test = { path = "../id_effect_test", features = ["sql"] } tempfile = "3" tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "time"] } diff --git a/crates/id_effect_events/src/event_store.rs b/crates/id_effect_events/src/event_store.rs index 3ebdde01e..128465044 100644 --- a/crates/id_effect_events/src/event_store.rs +++ b/crates/id_effect_events/src/event_store.rs @@ -258,6 +258,17 @@ impl EventLog for MemoryEventStore { ) }) } + + /// The number of events appended (positions are gap-free from 1), in O(1). + fn head(&self) -> Effect { + let streams = Arc::clone(&self.streams); + attempt(move || { + let guard = streams + .lock() + .map_err(|e| EventStoreError::Io(e.to_string()))?; + Ok(guard.log.len() as u64) + }) + } } /// JSON-lines journal on disk (one [`StoredEvent`] per line). One writer process at a time: @@ -541,6 +552,20 @@ mod tests { fn memory_default_store_is_empty() { let store = MemoryEventStore::::default(); assert_eq!(run_sync(store.latest_version("any")).expect("latest"), 0); + assert_eq!(run_sync(store.head()).expect("head"), 0); + } + + #[test] + fn file_journal_clones_share_the_file_and_an_empty_path_is_refused() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = FileJournal::::open(dir.path().join("events.jsonl")).expect("open"); + run_sync(store.clone().append("s", &[CounterEvt(1)])).expect("append through a clone"); + assert_eq!(run_sync(store.latest_version("s")).expect("latest"), 1); + // No parent directory to create, and nothing to open. + assert!(matches!( + FileJournal::::open(""), + Err(EventStoreError::Io(_)) + )); } #[test] diff --git a/crates/id_effect_events/src/sql_journal.rs b/crates/id_effect_events/src/sql_journal.rs index b4b3fdf92..3f322128f 100644 --- a/crates/id_effect_events/src/sql_journal.rs +++ b/crates/id_effect_events/src/sql_journal.rs @@ -345,6 +345,15 @@ impl EventLog Effect { + self + .client + .query(HEAD_SQL, &[]) + .map_err(EventStoreError::from) + .flat_map(|rows| ready(first_u64(&rows, "head"))) + } } #[cfg(test)] @@ -453,6 +462,27 @@ mod tests { )); } + #[test] + fn head_is_one_query_for_the_greatest_position() { + let test = connected(); + let journal = SqlEventJournal::::new(Arc::new(test.clone())); + assert_eq!(run_sync(journal.head()).expect("empty head"), 0); + test.script_query(HEAD_SQL, vec![SqlRow::from_pairs([("head", 12i64)])]); + assert_eq!(run_sync(journal.head()).expect("head"), 12); + let queries = test.recorded_queries(); + assert_eq!(queries.len(), 2); + assert!( + queries + .iter() + .all(|(sql, params)| sql == HEAD_SQL && params.is_empty()) + ); + test.script_query(HEAD_SQL, vec![SqlRow::from_pairs([("head", -1i64)])]); + assert!(matches!( + run_sync(journal.head()).expect_err("negative"), + EventStoreError::Io(_) + )); + } + #[test] fn stale_expected_version_rolls_back() { let test = connected(); diff --git a/crates/id_effect_events/src/subscription.rs b/crates/id_effect_events/src/subscription.rs index 4d3a7bf49..c51cacacf 100644 --- a/crates/id_effect_events/src/subscription.rs +++ b/crates/id_effect_events/src/subscription.rs @@ -40,6 +40,24 @@ pub trait EventLog: Send + Sync { /// Up to `limit` events with a position greater than `after` (`0`: from the start), in /// position order. fn read_all(&self, after: u64, limit: usize) -> Effect>, EventStoreError>; + + /// The log's head: the position of the last event appended (`0` while the log is empty). + /// Reading after it ([`read_all`](Self::read_all)`(head, _)`) yields only what is appended + /// later, which is how a reader starts "from now". + /// + /// The provided implementation reads the log to its end with `read_all(0, usize::MAX)` and + /// takes the greatest position: correct for every log, but it reads every event (time and + /// memory grow with the log, because the returned effect cannot borrow `self` to page). Logs + /// that know their head override it; [`MemoryEventStore`](crate::MemoryEventStore) and + /// [`SqlEventJournal`](crate::SqlEventJournal) answer in one step. + fn head(&self) -> Effect + where + E: Send + 'static, + { + self + .read_all(0, usize::MAX) + .map(|events| events.iter().map(|e| e.position).max().unwrap_or(0)) + } } /// A shared log is a log. @@ -47,6 +65,13 @@ impl + ?Sized> EventLog for Arc { fn read_all(&self, after: u64, limit: usize) -> Effect>, EventStoreError> { (**self).read_all(after, limit) } + + fn head(&self) -> Effect + where + E: Send + 'static, + { + (**self).head() + } } /// Where each named subscription got to in the [`EventLog`]. diff --git a/crates/id_effect_events/tests/head.rs b/crates/id_effect_events/tests/head.rs new file mode 100644 index 000000000..0c19093c4 --- /dev/null +++ b/crates/id_effect_events/tests/head.rs @@ -0,0 +1,162 @@ +//! `EventLog::head` (r13-f-eventlog-head, 0.12 follow-up F21): the position of the last event +//! appended, `0` while the log is empty. The in-memory store and the SQL journal answer in one +//! step; the provided implementation (a log that only implements `read_all`) gives the same +//! answer by reading the log to its end. +//! +//! The PostgreSQL test runs against a container this test binary starts itself (never the +//! ambient `DATABASE_URL`). Without Docker it prints `SKIP` and passes, unless +//! `ID_EFFECT_REQUIRE_PG=1` (or `ID_EFFECT_REQUIRE_DOCKER=1`), where it fails. + +use std::sync::Arc; + +use id_effect::{Cause, Effect, Exit}; +use id_effect_events::{ + EventLog, EventStore, EventStoreError, MemoryEventStore, RecordedEvent, SqlEventJournal, +}; +use id_effect_tokio::TokioRuntime; + +/// Runs `eff` on the test's tokio runtime (sqlx needs one): the typed outcome as a `Result`. +async fn run( + eff: Effect, +) -> Result { + match TokioRuntime::current().expect("tokio").run(eff).await { + Exit::Success(a) => Ok(a), + Exit::Failure(Cause::Fail(e)) => Err(e), + Exit::Failure(c) => panic!("unexpected cause: {c:?}"), + } +} + +/// A log that only implements `read_all`: its `head` is the provided one. +struct ReadAllOnly(L); + +impl> EventLog for ReadAllOnly { + fn read_all(&self, after: u64, limit: usize) -> Effect>, EventStoreError> { + self.0.read_all(after, limit) + } +} + +fn unique(name: &str) -> String { + format!("{name}-{}", uuid::Uuid::new_v4()) +} + +/// After appends to two streams, `head` is the position of the last event `read_all` returns, +/// and nothing lies after it; one more append moves it by the number of events appended. +async fn head_tracks_appends(store: S) +where + S: EventStore + EventLog + Clone + 'static, +{ + let start = run(store.head()).await.expect("head"); + let (a, b) = (unique("a"), unique("b")); + run(store.append(&a, &[1, 2])).await.expect("a"); + run(store.append(&b, &[3])).await.expect("b"); + let head = run(store.head()).await.expect("head"); + assert_eq!(head, start + 3); + let all = run(store.read_all(0, usize::MAX)).await.expect("read_all"); + assert_eq!(all.last().map(|e| e.position), Some(head)); + assert_eq!(all.last().map(|e| e.stream_id.as_str()), Some(b.as_str())); + assert!( + run(store.read_all(head, 10)) + .await + .expect("after head") + .is_empty() + ); + + run(store.append(&a, &[4, 5])).await.expect("a again"); + let moved = run(store.head()).await.expect("moved"); + assert_eq!(moved, head + 2); + let tail = run(store.read_all(head, 10)).await.expect("tail"); + assert_eq!( + tail.iter().map(|e| e.event.payload).collect::>(), + [4, 5] + ); + + // Through `Arc` and `dyn`, and by the provided implementation: the same head. + let shared: Arc> = Arc::new(store.clone()); + assert_eq!(run(shared.head()).await.expect("dyn"), moved); + assert_eq!( + run(ReadAllOnly(store).head()).await.expect("provided"), + moved + ); +} + +mod memory { + use super::*; + + #[tokio::test(flavor = "multi_thread")] + async fn head_is_last_global_position() { + let store = MemoryEventStore::::new(); + assert_eq!(run(store.head()).await.expect("empty"), 0); + head_tracks_appends(store).await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn head_default_impl_matches_override() { + let store = MemoryEventStore::::new(); + let provided = ReadAllOnly(store.clone()); + assert_eq!(run(provided.head()).await.expect("empty"), 0); + for (n, stream) in ["x", "y", "x", "z", "y"].into_iter().enumerate() { + let values: Vec = (0..=i64::try_from(n).expect("small")).collect(); + run(store.append(stream, &values)).await.expect("append"); + let over = run(store.head()).await.expect("override"); + assert_eq!(run(provided.head()).await.expect("provided"), over); + } + assert_eq!(run(store.head()).await.expect("head"), 15); + + // The provided head fails as its read fails. + struct Broken; + impl EventLog for Broken { + fn read_all(&self, _: u64, _: usize) -> Effect>, EventStoreError> { + id_effect::fail(EventStoreError::Io("down".into())) + } + } + assert_eq!( + run(Broken.head()).await.expect_err("broken"), + EventStoreError::Io("down".into()) + ); + } +} + +mod pg { + use super::*; + use id_effect::HNil; + use id_effect_test::{ContainerError, Postgres, Shared, docker_available}; + + static PG: Shared = Shared::new(Postgres::new); + + fn pg_required() -> bool { + ["ID_EFFECT_REQUIRE_PG", "ID_EFFECT_REQUIRE_DOCKER"] + .iter() + .any(|var| std::env::var(var).is_ok_and(|v| v == "1")) + } + + /// A journal on this binary's own PostgreSQL container, or `None` (SKIP) without Docker. + async fn pg_journal(test: &str) -> Option> { + if let Err(e) = tokio::task::spawn_blocking(docker_available) + .await + .expect("probe") + { + assert!( + !pg_required(), + "{test}: ID_EFFECT_REQUIRE_PG=1 but Docker is unavailable: {e}" + ); + eprintln!("SKIP {test}: {e} (set ID_EFFECT_REQUIRE_PG=1 to require PostgreSQL)"); + return None; + } + let pg = run(PG.get::()) + .await + .expect("start PostgreSQL"); + let env = run(pg.sql_layer::().build()) + .await + .expect("connect"); + let journal = SqlEventJournal::from_service(&env.0); + run(journal.create_table()).await.expect("journal table"); + Some(journal) + } + + #[tokio::test(flavor = "multi_thread")] + async fn head_is_last_global_position() { + if let Some(journal) = pg_journal("pg::head_is_last_global_position").await { + head_tracks_appends(journal).await; + } + } +} diff --git a/crates/id_effect_rpc/src/subscription/events.rs b/crates/id_effect_rpc/src/subscription/events.rs index 00b536522..e7bdf8a69 100644 --- a/crates/id_effect_rpc/src/subscription/events.rs +++ b/crates/id_effect_rpc/src/subscription/events.rs @@ -22,7 +22,9 @@ //! correlation id, answered as `InternalError { id }`. The client resumes from its last cursor. //! //! Without a cursor a call starts after [`EventSubscription::starting_after`] (default `0`: the -//! whole log). +//! whole log), or, after [`EventSubscription::from_now`], after the log's +//! [`head`](EventLog::head) when the call's stream is first pulled (only what is appended from +//! then on). A call that names a cursor always starts after it. //! //! ```rust //! use id_effect::{DynEnv, Exit, Never, fail, schema, succeed}; @@ -66,7 +68,7 @@ //! ``` use std::fmt; -use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use id_effect::{Ctx, DynEnv, Effect, Stream, die, sleep, succeed}; @@ -86,10 +88,19 @@ struct BookSubscriptionsDoctests; pub struct EventSubscription { log: Arc>, config: TailConfig, - start: u64, + start: Start, readers: Arc, } +/// Where a call without a cursor starts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Start { + /// After this position (`0`: the whole log). + After(u64), + /// After the log's head, read when the call's stream is first pulled. + Head, +} + impl Clone for EventSubscription { fn clone(&self) -> Self { Self { @@ -122,7 +133,7 @@ impl EventSubscription { Self { log, config: TailConfig::default(), - start: 0, + start: Start::After(0), readers: Arc::new(AtomicUsize::new(0)), } } @@ -134,11 +145,23 @@ impl EventSubscription { self } - /// Where a call without a cursor starts: after `position` (`0`, the default: the whole log; - /// the log's head: only what is appended from now on). + /// Where a call without a cursor starts: after `position` (`0`, the default: the whole log). + /// Replaces an earlier [`from_now`](Self::from_now). #[must_use] pub fn starting_after(mut self, position: u64) -> Self { - self.start = position; + self.start = Start::After(position); + self + } + + /// A call without a cursor starts after the log's [`head`](EventLog::head), read when its + /// stream is first pulled: it skips the history and delivers only what is appended from then + /// on. Items still carry their positions as cursors, so a resume from the last one replays + /// what was missed while disconnected. Replaces an earlier + /// [`starting_after`](Self::starting_after); a failed head read ends the call as a defect, + /// like a failed read. + #[must_use] + pub fn from_now(mut self) -> Self { + self.start = Start::Head; self } @@ -200,11 +223,16 @@ impl EventSubscription { E: Send + 'static, F: FnMut(RecordedEvent) -> Option + Send + 'static, { - let from = position_after(after, CursorSource::Events)?.unwrap_or(self.start); + let start = position_after(after, CursorSource::Events)?.map_or(self.start, Start::After); + let (from, at_head) = match start { + Start::After(position) => (position, false), + Start::Head => (0, true), + }; let reader = Reader { log: Arc::clone(&self.log), config: self.config, position: Arc::new(AtomicU64::new(from)), + at_head: Arc::new(AtomicBool::new(at_head)), project: Arc::new(Mutex::new(project)), _open: OpenReader::new(Arc::clone(&self.readers)), }; @@ -228,11 +256,14 @@ impl Drop for OpenReader { } } -/// The state of one stream: the last position delivered and the projection. +/// The state of one stream: the last position delivered, whether it still has to start at the +/// log's head (a [`from_now`](EventSubscription::from_now) call before its first read), and the +/// projection. struct Reader { log: Arc>, config: TailConfig, position: Arc, + at_head: Arc, project: Arc>, _open: OpenReader, } @@ -249,17 +280,22 @@ where E: Send + 'static, F: FnMut(RecordedEvent) -> Option + Send + 'static, { - let from = self.position.load(Ordering::Acquire); let position = Arc::clone(&self.position); let project = Arc::clone(&self.project); let interval = self.config.poll_interval(); - on_dyn_env(self.log.read_all(from, self.config.batch_size())) - .catch(move |error| { - die(format!( - "subscription: reading the event log after position {from} failed: {error}" - )) + let (log, batch_size) = (Arc::clone(&self.log), self.config.batch_size()); + self + .start() + .flat_map(move |from| { + on_dyn_env(log.read_all(from, batch_size)) + .catch(move |error| { + die(format!( + "subscription: reading the event log after position {from} failed: {error}" + )) + }) + .map(move |events| (from, events)) }) - .flat_map(move |events| { + .flat_map(move |(from, events)| { // Only what lies after the last position, in increasing order: a log that answered // out of order can never make the stream repeat or rewind. let mut last = from; @@ -281,6 +317,26 @@ where succeed(Some(fresh.into_iter().filter_map(&mut *project).collect())) }) } + + /// The position to read after: the last one delivered, or, before the first read of a + /// [`from_now`](EventSubscription::from_now) call, the log's head (stored as the position). + fn start(&self) -> Effect { + if !self.at_head.load(Ordering::Acquire) { + return succeed(self.position.load(Ordering::Acquire)); + } + let (position, at_head) = (Arc::clone(&self.position), Arc::clone(&self.at_head)); + on_dyn_env(self.log.head()) + .catch(|error| { + die(format!( + "subscription: reading the event log head failed: {error}" + )) + }) + .map(move |head| { + position.store(head, Ordering::Release); + at_head.store(false, Ordering::Release); + head + }) + } } /// Run a log effect (environment-free) on the subscription's fiber, under its `DynEnv`. @@ -342,6 +398,77 @@ mod tests { assert!(format!("{cause:?}").contains("reading the event log")); } + /// A log whose head cannot be read. + struct Headless; + + impl EventLog for Headless { + fn read_all(&self, _: u64, _: usize) -> Effect>, EventStoreError> { + succeed(Vec::new()) + } + + fn head(&self) -> Effect { + fail(EventStoreError::Io("no head".into())) + } + } + + #[test] + fn subscription_from_now_skips_history() { + let store = MemoryEventStore::::new(); + let _ = store.append("a", &[1, 2]).block_on(); + let feed = EventSubscription::new(store.clone()) + .with_config(TailConfig::new().with_poll_interval(std::time::Duration::from_millis(10))) + .from_now(); + assert!(format!("{feed:?}").contains("Head")); + let subscription = feed + .subscribe::(None, |e| Some(e.event.payload)) + .unwrap(); + + // The head is read when the stream is first pulled; what is appended after it arrives. + let writer = std::thread::spawn({ + let store = store.clone(); + move || { + std::thread::sleep(std::time::Duration::from_millis(50)); + let _ = store.append("b", &[3]).block_on(); + let _ = store.append("a", &[4]).block_on(); + } + }); + let Exit::Success(items) = collect(subscription.into_stream(), 2) else { + panic!("items") + }; + writer.join().unwrap(); + let got: Vec<_> = items.iter().map(|i| (i.value, i.cursor)).collect(); + assert_eq!( + got, + [(3, Some(Cursor::events(3))), (4, Some(Cursor::events(4)))] + ); + + // A cursor still wins over `from_now`, and `starting_after` replaces it. + let Exit::Success(resumed) = collect(feed.events(Some(Cursor::events(1))).unwrap(), 1) else { + panic!("resumed") + }; + assert_eq!(resumed[0].position, 2); + let Exit::Success(all) = collect(feed.starting_after(0).events(None).unwrap(), 1) else { + panic!("all") + }; + assert_eq!(all[0].position, 1); + } + + #[test] + fn from_now_over_a_failing_head_or_tail_is_a_defect() { + let feed = EventSubscription::new(Headless).from_now(); + let Exit::Failure(cause) = collect(feed.events(None).unwrap(), 1) else { + panic!("the head read fails") + }; + assert!(format!("{cause:?}").contains("reading the event log head failed")); + + // `Unordered` keeps the provided `head` (3); its read after 3 fails. + let feed = EventSubscription::new(Unordered).from_now(); + let Exit::Failure(cause) = collect(feed.events(None).unwrap(), 1) else { + panic!("the read after the head fails") + }; + assert!(format!("{cause:?}").contains("after position 3")); + } + #[test] fn sources_are_configurable_and_count_their_readers() { let store = MemoryEventStore::::new(); From c728225aef39eded4fd4a3b1482abe91834a6f02 Mon Sep 17 00:00:00 2001 From: Tom Wieland Date: Wed, 21 Oct 2026 09:04:39 +0200 Subject: [PATCH 14/87] feat(ai): promote id_effect_ai to integration: provider-neutral chat, streaming, safe defaults (r13-e9-1-promote) Tier integration; publish = false and the PENDING_PROMOTION entry removed; every 0.13 feature and optional dependency declared once (openai, anthropic, ollama, cursor, tools, embeddings, mcp, mcp-http, test-util; rmcp =3.4.0 as the dev-only MCP interop peer). Module layout for the wave 2-3 leaves: chat, stream, vendors/{openai,anthropic,ollama}/ {mod,chat,embed}.rs, tools/, embeddings/, mcp/, and testing::ScriptedModel (replies, tool calls, streams and failures in order, requests recorded). Secure by default (ADR 0013, ADR 0015 B13 B14): all HTTP through the platform client (SSRF policy on provider base URLs, request timeout, response cap), API keys as Secret, prompts and completions never on spans unless AiConfig::dangerous_log_prompts (recorded), max_tokens 1024 by default, SSE decoded incrementally under byte, event and per-event caps (stream::decode_sse_event public for the fuzz target). ai.llm #1 #2 #7 re-evidenced by the wiremock, streaming, telemetry and cursor fixture tests. Book chapter 35 rewritten; CHANGELOG and MIGRATION-0.13 entries. Signed-off-by: Tom Wieland Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 45 ++ Cargo.lock | 99 ++++ crates/id_effect/book/src/SUMMARY.md | 2 +- crates/id_effect/book/src/part6/ch35-00-ai.md | 194 +++++-- crates/id_effect_ai/Cargo.toml | 92 ++- crates/id_effect_ai/README.md | 66 ++- .../examples/005_scripted_model.rs | 42 ++ .../id_effect_ai/examples/010_openai_once.rs | 27 +- .../examples/011_anthropic_once.rs | 38 +- crates/id_effect_ai/examples/ask_once.rs | 22 +- .../examples/mcp_server_template.rs | 14 +- crates/id_effect_ai/moon.yml | 2 +- crates/id_effect_ai/src/chat.rs | 389 +++++++++++++ crates/id_effect_ai/src/config.rs | 379 +++++++++++-- crates/id_effect_ai/src/cursor/agents.rs | 20 +- crates/id_effect_ai/src/cursor/models.rs | 7 +- crates/id_effect_ai/src/embeddings/mod.rs | 2 + crates/id_effect_ai/src/error.rs | 124 +++- crates/id_effect_ai/src/http_util.rs | 36 +- crates/id_effect_ai/src/lib.rs | 70 ++- crates/id_effect_ai/src/mcp/mod.rs | 3 + crates/id_effect_ai/src/model.rs | 137 ----- crates/id_effect_ai/src/sse.rs | 98 ---- crates/id_effect_ai/src/stream.rs | 533 ++++++++++++++++++ crates/id_effect_ai/src/streaming.rs | 74 --- crates/id_effect_ai/src/testing.rs | 251 +++++++++ crates/id_effect_ai/src/tools/mod.rs | 4 + crates/id_effect_ai/src/tracing_util.rs | 102 +++- crates/id_effect_ai/src/vendors/anthropic.rs | 247 -------- .../src/vendors/anthropic/chat.rs | 304 ++++++++++ .../src/vendors/anthropic/embed.rs | 3 + .../id_effect_ai/src/vendors/anthropic/mod.rs | 8 + crates/id_effect_ai/src/vendors/mod.rs | 154 ++++- .../id_effect_ai/src/vendors/ollama/chat.rs | 4 + .../id_effect_ai/src/vendors/ollama/embed.rs | 1 + crates/id_effect_ai/src/vendors/ollama/mod.rs | 11 + crates/id_effect_ai/src/vendors/openai.rs | 229 -------- .../id_effect_ai/src/vendors/openai/chat.rs | 293 ++++++++++ .../id_effect_ai/src/vendors/openai/embed.rs | 1 + crates/id_effect_ai/src/vendors/openai/mod.rs | 8 + .../id_effect_ai/tests/anthropic_wiremock.rs | 148 +++-- crates/id_effect_ai/tests/cursor_fixtures.rs | 193 +++++-- crates/id_effect_ai/tests/mock_complete.rs | 15 - crates/id_effect_ai/tests/model.rs | 44 +- crates/id_effect_ai/tests/openai_wiremock.rs | 225 ++++++-- crates/id_effect_ai/tests/scripted.rs | 67 +++ crates/id_effect_ai/tests/security.rs | 286 ++++++++++ crates/id_effect_ai/tests/streaming.rs | 35 +- crates/id_effect_ai/tests/telemetry.rs | 84 +++ docs/MIGRATION-0.13.md | 17 +- examples/book-snippets/Cargo.toml | 5 + scripts/validate-crate-packaging.sh | 10 +- 52 files changed, 4081 insertions(+), 1183 deletions(-) create mode 100644 crates/id_effect_ai/examples/005_scripted_model.rs create mode 100644 crates/id_effect_ai/src/chat.rs create mode 100644 crates/id_effect_ai/src/embeddings/mod.rs create mode 100644 crates/id_effect_ai/src/mcp/mod.rs delete mode 100644 crates/id_effect_ai/src/model.rs delete mode 100644 crates/id_effect_ai/src/sse.rs create mode 100644 crates/id_effect_ai/src/stream.rs delete mode 100644 crates/id_effect_ai/src/streaming.rs create mode 100644 crates/id_effect_ai/src/testing.rs create mode 100644 crates/id_effect_ai/src/tools/mod.rs delete mode 100644 crates/id_effect_ai/src/vendors/anthropic.rs create mode 100644 crates/id_effect_ai/src/vendors/anthropic/chat.rs create mode 100644 crates/id_effect_ai/src/vendors/anthropic/embed.rs create mode 100644 crates/id_effect_ai/src/vendors/anthropic/mod.rs create mode 100644 crates/id_effect_ai/src/vendors/ollama/chat.rs create mode 100644 crates/id_effect_ai/src/vendors/ollama/embed.rs create mode 100644 crates/id_effect_ai/src/vendors/ollama/mod.rs delete mode 100644 crates/id_effect_ai/src/vendors/openai.rs create mode 100644 crates/id_effect_ai/src/vendors/openai/chat.rs create mode 100644 crates/id_effect_ai/src/vendors/openai/embed.rs create mode 100644 crates/id_effect_ai/src/vendors/openai/mod.rs delete mode 100644 crates/id_effect_ai/tests/mock_complete.rs create mode 100644 crates/id_effect_ai/tests/scripted.rs create mode 100644 crates/id_effect_ai/tests/security.rs create mode 100644 crates/id_effect_ai/tests/telemetry.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d65c1fdd5..5059f3e42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,51 @@ ### Added +- `id_effect_ai` promoted to tier `integration` and published (r13-e9-1-promote, D6; ai.llm #1 + #2 #7): provider-neutral chat (`chat`: `LanguageModel`, `ChatRequest::new` / `user` with a + per-request `max_tokens`, `ChatResponse` with `Usage`, `stop_reason` and `ToolCall`s) and + streaming (`stream`: SSE decoded incrementally from the platform client's body, one chunk per + pull; `stream::decode_sse_event` for the `ai_sse_event` fuzz target), OpenAI and Anthropic + adapters under `vendors/{openai,anthropic}/chat.rs`, retries (now also Anthropic's 529), and an + `ai.request` span with vendor, operation, model and token usage. `testing::ScriptedModel` + (feature `test-util`, D8) replays replies, tool calls, streams and failures in order and records + every request. Every 0.13 feature is declared once (`openai`, `anthropic`, `ollama`, `cursor`, + `tools`, `embeddings`, `mcp`, `mcp-http`, `test-util`), with `tools/`, `embeddings/`, `mcp/` + and the `vendors/*/embed.rs` modules in place for the wave 2–3 leaves. Secure by default (ADR + 0013, ADR 0015 B13 B14): every call goes through id_effect_platform's client, so the SSRF policy + applies to provider base URLs (`AiError::Blocked`); API keys are `Secret` and redacted in + `Debug`; prompts and completions are never logged or put on spans unless + `AiConfig::dangerous_log_prompts()` is taken (recorded as `DANGEROUS_LOG_PROMPTS`); `AiLimits` + gives every request `max_tokens` 1024, a 60 s timeout and a 4 MiB response cap, and a stream + 16 MiB, 65 536 events and 1 MiB per event (`AiError::LimitExceeded`, the stream is closed). The + Cursor agents client stays behind the non-default `cursor` feature. API changes from the labs + crate in [MIGRATION-0.13.md](docs/MIGRATION-0.13.md); book chapter 35 rewritten. +- Relations and eager loading in `id_effect_sql` (r13-e3-3-relations, data.orm #3): + `#[entity(belongs_to(..))]`, `has_many(..)` and `many_to_many(.., through = "..")` generate + `Relation` constructors (`Order::lines()`); `Repository::with(rel)` returns an + `EagerRepository` whose `find` / `find_all` / `find_by` / `find_page` / `load` return `Loaded` + values, loading each relation in one `IN (…)` query per relation per level (no N+1; keys + deduplicated, chunked at 1 000), nested with `Relation::with` at most `MAX_EAGER_DEPTH` (3) + levels, on PostgreSQL, MySQL and SQLite. Secure by default (ADR 0013 R1): children are read + with the child entity's tenant, soft-delete and custom filters (inside the join for N:M), so a + relation never returns another tenant's row, and a tenant-scoped child without a tenant fails + closed before any query. `Entity::FIELDS` (`FieldMeta`: column, type, nullable, + `#[entity(editable)]`, `#[entity(label = "..")]`, relation), `Entity::RELATIONS` + (`RelationMeta`) and `Entity::LABEL`, all defaulted so hand-written impls compile unchanged. + `repository.rs` becomes `repository/{mod,relations}.rs`; every public path is kept. Book: + chapter 28.6. +- Server-rendered UI in `id_effect_axum`, feature `ui` (r13-e9-5-views, D3; web.ui #1 #2 #6, margin + #5): maud (`=0.27.0`) `Markup` as a response on typed routes; `ui::Component` with `Slots` + (default and named slots, composable); `ui::Page` + `ui::LayoutLayer` / `ui::BasicLayout` answer + the full page to browsers and the fragment to htmx (`HX-Request`, `Vary: HX-Request`); htmx + helpers `HxRequest`, `HxTarget`, `HxTrigger`, `HxRedirect` (same-origin paths only), + `HxReswap`, `HxRetarget`; htmx 2.0.11 vendored (0BSD, sha384 in + `crates/id_effect_axum/assets/README.md`) and served by `ui::htmx::asset_route()`, loaded with + `integrity` and `htmx-config` `allowEval: false`, `selfRequestsOnly: true` (pages keep + `script-src 'self'`); the layout sends the masked CSRF token on every htmx request + (`hx-headers`); `ui::HtmlForm` / `ui::FormState` re-render forms with `Schema` decode errors + bound to their fields and translatable message keys; raw HTML only through `ui::trusted_html` + (grep test over the axum and admin sources). Example `095_htmx_todo`; book chapter 29.10. - `Effect::any_env` and `LocalEffect::any_env` (r13-f-any-env, F52 / D14): run an env-free effect (`R = HNil`) under any environment `R`, HList or not (a bare `DynEnv`, a generic `R`), replacing the `Effect::from_ctx(|ctx| async move { ctx.bind(e).await })` workaround. A ready diff --git a/Cargo.lock b/Cargo.lock index db9d9f87f..9d5ae21f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -673,6 +673,7 @@ dependencies = [ "cli_minimal", "http-body-util", "id_effect", + "id_effect_ai", "id_effect_auth", "id_effect_authz", "id_effect_axum", @@ -2668,13 +2669,21 @@ version = "0.12.0" name = "id_effect_ai" version = "0.12.0" dependencies = [ + "axum", "base64 0.22.1", "clap", + "futures-util", + "http", "id_effect", + "id_effect_ai", + "id_effect_authz", + "id_effect_axum", "id_effect_cli", "id_effect_config", "id_effect_platform", "id_effect_tokio", + "rand 0.9.5", + "rmcp", "serde", "serde_json", "thiserror 2.0.21", @@ -4464,6 +4473,12 @@ dependencies = [ "phc", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pear" version = "0.2.9" @@ -5235,6 +5250,52 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b23c62fe489ac1d401ab32688cfacac3737a8978dc3343e5361464c7724fd3cb" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "indexmap 2.14.2", + "pastey", + "pin-project-lite", + "rand 0.10.3", + "reqwest", + "rmcp-macros", + "schemars 1.2.3", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f55ef2d84752d676d735604548fcb65811504e23330d85670b0eff5f8735144" +dependencies = [ + "darling 0.24.1", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.9", +] + [[package]] name = "rsa" version = "0.9.10" @@ -5475,8 +5536,10 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] @@ -5487,6 +5550,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.9", +] + [[package]] name = "sea-query" version = "1.0.2" @@ -5599,6 +5674,17 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "serde_json" version = "1.0.154" @@ -6118,6 +6204,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stats_alloc" version = "0.1.10" diff --git a/crates/id_effect/book/src/SUMMARY.md b/crates/id_effect/book/src/SUMMARY.md index bbd46b1a2..d73c586d8 100644 --- a/crates/id_effect/book/src/SUMMARY.md +++ b/crates/id_effect/book/src/SUMMARY.md @@ -204,7 +204,7 @@ - [DX, generators, and deploy](./part6/ch34-00-dx-ship.md) - [Doctor and the layer graph](./part6/ch34-01-doctor-graph.md) - [The dev loop](./part6/ch34-02-dev.md) -- [AI and MCP (`id_effect_ai`, labs)](./part6/ch35-00-ai.md) +- [AI and MCP (`id_effect_ai`)](./part6/ch35-00-ai.md) # Part VII: Process Mesh diff --git a/crates/id_effect/book/src/part6/ch35-00-ai.md b/crates/id_effect/book/src/part6/ch35-00-ai.md index f85999b04..66c30a142 100644 --- a/crates/id_effect/book/src/part6/ch35-00-ai.md +++ b/crates/id_effect/book/src/part6/ch35-00-ai.md @@ -1,57 +1,175 @@ # AI and MCP -> **Labs crate — unpublished, no API guarantees ([ADR 0010](https://github.com/Industrial/id_effect/blob/main/docs/adrs/0010-crate-tiers.md)).** -> `id_effect_ai` lives in [`labs/id_effect_ai`](https://github.com/Industrial/id_effect/tree/main/labs/id_effect_ai). -> Use it via a path or git dependency on `labs/`; it is not on crates.io. - -Phase H adds `id_effect_ai` — vendor-neutral LLM traits over Phase A `HttpClient`, with feature-gated vendors. - -## Language model trait - - -```rust,ignore -use id_effect_ai::{ChatRequest, LanguageModel, complete}; +`id_effect_ai` talks to language models the way the rest of the stack talks to databases: the +model is a capability in the typed environment, the vendor is a layer chosen at the edge, every +request goes through the platform HTTP client, and tests run against a deterministic double +instead of a vendor. The crate is tier `integration` ([ADR 0010](https://github.com/Industrial/id_effect/blob/main/docs/adrs/0010-crate-tiers.md) +Amendment 9) and published from `crates/id_effect_ai`. + +| Feature | What it adds | +|---------|--------------| +| `openai` | `OpenAiLanguageModel`: Chat Completions (`/v1/chat/completions`), ChatGPT models and OpenAI-compatible servers | +| `anthropic` | `AnthropicLanguageModel`: the Messages API (`/v1/messages`) | +| `test-util` | `testing::ScriptedModel`, the deterministic double | +| `cursor` | `CursorAgentsClient` for Cursor Cloud Agents (non-default) | +| `tools`, `embeddings`, `mcp`, `mcp-http` | tool calling, embeddings and MCP (the following chapters) | + +## Chat + +`LanguageModel` has two methods, `complete` (a buffered `ChatResponse`) and `complete_stream`. +A program does not hold a model; it asks for the capability `LanguageModelService` with +`complete::(req)` and returns an `Effect`: + +```rust +use id_effect::{Effect, HCons, HNil}; +use id_effect_ai::{ + AiError, ChatMessage, ChatRequest, LanguageModelService, complete, provide_mock_language_model, +}; + +type Env = HCons; + +fn greet(name: &str) -> Effect { + let req = ChatRequest::new( + "gpt-4o-mini", + vec![ChatMessage::system("Answer in one line."), ChatMessage::user(format!("Greet {name}"))], + ); + complete::(req).map(|resp| resp.content) +} + +// `provide_mock_language_model` echoes the last user message; production provides a vendor. +let out = greet("Ada") + .provide_layer(provide_mock_language_model::()) + .block_on() + .into_result() + .expect("greet"); +assert_eq!(out, "echo:Greet Ada"); ``` -`LanguageModel` is a capability (`LanguageModelService`) in the typed environment; `complete::(req)` reads it from `R` and returns an `Effect`. Provide `provide_mock_language_model::()` in tests; wire OpenAI or Anthropic at the edge with `provide_openai_language_model::(client, config)` / `provide_anthropic_language_model::(..)` — each returns `Result, E>, AiError>`: +At the edge the vendor layer replaces the mock. `AiConfig::from_env()` reads the API keys +(`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`) and base URLs, and `http_client()` builds the platform +client the adapter sends through: - -```rust,ignore -use id_effect::{HCons, HNil}; -use id_effect_ai::{AiConfig, LanguageModelService, complete, provide_openai_language_model}; +```rust,no_run +use id_effect_ai::{AiConfig, AiError, provide_anthropic_language_model, provide_openai_language_model}; -let layer = provide_openai_language_model::(http_client, AiConfig::from_env())?; -let resp = complete::, _>(req) - .provide_layer(layer); +let cfg = AiConfig::from_env(); +let openai = provide_openai_language_model::(cfg.http_client(), cfg.clone())?; +let claude = provide_anthropic_language_model::(cfg.http_client(), cfg)?; +# let _ = (openai, claude); +# Ok::<(), AiError>(()) ``` -OpenAI and ChatGPT share one adapter (`feature = "openai"`) — model IDs like `gpt-4o` select the model. +A `ChatResponse` carries the text, the token `usage` (`input_tokens`, `output_tokens`), the +vendor's `stop_reason` (`length` or `max_tokens` means the cap was reached) and, for tool calling, +`tool_calls`. Anthropic's system messages map to its top-level `system` field. ## Streaming -`complete_stream` returns an `Effect, AiError, R>` — token deltas map to chunks for sinks and UI bridges. - -## Anthropic Claude - -`feature = "anthropic"` uses the Messages API (`/v1/messages`) with SSE streaming. System prompts map to the top-level `system` field. - -## Cursor Cloud Agents +`complete_stream` returns an `id_effect::Stream`: each chunk is a text +delta, and the last one has `done` set. The vendor adapters decode the server-sent events +incrementally, one body chunk per pull, so the first tokens arrive before the response ends: -`feature = "cursor"` exposes `CursorAgentsClient` — a **separate** capability from `LanguageModel`: +```rust +use id_effect::{HCons, HNil}; +use id_effect_ai::{AiError, ChatRequest, LanguageModelService, complete_stream, provide_mock_language_model}; + +let text: String = complete_stream::, _>(ChatRequest::user("m", "stream me")) + .provide_layer(provide_mock_language_model::()) + .flat_map(|stream| stream.run_collect()) + .block_on() + .into_result() + .expect("stream") + .iter() + .map(|chunk| chunk.delta.as_str()) + .collect(); +assert_eq!(text, "echo:stream me"); +``` -- `list_models()` — `GET /v1/models` -- `create_agent(prompt)` — `POST /v1/agents` -- `send_followup(agent_id, prompt)` — `POST /v1/agents/{id}/runs` -- `wait_until_idle(agent_id, run_id)` — poll until terminal status +## Retries, spans and usage + +Statuses 429, 502, 503, 504 and Anthropic's 529 ("overloaded") are retried with exponential +backoff (`retry_transient_ai_http`); 401/403 become `AiError::Unauthorized` and are not retried. +Every call runs inside an `ai.request` span with the attributes `ai.vendor`, `ai.operation` and +`ai.model`, and a completion adds `ai.usage.input_tokens`, `ai.usage.output_tokens` and +`ai.stop_reason`, so the OpenTelemetry bridge (chapter 27) exports cost and latency per model +without any prompt text. + +## Safety defaults + +The adapters are secure by default ([ADR 0013](https://github.com/Industrial/id_effect/blob/main/docs/adrs/0013-secure-by-default.md), +ADR 0015 B13 and B14): + +- **One HTTP client.** Every request goes through `id_effect_platform`'s client, so the 0.11 SSRF + policy applies to every provider base URL: a loopback, private, link-local or metadata address + fails with `AiError::Blocked` before anything is sent, and redirects are not followed. A + self-hosted endpoint (Ollama, a vLLM box) is allowed by name, not by turning the guard off. +- **Secrets stay secret.** API keys are `id_effect_config::Secret`; `Debug` on the config and on + the models prints ``. +- **Prompts are not telemetry.** Prompts and completions are never logged or put on a span. + `AiConfig::dangerous_log_prompts()` turns that on for debugging, and records + `DANGEROUS_LOG_PROMPTS` in the posture registry, so the prod posture check refuses to start + until the operator accepts it. +- **Everything is bounded** (`AiLimits`): every request carries `max_tokens` (1024 unless the + request sets its own), a 60 s timeout, a 4 MiB response cap; a stream is capped at 16 MiB and + 65 536 events, and one event at 1 MiB. Over a cap the call fails with + `AiError::LimitExceeded` and the stream is closed. + +```rust +use std::time::Duration; +use id_effect_ai::{AiConfig, AiLimits}; +use id_effect_platform::http::HttpClientConfig; + +let cfg = AiConfig::default() + .with_openai_api_key("sk-test") + .with_openai_base_url("http://ollama.internal:11434") + // Allow the one self-hosted host; every other private address stays refused. + .with_http(HttpClientConfig::default().allow_hosts(["ollama.internal"])) + .with_limits( + AiLimits::default() + .with_max_tokens(256) + .with_request_timeout(Duration::from_secs(20)), + ); +assert!(!format!("{cfg:?}").contains("sk-test")); +assert!(!cfg.logs_prompts()); +``` -Cursor uses HTTP Basic auth (`CURSOR_API_KEY`). +## Testing with `ScriptedModel` -## Configuration and secrets +No test calls a vendor (ADR 0015 D8). `testing::ScriptedModel` (feature `test-util`) is a +`LanguageModel` that answers from a script, in order: text replies, full responses, tool calls, +streams of deltas and failures. It records every request, so a test asserts both what the +program asked and what it did with the answer: -Load keys via `AiConfig::from_env()`. API keys are `Secret` from `id_effect_config` — never log `.expose()` output. +```rust +use id_effect::{HCons, HNil}; +use id_effect_ai::testing::ScriptedModel; +use id_effect_ai::{AiError, ChatRequest, LanguageModelService, complete}; + +type Env = HCons; + +let model = ScriptedModel::new() + .reply("invoice") + .tool_call("call-1", "lookup_customer", serde_json::json!({ "id": 42 })) + .fail(AiError::Timeout); +let ask = |text: &str| { + complete::(ChatRequest::user("any", text)) + .provide_layer(model.layer::()) + .block_on() + .into_result() +}; + +assert_eq!(ask("classify").expect("reply").content, "invoice"); +assert_eq!(ask("who?").expect("tool call").tool_calls[0].name, "lookup_customer"); +assert!(ask("again").is_err()); +assert_eq!(model.requests()[0].messages[0].content, "classify"); +``` -Transient HTTP failures (429/502/503/504) retry via `Schedule` in `retry_transient_ai_http`. +The vendor adapters themselves are tested against recorded fixtures (wiremock) for both vendors, +buffered and streamed. -## MCP server template +## Cursor Cloud Agents -`cargo run --manifest-path labs/Cargo.toml -p id_effect_ai --example mcp_server_template` scaffolds a minimal MCP JSON-RPC loop over stdio using the same `Effect` + capability patterns. +`feature = "cursor"` exposes `CursorAgentsClient`, a capability separate from `LanguageModel`: +`list_models`, `create_agent`, `send_followup`, `get_run` and `wait_until_idle` (polls until the +run is terminal or the timeout passes). It authenticates with HTTP Basic (`CURSOR_API_KEY`) and +goes through the same platform client and limits. diff --git a/crates/id_effect_ai/Cargo.toml b/crates/id_effect_ai/Cargo.toml index ce79dfcd0..03e0c2355 100644 --- a/crates/id_effect_ai/Cargo.toml +++ b/crates/id_effect_ai/Cargo.toml @@ -1,11 +1,10 @@ [package] -publish = false name = "id_effect_ai" version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true -description = "Vendor-neutral LLM client traits and streaming for id_effect" +description = "Provider-neutral LLM chat, streaming and test doubles for id_effect, secure by default" repository.workspace = true readme = "README.md" keywords = ["effect", "ai", "llm", "streaming", "mcp"] @@ -15,33 +14,110 @@ categories = ["api-bindings", "asynchronous"] unsafe_code = "forbid" unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } +# Every 0.13 feature and optional dependency is declared here once (r13-e9-1-promote), so the +# wave 2–3 leaves (tools, embeddings, MCP) add modules behind them without editing this file. [features] default = [] +# Chat vendors (ai.llm #1): OpenAI Chat Completions and the Anthropic Messages API. openai = [] anthropic = [] +# Ollama (embeddings, r13-e9-1-embeddings; its chat speaks the OpenAI wire format). +ollama = [] +# Cursor Cloud Agents (D6: non-default, kept with its fixture tests). cursor = [] -full = ["openai", "anthropic", "cursor"] +# Typed tool calling, structured output and the bounded agent loop (r13-e9-1-tools): tool +# parameters are exported as JSON Schema through id_effect's `schema-serde`. +tools = ["id_effect/schema-serde"] +# The `Embedder` capability (r13-e9-1-embeddings). +embeddings = [] +# MCP server and client over stdio (r13-e9-1-mcp); tools come from the `tools` feature. +mcp = ["tools", "dep:tokio"] +# MCP Streamable HTTP: an id_effect_axum route behind a typed guard (r13-e9-1-mcp). +mcp-http = [ + "mcp", + "dep:axum", + "dep:futures-util", + "dep:http", + "dep:id_effect_authz", + "dep:id_effect_axum", + "dep:rand", + "id_effect_axum?/sse", +] +# `testing::ScriptedModel`: deterministic replies, tool calls and streams for tests (D8). +test-util = [] +full = [ + "openai", + "anthropic", + "ollama", + "cursor", + "tools", + "embeddings", + "mcp", + "mcp-http", +] [dependencies] id_effect = { path = "../id_effect", version = "0.12.0" } id_effect_config = { path = "../id_effect_config", version = "0.12.0" } +# Every HTTP call goes through the platform client: SSRF policy, timeouts, body caps (ADR 0013). id_effect_platform = { path = "../id_effect_platform", version = "0.12.0" } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" base64 = "0.22" +# Optional dependencies of the features above (the pins id_effect_rpc already uses). +axum = { version = "0.8.9", optional = true, features = ["json"] } +futures-util = { version = "=0.3.34", optional = true, default-features = false, features = [ + "std", +] } +http = { version = "1", optional = true } +id_effect_authz = { path = "../id_effect_authz", version = "0.12.0", optional = true } +id_effect_axum = { path = "../id_effect_axum", version = "0.12.0", optional = true } +# 128-bit random identifiers the MCP server mints (ADR 0015 §7). +rand = { version = "0.9", optional = true } +# The MCP stdio transport. +tokio = { version = "1.50.0", optional = true, features = [ + "io-std", + "io-util", + "rt", + "sync", + "time", +] } [dev-dependencies] -tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "time"] } -id_effect_tokio = { path = "../id_effect_tokio" } +# Path-only dev-dependencies (cargo publish resolves path+version dev-deps on crates.io). +# Self dev-dependency, so the integration tests see `testing::ScriptedModel`. +id_effect_ai = { path = ".", features = ["test-util"] } id_effect_cli = { path = "../id_effect_cli", features = ["clap"] } +id_effect_tokio = { path = "../id_effect_tokio" } +tokio = { version = "1.50.0", features = [ + "macros", + "rt-multi-thread", + "sync", + "time", +] } wiremock = "0.6" clap = { version = "4", features = ["derive"] } +# The MCP interop peer in tests, both directions (D7: dev-dependency only, exact pin; ADR 0015 §7). +rmcp = { version = "=3.4.0", default-features = false, features = [ + "client", + "macros", + "reqwest", + "server", + "transport-io", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-server", +] } [[example]] name = "mcp_server_template" path = "examples/mcp_server_template.rs" +[[example]] +name = "005_scripted_model" +path = "examples/005_scripted_model.rs" +required-features = ["test-util"] + [[example]] name = "010_openai_once" path = "examples/010_openai_once.rs" @@ -64,6 +140,6 @@ required-features = ["cursor"] [package.metadata.id_effect] # ADR 0010 crate tier — checked by scripts/tier-conformance.sh. Amendment 9 (0.13): moved out of -# labs/ by r13-t0-skeletons; transitional `merge` (publish = false, listed in PENDING_PROMOTION of -# scripts/validate-crate-packaging.sh) until r13-e9-1-promote brings it to `integration`. -tier = "merge" +# labs/ by r13-t0-skeletons and promoted to `integration` by r13-e9-1-promote; it moves to +# `platform` when the Pipeline reference app depends on it (r13-w6-pipeline, check P). +tier = "integration" diff --git a/crates/id_effect_ai/README.md b/crates/id_effect_ai/README.md index 608d29cf7..89a8addb5 100644 --- a/crates/id_effect_ai/README.md +++ b/crates/id_effect_ai/README.md @@ -1,41 +1,58 @@ # id_effect_ai -Vendor-neutral LLM client traits and multi-vendor HTTP adapters for `id_effect`. +Provider-neutral LLM chat, streaming and deterministic test doubles for `id_effect`, secure by +default. Tier `integration` ([ADR 0010](../../docs/adrs/0010-crate-tiers.md) Amendment 9). ## Features -| Cargo feature | Vendor | API | -|---------------|--------|-----| -| `openai` | OpenAI / ChatGPT models | `LanguageModel` via `/v1/chat/completions` | -| `anthropic` | Claude | `LanguageModel` via `/v1/messages` | -| `cursor` | Cursor Cloud Agents | `CursorAgentsClient` (native agents API) | -| `full` | All of the above | — | +| Cargo feature | What | API | +|---------------|------|-----| +| `openai` | OpenAI / ChatGPT models, OpenAI-compatible servers | `OpenAiLanguageModel` (`/v1/chat/completions`) | +| `anthropic` | Claude | `AnthropicLanguageModel` (`/v1/messages`) | +| `ollama` | Ollama (embeddings) | `vendors::ollama` | +| `cursor` | Cursor Cloud Agents (non-default) | `CursorAgentsClient` | +| `test-util` | Deterministic doubles | `testing::ScriptedModel` | +| `tools`, `embeddings`, `mcp`, `mcp-http` | Tool calling, embeddings, MCP | `tools`, `embeddings`, `mcp` | +| `full` | All of the above but `test-util` | — | ## Quick start ```rust use id_effect::{HCons, HNil}; use id_effect_ai::{ - AiConfig, AiError, ChatMessage, ChatRequest, ChatRole, LanguageModelService, complete, - provide_openai_language_model, + AiConfig, AiError, ChatRequest, LanguageModelService, complete, provide_openai_language_model, }; -use id_effect_platform::http::ReqwestHttpClient; -use std::sync::Arc; let cfg = AiConfig::from_env(); -let client = Arc::new(ReqwestHttpClient::default_client()); -let layer = provide_openai_language_model::(client, cfg)?; -let req = ChatRequest { - model: "gpt-4o-mini".into(), - messages: vec![ChatMessage { role: ChatRole::User, content: "Hello".into() }], -}; +let layer = provide_openai_language_model::(cfg.http_client(), cfg)?; // `id_effect` effect; `block_on` at the program edge (or `TokioRuntime::run` in async code). -let resp = complete::, _>(req) +let resp = complete::, _>(ChatRequest::user("gpt-4o-mini", "Hello")) .provide_layer(layer) .block_on() .into_result(); ``` +Streaming: `complete_stream` returns an `id_effect::Stream`, decoded +incrementally from the vendor's server-sent events. + +## Secure by default (ADR 0013) + +- Every HTTP call goes through `id_effect_platform`'s client: the SSRF policy refuses loopback, + private, link-local and metadata addresses (a self-hosted endpoint is allowed explicitly with + `HttpClientConfig::allow_hosts` in `AiConfig::http`), redirects are not followed. +- API keys are `id_effect_config::Secret`: `Debug` prints ``. +- Prompts and completions are never logged or put on spans; `AiConfig::dangerous_log_prompts()` + turns that on and records itself in the posture registry. +- `AiLimits`: `max_tokens` 1024 by default on every request, a 60 s request timeout, a 4 MiB + response cap, and 16 MiB / 65 536 events per stream; a stream over a cap fails with + `AiError::LimitExceeded` and is closed. + +## Testing without a vendor + +`testing::ScriptedModel` (feature `test-util`) answers from a script: replies, tool calls, streams +and failures, in order, and records every request. CI never calls a vendor (ADR 0015 D8); the +vendor adapters are tested against wiremock fixtures. + ## Environment variables | Variable | Purpose | @@ -45,16 +62,17 @@ let resp = complete::, _>(req) | `CURSOR_API_KEY` | Cursor Cloud Agents | | `OPENAI_BASE_URL` | Optional OpenAI-compatible base URL | | `ANTHROPIC_BASE_URL` | Optional Anthropic base URL | +| `OLLAMA_BASE_URL` | Ollama base URL (default `http://localhost:11434`) | | `CURSOR_BASE_URL` | Optional Cursor API base (default `https://api.cursor.com`) | - -Secrets use `id_effect_config::Secret` — never logged. +| `AI_MAX_TOKENS` | Default `max_tokens` (`ANTHROPIC_MAX_TOKENS` is still read) | ## Examples ```bash +cargo run -p id_effect_ai --features test-util --example 005_scripted_model cargo run -p id_effect_ai --features openai --example 010_openai_once -- "Hello" cargo run -p id_effect_ai --features anthropic --example 011_anthropic_once -- "Hello" -cargo run -p id_effect_ai --features full --example ask_once -- --vendor openai --prompt "Hi" +cargo run -p id_effect_ai --features openai,anthropic --example ask_once -- --vendor openai --prompt "Hi" cargo run -p id_effect_ai --features cursor --example 020_cursor_agent_once -- "Summarize README" cargo run -p id_effect_ai --example mcp_server_template ``` @@ -65,8 +83,4 @@ cargo run -p id_effect_ai --example mcp_server_template cargo nextest run -p id_effect_ai --all-features ``` -Wiremock integration tests run without live API keys. Optional live tests require env vars. - -## Design - -See [adr-ai-vendor-layout.md](../../docs/platform/adrs/adr-ai-vendor-layout.md) and [phase-h-ai.md](../../docs/effect-ts-parity/phases/phase-h-ai.md). +The book chapter is "AI and MCP" (part 6, chapter 35). diff --git a/crates/id_effect_ai/examples/005_scripted_model.rs b/crates/id_effect_ai/examples/005_scripted_model.rs new file mode 100644 index 000000000..f99e83bde --- /dev/null +++ b/crates/id_effect_ai/examples/005_scripted_model.rs @@ -0,0 +1,42 @@ +//! Deterministic AI with `testing::ScriptedModel` (feature `test-util`): no API key, no network. +//! +//! The program only knows the `LanguageModel` capability; the scripted layer answers in order, +//! streams included, and records what was asked. +//! +//! Run: `cargo run -p id_effect_ai --features test-util --example 005_scripted_model` + +use id_effect::{Effect, HCons, HNil}; +use id_effect_ai::testing::ScriptedModel; +use id_effect_ai::{AiError, ChatRequest, LanguageModelService, complete, complete_stream}; + +type Env = HCons; + +/// Classify a document, then stream a summary of it. +fn triage(doc: &str) -> Effect<(String, String), AiError, Env> { + let summary_req = ChatRequest::user("any-model", format!("Summarize: {doc}")); + complete::(ChatRequest::user("any-model", format!("Classify: {doc}"))).flat_map( + move |label| { + complete_stream::(summary_req) + .flat_map(|stream| stream.run_collect().narrow()) + .map(move |chunks| { + let summary: String = chunks.iter().map(|c| c.delta.as_str()).collect(); + (label.content, summary) + }) + }, + ) +} + +fn main() { + let model = ScriptedModel::new() + .reply("invoice") + .stream(["An invoice ", "for 3 items."]); + let (label, summary) = triage("INV-7: 3 items") + .provide_layer(model.layer::()) + .block_on() + .into_result() + .expect("scripted run"); + println!("label: {label}\nsummary: {summary}"); + println!("requests: {}", model.requests().len()); + assert_eq!(label, "invoice"); + assert_eq!(summary, "An invoice for 3 items."); +} diff --git a/crates/id_effect_ai/examples/010_openai_once.rs b/crates/id_effect_ai/examples/010_openai_once.rs index 397c10603..20797a5d8 100644 --- a/crates/id_effect_ai/examples/010_openai_once.rs +++ b/crates/id_effect_ai/examples/010_openai_once.rs @@ -1,28 +1,23 @@ //! One-shot OpenAI chat completion (requires `OPENAI_API_KEY`). +//! +//! The model is built from `AiConfig::from_env`, so the request goes through the platform HTTP +//! client with its SSRF policy and timeouts, and carries the default `max_tokens` cap. use id_effect::{HCons, HNil}; use id_effect_ai::{ - AiConfig, AiError, ChatMessage, ChatRequest, ChatRole, LanguageModelService, complete, - provide_openai_language_model, + AiConfig, AiError, ChatRequest, LanguageModelService, complete, provide_openai_language_model, }; -use id_effect_platform::http::ReqwestHttpClient; -use std::sync::Arc; fn main() { let cfg = AiConfig::from_env(); - let client = Arc::new(ReqwestHttpClient::default_client()); - let layer = provide_openai_language_model::(client, cfg).expect("openai provider"); - let req = ChatRequest { - model: std::env::var("OPENAI_MODEL").unwrap_or_else(|_| "gpt-4o-mini".into()), - messages: vec![ChatMessage { - role: ChatRole::User, - content: std::env::args() - .nth(1) - .unwrap_or_else(|| "Say hello in one word.".into()), - }], - }; + let layer = + provide_openai_language_model::(cfg.http_client(), cfg).expect("openai provider"); + let model = std::env::var("OPENAI_MODEL").unwrap_or_else(|_| "gpt-4o-mini".into()); + let prompt = std::env::args() + .nth(1) + .unwrap_or_else(|| "Say hello in one word.".into()); // The program edge: run on the default runtime (its tokio drivers serve reqwest). - let resp = complete::, _>(req) + let resp = complete::, _>(ChatRequest::user(model, prompt)) .provide_layer(layer) .block_on() .into_result() diff --git a/crates/id_effect_ai/examples/011_anthropic_once.rs b/crates/id_effect_ai/examples/011_anthropic_once.rs index fc02577d6..6c3c504ca 100644 --- a/crates/id_effect_ai/examples/011_anthropic_once.rs +++ b/crates/id_effect_ai/examples/011_anthropic_once.rs @@ -1,31 +1,25 @@ -//! One-shot Anthropic chat completion (requires `ANTHROPIC_API_KEY`). +//! One-shot Anthropic Messages completion, streamed (requires `ANTHROPIC_API_KEY`). use id_effect::{HCons, HNil}; use id_effect_ai::{ - AiConfig, AiError, ChatMessage, ChatRequest, ChatRole, LanguageModelService, complete, + AiConfig, AiError, ChatRequest, LanguageModelService, complete_stream, provide_anthropic_language_model, }; -use id_effect_platform::http::ReqwestHttpClient; -use std::sync::Arc; fn main() { let cfg = AiConfig::from_env(); - let client = Arc::new(ReqwestHttpClient::default_client()); - let layer = provide_anthropic_language_model::(client, cfg).expect("anthropic provider"); - let req = ChatRequest { - model: std::env::var("ANTHROPIC_MODEL").unwrap_or_else(|_| "claude-sonnet-4-20250514".into()), - messages: vec![ChatMessage { - role: ChatRole::User, - content: std::env::args() - .nth(1) - .unwrap_or_else(|| "Say hello in one word.".into()), - }], - }; - // The program edge: run on the default runtime (its tokio drivers serve reqwest). - let resp = complete::, _>(req) - .provide_layer(layer) - .block_on() - .into_result() - .expect("complete"); - println!("{}", resp.content); + let layer = provide_anthropic_language_model::(cfg.http_client(), cfg) + .expect("anthropic provider"); + let model = + std::env::var("ANTHROPIC_MODEL").unwrap_or_else(|_| "claude-sonnet-4-20250514".into()); + let prompt = std::env::args() + .nth(1) + .unwrap_or_else(|| "Say hello in one word.".into()); + let program = + complete_stream::, _>(ChatRequest::user(model, prompt)) + .provide_layer(layer) + .flat_map(|stream| stream.run_collect()); + let chunks = program.block_on().into_result().expect("stream"); + let text: String = chunks.iter().map(|c| c.delta.as_str()).collect(); + println!("{text}"); } diff --git a/crates/id_effect_ai/examples/ask_once.rs b/crates/id_effect_ai/examples/ask_once.rs index 09dc92af3..12d3ef5d3 100644 --- a/crates/id_effect_ai/examples/ask_once.rs +++ b/crates/id_effect_ai/examples/ask_once.rs @@ -3,13 +3,11 @@ use clap::{Parser, ValueEnum}; use id_effect::{Effect, HCons, HNil, succeed}; use id_effect_ai::{ - AiConfig, AiError, ChatMessage, ChatRequest, ChatRole, LanguageModelService, complete, - provide_anthropic_language_model, provide_openai_language_model, + AiConfig, AiError, ChatRequest, LanguageModelService, complete, provide_anthropic_language_model, + provide_openai_language_model, }; use id_effect_cli::{RunMainConfig, run_main}; -use id_effect_platform::http::ReqwestHttpClient; use std::process::ExitCode; -use std::sync::Arc; #[derive(Parser)] #[command(name = "ask_once")] @@ -20,6 +18,9 @@ struct Args { model: Option, #[arg(long, default_value = "Hello")] prompt: String, + /// Cap on generated tokens (the default cap applies when omitted). + #[arg(long)] + max_tokens: Option, } #[derive(Clone, Copy, ValueEnum)] @@ -31,7 +32,7 @@ enum Vendor { fn main() -> ExitCode { let args = Args::parse(); let cfg = AiConfig::from_env(); - let client = Arc::new(ReqwestHttpClient::default_client()); + let client = cfg.http_client(); let layer = match args.vendor { Vendor::Openai => provide_openai_language_model::(client, cfg).expect("openai"), Vendor::Anthropic => { @@ -44,13 +45,10 @@ fn main() -> ExitCode { .model .unwrap_or_else(|| "claude-sonnet-4-20250514".into()), }; - let req = ChatRequest { - model, - messages: vec![ChatMessage { - role: ChatRole::User, - content: args.prompt, - }], - }; + let mut req = ChatRequest::user(model, args.prompt); + if let Some(n) = args.max_tokens { + req = req.with_max_tokens(n); + } type Env = HCons; let effect: Effect<(), AiError, Env> = complete(req).flat_map(|resp| { println!("{}", resp.content); diff --git a/crates/id_effect_ai/examples/mcp_server_template.rs b/crates/id_effect_ai/examples/mcp_server_template.rs index ebc2ad665..83524de75 100644 --- a/crates/id_effect_ai/examples/mcp_server_template.rs +++ b/crates/id_effect_ai/examples/mcp_server_template.rs @@ -5,7 +5,7 @@ //! Send one line: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}` use id_effect::{Effect, succeed}; -use id_effect_ai::{ChatMessage, ChatRequest, ChatRole, LanguageModel, MockLanguageModel}; +use id_effect_ai::{ChatRequest, LanguageModel, MockLanguageModel}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::io::{self, BufRead, Write}; @@ -34,16 +34,8 @@ fn handle_line(line: &str) -> Result { serde_json::json!({"tools": [{"name": "echo", "description": "Echo user text via MockLanguageModel"}]}) } "tools/call" => { - let model = MockLanguageModel::echo(); - let chat = ChatRequest { - model: "mock".into(), - messages: vec![ChatMessage { - role: ChatRole::User, - content: "mcp".into(), - }], - }; - let resp = model - .complete(chat) + let resp = MockLanguageModel::echo() + .complete(ChatRequest::user("mock", "mcp")) .block_on() .into_result() .map_err(|cause| cause.pretty())?; diff --git a/crates/id_effect_ai/moon.yml b/crates/id_effect_ai/moon.yml index f3beb3b41..3dd100c0d 100644 --- a/crates/id_effect_ai/moon.yml +++ b/crates/id_effect_ai/moon.yml @@ -21,7 +21,7 @@ tasks: outputStyle: buffer-only-failure test: deps: ['effect:ci-format', '^:test', '~:clippy'] - command: ['cargo', 'nextest', 'run', '-p', 'id_effect_ai'] + command: ['cargo', 'nextest', 'run', '-p', 'id_effect_ai', '--all-features'] inputs: ['Cargo.toml', 'src/**/*.rs', 'tests/**/*.rs'] options: {cache: true, outputStyle: buffer-only-failure} build: diff --git a/crates/id_effect_ai/src/chat.rs b/crates/id_effect_ai/src/chat.rs new file mode 100644 index 000000000..25e86e40b --- /dev/null +++ b/crates/id_effect_ai/src/chat.rs @@ -0,0 +1,389 @@ +//! Provider-neutral chat: messages, requests, responses, token usage, and the [`LanguageModel`] +//! capability every vendor adapter (and every test double) implements. +//! +//! A program asks for the capability ([`LanguageModelService`]) in its environment and calls +//! [`complete`]; the edge provides it with a vendor layer +//! (`provide_openai_language_model`, `provide_anthropic_language_model`), the echo +//! [`provide_mock_language_model`], or a scripted model in tests (`testing::ScriptedModel`). +//! +//! ``` +//! use id_effect::{HCons, HNil}; +//! use id_effect_ai::{AiError, ChatRequest, LanguageModelService, complete, provide_mock_language_model}; +//! +//! let resp = complete::, _>(ChatRequest::user("mock", "hi")) +//! .provide_layer(provide_mock_language_model::()) +//! .block_on() +//! .into_result() +//! .expect("complete"); +//! assert_eq!(resp.content, "echo:hi"); +//! ``` + +use std::sync::Arc; + +use id_effect::{Effect, HCons, HList, HNil, Layer, Selector, Stream, hlist, need, suspend}; +use serde::{Deserialize, Serialize}; + +use crate::error::AiError; +use crate::http_util::from_result; +use crate::stream::{CompletionChunk, mock_chunk_stream}; + +/// Chat role label. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ChatRole { + /// System prompt. + System, + /// User message. + User, + /// Assistant reply. + Assistant, +} + +/// Single chat message. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChatMessage { + /// Role of the speaker. + pub role: ChatRole, + /// Message text. + pub content: String, +} + +impl ChatMessage { + /// A message from `role`. + #[inline] + pub fn new(role: ChatRole, content: impl Into) -> Self { + Self { + role, + content: content.into(), + } + } + + /// A system message. + #[inline] + pub fn system(content: impl Into) -> Self { + Self::new(ChatRole::System, content) + } + + /// A user message. + #[inline] + pub fn user(content: impl Into) -> Self { + Self::new(ChatRole::User, content) + } + + /// An assistant message. + #[inline] + pub fn assistant(content: impl Into) -> Self { + Self::new(ChatRole::Assistant, content) + } +} + +/// Request for a chat completion. Build it with [`ChatRequest::new`] or [`ChatRequest::user`] +/// (the struct is non-exhaustive, so fields can be added without a break). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ChatRequest { + /// Model id (vendor-specific string). + pub model: String, + /// Conversation messages. + pub messages: Vec, + /// Cap on generated tokens for this request. `None` sends the adapter's finite default + /// ([`AiLimits::max_tokens`](crate::AiLimits::max_tokens), 1024): no request goes out + /// without a cap (ADR 0013 R4). + #[serde(default)] + pub max_tokens: Option, +} + +impl ChatRequest { + /// A request for `model` over `messages`. + pub fn new(model: impl Into, messages: Vec) -> Self { + Self { + model: model.into(), + messages, + max_tokens: None, + } + } + + /// A one-message request: `text` from the user. + pub fn user(model: impl Into, text: impl Into) -> Self { + Self::new(model, vec![ChatMessage::user(text)]) + } + + /// Sets [`max_tokens`](Self::max_tokens). + #[must_use] + pub fn with_max_tokens(mut self, max_tokens: u32) -> Self { + self.max_tokens = Some(max_tokens); + self + } + + /// Validates the request: at least one message, and a `max_tokens` (when set) above 0. + pub fn validate(&self) -> Result<(), AiError> { + if self.messages.is_empty() { + return Err(AiError::EmptyRequest); + } + if self.max_tokens == Some(0) { + return Err(AiError::InvalidRequest( + "max_tokens must be at least 1".into(), + )); + } + Ok(()) + } + + /// The token cap to send: this request's, else `default`. + #[inline] + pub fn effective_max_tokens(&self, default: u32) -> u32 { + self.max_tokens.unwrap_or(default) + } +} + +/// Token usage reported by the vendor (zero when it reports none). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Usage { + /// Prompt tokens. + pub input_tokens: u32, + /// Generated tokens. + pub output_tokens: u32, +} + +impl Usage { + /// Usage of `input_tokens` prompt and `output_tokens` generated tokens. + #[inline] + pub fn new(input_tokens: u32, output_tokens: u32) -> Self { + Self { + input_tokens, + output_tokens, + } + } + + /// Prompt plus generated tokens (saturating). + #[inline] + pub fn total(&self) -> u32 { + self.input_tokens.saturating_add(self.output_tokens) + } +} + +/// A tool the model asked to call, in a provider-neutral shape: the tool's `name` and its JSON +/// `arguments`, which the caller decodes before running anything. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ToolCall { + /// The vendor's id of this call (echoed back with the tool's result). + pub id: String, + /// The tool name the model chose. + pub name: String, + /// The arguments as the model wrote them (untrusted input). + pub arguments: serde_json::Value, +} + +impl ToolCall { + /// A call of tool `name` with `arguments`. + pub fn new(id: impl Into, name: impl Into, arguments: serde_json::Value) -> Self { + Self { + id: id.into(), + name: name.into(), + arguments, + } + } +} + +/// Buffered completion response. Build it with [`ChatResponse::text`] (non-exhaustive). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ChatResponse { + /// Assistant text. + pub content: String, + /// Token usage. + pub usage: Usage, + /// Tool calls the model asked for (empty for a plain reply). + #[serde(default)] + pub tool_calls: Vec, + /// Why generation stopped, as the vendor reports it (`stop`, `length`, `end_turn`, + /// `max_tokens`, ...). + #[serde(default)] + pub stop_reason: Option, +} + +impl ChatResponse { + /// A reply with `content`, no usage, no tool calls. + pub fn text(content: impl Into) -> Self { + Self { + content: content.into(), + usage: Usage::default(), + tool_calls: Vec::new(), + stop_reason: None, + } + } + + /// Sets [`usage`](Self::usage). + #[must_use] + pub fn with_usage(mut self, usage: Usage) -> Self { + self.usage = usage; + self + } + + /// Sets [`tool_calls`](Self::tool_calls). + #[must_use] + pub fn with_tool_calls(mut self, tool_calls: Vec) -> Self { + self.tool_calls = tool_calls; + self + } + + /// Sets [`stop_reason`](Self::stop_reason). + #[must_use] + pub fn with_stop_reason(mut self, reason: impl Into) -> Self { + self.stop_reason = Some(reason.into()); + self + } +} + +/// Injectable language model service. +pub type LanguageModelService = Arc; + +/// Vendor-neutral language model capability. +pub trait LanguageModel: Send + Sync + 'static { + /// Buffered completion. + fn complete(&self, req: ChatRequest) -> Effect; + /// Streaming token chunks. + fn complete_stream(&self, req: ChatRequest) -> Effect, AiError>; +} + +/// A [`Layer`] providing `model` as the [`LanguageModelService`]. +pub fn provide_language_model( + model: impl LanguageModel, +) -> Layer, E> { + let model: LanguageModelService = Arc::new(model); + Layer::succeed(hlist![model]) +} + +/// Deterministic echo model for examples: replies with the last user message, prefixed +/// `echo:`. Tests that need scripted replies, tool calls or failures use +/// `testing::ScriptedModel` (feature `test-util`). +#[derive(Debug, Default, Clone)] +pub struct MockLanguageModel { + reply_prefix: String, +} + +impl MockLanguageModel { + /// Creates a mock that echoes the last user message. + #[inline] + pub fn echo() -> Self { + Self { + reply_prefix: "echo:".to_string(), + } + } + + fn last_user(req: &ChatRequest) -> Result { + req.validate()?; + req + .messages + .iter() + .rev() + .find(|m| m.role == ChatRole::User) + .map(|m| m.content.clone()) + .ok_or(AiError::EmptyRequest) + } +} + +impl LanguageModel for MockLanguageModel { + fn complete(&self, req: ChatRequest) -> Effect { + let prefix = self.reply_prefix.clone(); + suspend(move || { + from_result(Self::last_user(&req).map(|user| { + let content = format!("{prefix}{user}"); + let output_tokens = u32::try_from(content.len()).unwrap_or(u32::MAX); + ChatResponse::text(content).with_usage(Usage::new(0, output_tokens)) + })) + }) + } + + fn complete_stream(&self, req: ChatRequest) -> Effect, AiError> { + let prefix = self.reply_prefix.clone(); + suspend(move || { + from_result( + Self::last_user(&req).map(|user| mock_chunk_stream(&format!("{prefix}{user}"), 4)), + ) + }) + } +} + +/// Register mock language model provider: a [`Layer`] providing [`LanguageModelService`] +/// ([`MockLanguageModel::echo`]). +#[inline] +pub fn provide_mock_language_model() +-> Layer, E> { + provide_language_model(MockLanguageModel::echo()) +} + +/// Complete using installed [`LanguageModel`] capability. +#[inline] +pub fn complete(req: ChatRequest) -> Effect +where + R: Selector + HList, +{ + need::().flat_map(move |model| model.complete(req).narrow()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn message_constructors_set_roles() { + assert_eq!(ChatMessage::system("s").role, ChatRole::System); + assert_eq!(ChatMessage::user("u").role, ChatRole::User); + assert_eq!(ChatMessage::assistant("a").role, ChatRole::Assistant); + } + + #[test] + fn request_validation_and_max_tokens() { + let req = ChatRequest::user("m", "hi"); + assert_eq!(req.validate(), Ok(())); + assert_eq!(req.effective_max_tokens(9), 9); + let req = req.with_max_tokens(3); + assert_eq!(req.effective_max_tokens(9), 3); + assert!(matches!( + req.with_max_tokens(0).validate(), + Err(AiError::InvalidRequest(_)) + )); + assert_eq!( + ChatRequest::new("m", vec![]).validate(), + Err(AiError::EmptyRequest) + ); + } + + #[test] + fn request_deserializes_without_max_tokens() { + let req: ChatRequest = + serde_json::from_str(r#"{"model":"m","messages":[{"role":"User","content":"x"}]}"#) + .expect("decode"); + assert_eq!(req.max_tokens, None); + } + + #[test] + fn response_builders_and_usage() { + let call = ToolCall::new("c1", "lookup", serde_json::json!({"q": 1})); + let resp = ChatResponse::text("hi") + .with_usage(Usage::new(2, 3)) + .with_tool_calls(vec![call.clone()]) + .with_stop_reason("stop"); + assert_eq!(resp.usage.total(), 5); + assert_eq!(resp.tool_calls, vec![call]); + assert_eq!(resp.stop_reason.as_deref(), Some("stop")); + assert_eq!(Usage::new(u32::MAX, 1).total(), u32::MAX); + } + + #[test] + fn mock_without_user_message_is_empty_request() { + let req = ChatRequest::new("m", vec![ChatMessage::system("s")]); + let err = MockLanguageModel::echo() + .complete(req.clone()) + .block_on() + .into_result() + .unwrap_err(); + assert_eq!(err.fail_option(), Some(AiError::EmptyRequest)); + assert!( + MockLanguageModel::echo() + .complete_stream(req) + .block_on() + .into_result() + .is_err() + ); + } +} diff --git a/crates/id_effect_ai/src/config.rs b/crates/id_effect_ai/src/config.rs index 3302bc05a..500769b2e 100644 --- a/crates/id_effect_ai/src/config.rs +++ b/crates/id_effect_ai/src/config.rs @@ -1,24 +1,135 @@ -//! AI vendor configuration and API key loading. +//! AI vendor configuration: endpoints, API keys, limits and the HTTP client. +//! +//! Secure by default (ADR 0013): API keys are [`Secret`]s (never printed by `Debug`), every +//! request goes through id_effect_platform's client built from [`AiConfig::http`] (the SSRF +//! policy, timeouts and body caps of [`HttpClientConfig::default`]), [`AiLimits`] are finite, and +//! prompts and completions are not recorded unless [`AiConfig::dangerous_log_prompts`] is taken. +use std::sync::Arc; +use std::time::Duration; + +use id_effect::observability::posture::record_dangerous; use id_effect_config::Secret; +use id_effect_platform::http::{HttpClient, HttpClientConfig, ReqwestHttpClient}; + +use crate::error::AiError; + +/// Posture id of [`AiConfig::dangerous_log_prompts`] (what the prod posture check reads). +pub const DANGEROUS_LOG_PROMPTS: &str = "id_effect_ai::AiConfig::dangerous_log_prompts"; + +const REOPENS_LOG_PROMPTS: &str = "R3: prompts and completions are recorded in span annotations \ + (personal data and secrets in prompts reach the trace backend)"; + +/// Finite limits applied to every vendor call (ADR 0013 R4). None is infinite: each field is a +/// number, and the setters keep it at least 1. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct AiLimits { + /// Generated-token cap sent with a request that sets none ([`ChatRequest::max_tokens`](crate::ChatRequest::max_tokens)). + pub max_tokens: u32, + /// Total time for one request, connect to last byte (also bounds a stream). + pub request_timeout: Duration, + /// Cap on a buffered response body. + pub max_response_bytes: usize, + /// Cap on the bytes of one streamed response. + pub max_stream_bytes: usize, + /// Cap on the server-sent events of one streamed response. + pub max_stream_events: usize, +} + +impl AiLimits { + /// Default [`max_tokens`](Self::max_tokens): 1024. + pub const DEFAULT_MAX_TOKENS: u32 = 1024; + /// Default [`request_timeout`](Self::request_timeout): 60 s. + pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + /// Default [`max_response_bytes`](Self::max_response_bytes): 4 MiB. + pub const DEFAULT_MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024; + /// Default [`max_stream_bytes`](Self::max_stream_bytes): 16 MiB. + pub const DEFAULT_MAX_STREAM_BYTES: usize = 16 * 1024 * 1024; + /// Default [`max_stream_events`](Self::max_stream_events): 65 536. + pub const DEFAULT_MAX_STREAM_EVENTS: usize = 65_536; + + /// Replaces [`max_tokens`](Self::max_tokens) (at least 1). + #[must_use] + pub fn with_max_tokens(mut self, n: u32) -> Self { + self.max_tokens = n.max(1); + self + } -/// Vendor endpoints and API keys (secrets never logged via [`Secret`]). + /// Replaces [`request_timeout`](Self::request_timeout) (at least 1 ms). + #[must_use] + pub fn with_request_timeout(mut self, timeout: Duration) -> Self { + self.request_timeout = timeout.max(Duration::from_millis(1)); + self + } + + /// Replaces [`max_response_bytes`](Self::max_response_bytes) (at least 1). + #[must_use] + pub fn with_max_response_bytes(mut self, n: usize) -> Self { + self.max_response_bytes = n.max(1); + self + } + + /// Replaces [`max_stream_bytes`](Self::max_stream_bytes) (at least 1). + #[must_use] + pub fn with_max_stream_bytes(mut self, n: usize) -> Self { + self.max_stream_bytes = n.max(1); + self + } + + /// Replaces [`max_stream_events`](Self::max_stream_events) (at least 1). + #[must_use] + pub fn with_max_stream_events(mut self, n: usize) -> Self { + self.max_stream_events = n.max(1); + self + } +} + +impl Default for AiLimits { + fn default() -> Self { + Self { + max_tokens: Self::DEFAULT_MAX_TOKENS, + request_timeout: Self::DEFAULT_REQUEST_TIMEOUT, + max_response_bytes: Self::DEFAULT_MAX_RESPONSE_BYTES, + max_stream_bytes: Self::DEFAULT_MAX_STREAM_BYTES, + max_stream_events: Self::DEFAULT_MAX_STREAM_EVENTS, + } + } +} + +/// Vendor endpoints, API keys ([`Secret`], never printed), [`AiLimits`] and the HTTP client +/// configuration. +/// +/// ``` +/// use id_effect_ai::AiConfig; +/// +/// let cfg = AiConfig::default().with_openai_api_key("sk-test"); +/// assert!(!format!("{cfg:?}").contains("sk-test")); +/// assert_eq!(cfg.limits.max_tokens, 1024); +/// ``` #[derive(Debug, Clone)] pub struct AiConfig { /// OpenAI API key (`OPENAI_API_KEY`). pub openai_api_key: Option>, - /// OpenAI API base URL. + /// OpenAI API base URL (`OPENAI_BASE_URL`). pub openai_base_url: String, /// Anthropic API key (`ANTHROPIC_API_KEY`). pub anthropic_api_key: Option>, - /// Anthropic API base URL. + /// Anthropic API base URL (`ANTHROPIC_BASE_URL`). pub anthropic_base_url: String, + /// Ollama base URL (`OLLAMA_BASE_URL`). The default, `http://localhost:11434`, is a loopback + /// address: the SSRF policy refuses it until [`http`](Self::http) allows the host. + pub ollama_base_url: String, /// Cursor API key (`CURSOR_API_KEY`). pub cursor_api_key: Option>, - /// Cursor Cloud Agents API base URL. + /// Cursor Cloud Agents API base URL (`CURSOR_BASE_URL`). pub cursor_base_url: String, - /// Default max tokens for Anthropic requests. - pub anthropic_max_tokens: u32, + /// Finite limits of every call. + pub limits: AiLimits, + /// The platform HTTP client configuration [`http_client`](Self::http_client) builds from: + /// SSRF policy, connect timeout, redirects. + pub http: HttpClientConfig, + log_prompts: bool, } fn env_nonempty(key: &str) -> Option { @@ -32,66 +143,246 @@ impl Default for AiConfig { openai_base_url: "https://api.openai.com".to_string(), anthropic_api_key: None, anthropic_base_url: "https://api.anthropic.com".to_string(), + ollama_base_url: "http://localhost:11434".to_string(), cursor_api_key: None, cursor_base_url: "https://api.cursor.com".to_string(), - anthropic_max_tokens: 1024, + limits: AiLimits::default(), + http: HttpClientConfig::default(), + log_prompts: false, } } } impl AiConfig { - /// Load keys and optional base URL overrides from environment variables. + /// Load keys, base URL overrides and `AI_MAX_TOKENS` (`ANTHROPIC_MAX_TOKENS` is still read) + /// from environment variables; everything else keeps its default. pub fn from_env() -> Self { - let mut cfg = Self::default(); - if let Some(key) = env_nonempty("OPENAI_API_KEY") { - cfg.openai_api_key = Some(Secret::new(key)); - } - if let Some(url) = env_nonempty("OPENAI_BASE_URL") { - cfg.openai_base_url = url; - } - if let Some(key) = env_nonempty("ANTHROPIC_API_KEY") { - cfg.anthropic_api_key = Some(Secret::new(key)); - } - if let Some(url) = env_nonempty("ANTHROPIC_BASE_URL") { - cfg.anthropic_base_url = url; - } - if let Some(key) = env_nonempty("CURSOR_API_KEY") { - cfg.cursor_api_key = Some(Secret::new(key)); - } - if let Some(url) = env_nonempty("CURSOR_BASE_URL") { - cfg.cursor_base_url = url; + Self::from_lookup(env_nonempty) + } + + fn from_lookup(get: impl Fn(&str) -> Option) -> Self { + let mut cfg = Self { + openai_api_key: get("OPENAI_API_KEY").map(Secret::new), + anthropic_api_key: get("ANTHROPIC_API_KEY").map(Secret::new), + cursor_api_key: get("CURSOR_API_KEY").map(Secret::new), + ..Self::default() + }; + let urls = [ + ("OPENAI_BASE_URL", &mut cfg.openai_base_url), + ("ANTHROPIC_BASE_URL", &mut cfg.anthropic_base_url), + ("OLLAMA_BASE_URL", &mut cfg.ollama_base_url), + ("CURSOR_BASE_URL", &mut cfg.cursor_base_url), + ]; + for (key, slot) in urls { + if let Some(url) = get(key) { + *slot = url; + } } - if let Some(n) = env_nonempty("ANTHROPIC_MAX_TOKENS") - && let Ok(v) = n.parse() - { - cfg.anthropic_max_tokens = v; + let max_tokens = get("AI_MAX_TOKENS").or_else(|| get("ANTHROPIC_MAX_TOKENS")); + if let Some(n) = max_tokens.and_then(|n| n.parse().ok()) { + cfg.limits = cfg.limits.with_max_tokens(n); } cfg } + /// Sets the OpenAI API key. + #[must_use] + pub fn with_openai_api_key(mut self, key: impl Into) -> Self { + self.openai_api_key = Some(Secret::new(key.into())); + self + } + + /// Sets the OpenAI base URL. + #[must_use] + pub fn with_openai_base_url(mut self, url: impl Into) -> Self { + self.openai_base_url = url.into(); + self + } + + /// Sets the Anthropic API key. + #[must_use] + pub fn with_anthropic_api_key(mut self, key: impl Into) -> Self { + self.anthropic_api_key = Some(Secret::new(key.into())); + self + } + + /// Sets the Anthropic base URL. + #[must_use] + pub fn with_anthropic_base_url(mut self, url: impl Into) -> Self { + self.anthropic_base_url = url.into(); + self + } + + /// Sets the Ollama base URL. + #[must_use] + pub fn with_ollama_base_url(mut self, url: impl Into) -> Self { + self.ollama_base_url = url.into(); + self + } + + /// Sets the Cursor API key. + #[must_use] + pub fn with_cursor_api_key(mut self, key: impl Into) -> Self { + self.cursor_api_key = Some(Secret::new(key.into())); + self + } + + /// Sets the Cursor base URL. + #[must_use] + pub fn with_cursor_base_url(mut self, url: impl Into) -> Self { + self.cursor_base_url = url.into(); + self + } + + /// Replaces the [`AiLimits`]. + #[must_use] + pub fn with_limits(mut self, limits: AiLimits) -> Self { + self.limits = limits; + self + } + + /// Replaces the HTTP client configuration (for instance one whose SSRF policy allows a + /// self-hosted endpoint with [`HttpClientConfig::allow_hosts`]). + #[must_use] + pub fn with_http(mut self, http: HttpClientConfig) -> Self { + self.http = http; + self + } + + /// Records prompts and completions in the request span's annotations (`ai.prompt`, + /// `ai.completion`), which every span hook (the OpenTelemetry bridge, the tracing buffer) then + /// exports. Off by default (ADR 0013 R3): prompts carry personal data and secrets. Records + /// [`DANGEROUS_LOG_PROMPTS`] in the posture registry, so the prod posture check refuses to + /// start unless the operator accepted it. + #[must_use] + pub fn dangerous_log_prompts(mut self) -> Self { + record_dangerous(DANGEROUS_LOG_PROMPTS, REOPENS_LOG_PROMPTS); + self.log_prompts = true; + self + } + + /// Whether [`dangerous_log_prompts`](Self::dangerous_log_prompts) was taken. + #[inline] + pub fn logs_prompts(&self) -> bool { + self.log_prompts + } + + /// The platform HTTP client for [`http`](Self::http): the SSRF guard on every destination, + /// no redirects followed, the configured timeouts. + /// + /// # Panics + /// + /// As `ReqwestHttpClient::from_config`: if the TLS backend cannot be initialised. + pub fn http_client(&self) -> Arc { + Arc::new(ReqwestHttpClient::from_config(&self.http)) + } + /// Require OpenAI key or return error. pub fn require_openai_key(&self) -> Result<&Secret, AiError> { - self - .openai_api_key - .as_ref() - .ok_or_else(|| AiError::Upstream("OPENAI_API_KEY not set".into())) + require(self.openai_api_key.as_ref(), "OPENAI_API_KEY") } /// Require Anthropic key or return error. pub fn require_anthropic_key(&self) -> Result<&Secret, AiError> { - self - .anthropic_api_key - .as_ref() - .ok_or_else(|| AiError::Upstream("ANTHROPIC_API_KEY not set".into())) + require(self.anthropic_api_key.as_ref(), "ANTHROPIC_API_KEY") } /// Require Cursor key or return error. pub fn require_cursor_key(&self) -> Result<&Secret, AiError> { - self - .cursor_api_key - .as_ref() - .ok_or_else(|| AiError::Upstream("CURSOR_API_KEY not set".into())) + require(self.cursor_api_key.as_ref(), "CURSOR_API_KEY") } } -use crate::error::AiError; +fn require<'a>(key: Option<&'a Secret>, name: &str) -> Result<&'a Secret, AiError> { + key.ok_or_else(|| AiError::Upstream(format!("{name} not set"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn lookup(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + move |k| map.get(k).cloned() + } + + #[test] + fn from_lookup_reads_keys_urls_and_max_tokens() { + let cfg = AiConfig::from_lookup(lookup(&[ + ("OPENAI_API_KEY", "o"), + ("ANTHROPIC_API_KEY", "a"), + ("CURSOR_API_KEY", "c"), + ("OPENAI_BASE_URL", "https://o.example"), + ("ANTHROPIC_BASE_URL", "https://a.example"), + ("OLLAMA_BASE_URL", "https://l.example"), + ("CURSOR_BASE_URL", "https://c.example"), + ("AI_MAX_TOKENS", "77"), + ])); + assert_eq!(cfg.require_openai_key().unwrap().expose(), "o"); + assert_eq!(cfg.require_anthropic_key().unwrap().expose(), "a"); + assert_eq!(cfg.require_cursor_key().unwrap().expose(), "c"); + assert_eq!(cfg.openai_base_url, "https://o.example"); + assert_eq!(cfg.anthropic_base_url, "https://a.example"); + assert_eq!(cfg.ollama_base_url, "https://l.example"); + assert_eq!(cfg.cursor_base_url, "https://c.example"); + assert_eq!(cfg.limits.max_tokens, 77); + } + + #[test] + fn legacy_anthropic_max_tokens_and_bad_numbers() { + let cfg = AiConfig::from_lookup(lookup(&[("ANTHROPIC_MAX_TOKENS", "12")])); + assert_eq!(cfg.limits.max_tokens, 12); + let cfg = AiConfig::from_lookup(lookup(&[("AI_MAX_TOKENS", "lots")])); + assert_eq!(cfg.limits.max_tokens, AiLimits::DEFAULT_MAX_TOKENS); + let _ = AiConfig::from_env(); + } + + #[test] + fn missing_keys_are_errors() { + let cfg = AiConfig::default(); + assert!(cfg.require_openai_key().is_err()); + assert!(cfg.require_anthropic_key().is_err()); + assert!(cfg.require_cursor_key().is_err()); + assert!(!cfg.logs_prompts()); + } + + #[test] + fn builders_set_fields() { + let cfg = AiConfig::default() + .with_openai_api_key("o") + .with_openai_base_url("https://o") + .with_anthropic_api_key("a") + .with_anthropic_base_url("https://a") + .with_ollama_base_url("https://l") + .with_cursor_api_key("c") + .with_cursor_base_url("https://c") + .with_limits(AiLimits::default().with_max_tokens(5)) + .with_http(HttpClientConfig::default().allow_hosts(["127.0.0.1"])); + assert_eq!(cfg.openai_base_url, "https://o"); + assert_eq!(cfg.anthropic_base_url, "https://a"); + assert_eq!(cfg.ollama_base_url, "https://l"); + assert_eq!(cfg.cursor_base_url, "https://c"); + assert_eq!(cfg.limits.max_tokens, 5); + assert_eq!(cfg.http.ssrf.allow_hosts, vec!["127.0.0.1".to_string()]); + let _client = cfg.http_client(); + } + + #[test] + fn limit_setters_never_reach_zero() { + let limits = AiLimits::default() + .with_max_tokens(0) + .with_request_timeout(Duration::ZERO) + .with_max_response_bytes(0) + .with_max_stream_bytes(0) + .with_max_stream_events(0); + assert_eq!(limits.max_tokens, 1); + assert_eq!(limits.request_timeout, Duration::from_millis(1)); + assert_eq!(limits.max_response_bytes, 1); + assert_eq!(limits.max_stream_bytes, 1); + assert_eq!(limits.max_stream_events, 1); + } +} diff --git a/crates/id_effect_ai/src/cursor/agents.rs b/crates/id_effect_ai/src/cursor/agents.rs index 79789e7d8..37e16acb0 100644 --- a/crates/id_effect_ai/src/cursor/agents.rs +++ b/crates/id_effect_ai/src/cursor/agents.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use id_effect::{Effect, HCons, HNil, Layer, fail, hlist, sleep, succeed, suspend}; +use id_effect_platform::error::HttpError; use id_effect_platform::http::{HttpClient, HttpMethod, HttpRequest}; use serde::Deserialize; use serde::de::DeserializeOwned; @@ -72,7 +73,7 @@ impl HttpCursorAgentsClient { self .client .execute(req) - .map_err(|e| AiError::CursorAgents(format!("http: {e}"))) + .map_err(cursor_http_error) .flat_map(|resp| { from_result(Self::check_status(&resp.status, &resp.body).map(|()| resp.body)) }) @@ -89,8 +90,8 @@ impl HttpCursorAgentsClient { url: join_url(&this.config.cursor_base_url, &path), headers: vec![cursor_basic_auth_header(&key)], body: None, - timeout: None, - max_body_bytes: None, + timeout: Some(this.config.limits.request_timeout), + max_body_bytes: Some(this.config.limits.max_response_bytes), }) }) }) @@ -115,8 +116,8 @@ impl HttpCursorAgentsClient { ("Content-Type".to_string(), "application/json".to_string()), ], body: Some(bytes), - timeout: None, - max_body_bytes: None, + timeout: Some(this.config.limits.request_timeout), + max_body_bytes: Some(this.config.limits.max_response_bytes), }) }) }) @@ -148,6 +149,15 @@ impl HttpCursorAgentsClient { } } +/// A transport failure of a Cursor call: SSRF refusals, timeouts and caps keep their typed +/// variants, anything else is [`AiError::CursorAgents`]. +pub(crate) fn cursor_http_error(e: HttpError) -> AiError { + match AiError::from(e) { + AiError::Upstream(detail) => AiError::CursorAgents(detail), + other => other, + } +} + fn parse_json(bytes: &[u8]) -> Result { serde_json::from_slice(bytes).map_err(|e| AiError::InvalidJson(e.to_string())) } diff --git a/crates/id_effect_ai/src/cursor/models.rs b/crates/id_effect_ai/src/cursor/models.rs index dd908b05a..081f491c1 100644 --- a/crates/id_effect_ai/src/cursor/models.rs +++ b/crates/id_effect_ai/src/cursor/models.rs @@ -7,6 +7,7 @@ use id_effect_platform::http::{HttpClient, HttpMethod, HttpRequest, HttpResponse use serde::Deserialize; use crate::config::AiConfig; +use crate::cursor::agents::cursor_http_error; use crate::cursor::types::CursorModel; use crate::error::AiError; use crate::http_util::{check_status, cursor_basic_auth_header, from_result, join_url}; @@ -30,13 +31,13 @@ pub fn list_models( url: join_url(&config.cursor_base_url, "v1/models"), headers: vec![cursor_basic_auth_header(&key)], body: None, - timeout: None, - max_body_bytes: None, + timeout: Some(config.limits.request_timeout), + max_body_bytes: Some(config.limits.max_response_bytes), }; suspend(move || { client .execute(req) - .map_err(|e| AiError::CursorAgents(format!("http: {e}"))) + .map_err(cursor_http_error) .flat_map(|resp| from_result(parse_models(resp))) }) } diff --git a/crates/id_effect_ai/src/embeddings/mod.rs b/crates/id_effect_ai/src/embeddings/mod.rs new file mode 100644 index 000000000..9150ad0e1 --- /dev/null +++ b/crates/id_effect_ai/src/embeddings/mod.rs @@ -0,0 +1,2 @@ +//! Embeddings (`feature = "embeddings"`): the provider-neutral `Embedder` capability, with the +//! OpenAI and Ollama adapters under `vendors`, bounded batches and typed dimension checks. diff --git a/crates/id_effect_ai/src/error.rs b/crates/id_effect_ai/src/error.rs index ae3efaa60..af77d6518 100644 --- a/crates/id_effect_ai/src/error.rs +++ b/crates/id_effect_ai/src/error.rs @@ -1,13 +1,22 @@ //! AI client errors. +use id_effect_platform::error::HttpError; use thiserror::Error; +/// Upper bound on the upstream detail kept in an error (bytes, cut at a character boundary): a +/// vendor error body can be large and may echo request content. +pub(crate) const MAX_ERROR_DETAIL_BYTES: usize = 512; + /// Errors from LLM client operations. #[derive(Debug, Clone, Error, PartialEq, Eq)] +#[non_exhaustive] pub enum AiError { /// Empty prompt or message list. #[error("empty chat request")] EmptyRequest, + /// A request the adapter refuses before sending it (for example `max_tokens` of 0). + #[error("invalid request: {0}")] + InvalidRequest(String), /// Model returned no content. #[error("empty model response")] EmptyResponse, @@ -16,13 +25,13 @@ pub enum AiError { Transient { /// HTTP status code. status: u16, - /// Redacted detail. + /// Upstream detail, truncated. detail: String, }, /// Authentication failed. #[error("unauthorized")] Unauthorized, - /// Upstream HTTP or vendor failure (detail redacted for logs). + /// Upstream HTTP or vendor failure (detail truncated). #[error("upstream error: {0}")] Upstream(String), /// JSON parse failure. @@ -31,6 +40,23 @@ pub enum AiError { /// Cursor Agents API failure. #[error("cursor agents error: {0}")] CursorAgents(String), + /// The platform client's SSRF policy refused the destination (ADR 0013 R6): a provider base + /// URL on a loopback, private, link-local or metadata address, or a scheme other than + /// `http`/`https`. + #[error("request blocked: {0}")] + Blocked(String), + /// A finite limit was reached (ADR 0013 R4): `limit` names it (`response_bytes`, + /// `stream_bytes`, `stream_events`, `sse_event_bytes`) and `max` is its configured value. + #[error("limit exceeded: {limit} (max {max})")] + LimitExceeded { + /// Which limit. + limit: &'static str, + /// The configured maximum. + max: u64, + }, + /// The request did not complete within its timeout. + #[error("request timed out")] + Timeout, } impl AiError { @@ -40,13 +66,101 @@ impl AiError { matches!(self, Self::Transient { .. }) } - /// Map an HTTP status to an [`AiError`]. + /// Map an HTTP status to an [`AiError`] (the detail is truncated). pub fn from_http_status(status: u16, detail: impl Into) -> Self { - let detail = detail.into(); + let detail = truncate_detail(detail.into()); match status { 401 | 403 => Self::Unauthorized, - 429 | 502 | 503 | 504 => Self::Transient { status, detail }, + // 529: Anthropic's "overloaded". + 429 | 502 | 503 | 504 | 529 => Self::Transient { status, detail }, _ => Self::Upstream(format!("status {status}: {detail}")), } } } + +impl From for AiError { + /// A transport failure: a body over its cap is [`AiError::LimitExceeded`], an SSRF refusal + /// [`AiError::Blocked`], a timeout [`AiError::Timeout`], anything else + /// [`AiError::Upstream`]. + fn from(e: HttpError) -> Self { + match e { + HttpError::BodyTooLarge { max, .. } => Self::LimitExceeded { + limit: "response_bytes", + max: max as u64, + }, + HttpError::InvalidRequest(reason) if reason.contains("SSRF policy") => Self::Blocked(reason), + HttpError::Reqwest(e) if e.is_timeout() => Self::Timeout, + other => Self::Upstream(truncate_detail(format!("http: {other}"))), + } + } +} + +/// `detail` cut to [`MAX_ERROR_DETAIL_BYTES`] at a character boundary. +pub(crate) fn truncate_detail(mut detail: String) -> String { + if detail.len() > MAX_ERROR_DETAIL_BYTES { + let mut cut = MAX_ERROR_DETAIL_BYTES; + while !detail.is_char_boundary(cut) { + cut -= 1; + } + detail.truncate(cut); + detail.push('…'); + } + detail +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_mapping() { + assert_eq!(AiError::from_http_status(401, "x"), AiError::Unauthorized); + assert_eq!(AiError::from_http_status(403, "x"), AiError::Unauthorized); + assert!(AiError::from_http_status(429, "x").is_transient()); + assert!(AiError::from_http_status(503, "x").is_transient()); + assert_eq!( + AiError::from_http_status(400, "bad"), + AiError::Upstream("status 400: bad".into()) + ); + } + + #[test] + fn long_details_are_truncated_on_a_char_boundary() { + let long = "é".repeat(MAX_ERROR_DETAIL_BYTES); + let cut = truncate_detail(long); + assert!(cut.len() <= MAX_ERROR_DETAIL_BYTES + '…'.len_utf8()); + assert!(cut.ends_with('…')); + assert_eq!(truncate_detail("short".into()), "short"); + } + + #[test] + fn http_errors_map_to_typed_variants() { + assert_eq!( + AiError::from(HttpError::BodyTooLarge { len: 9, max: 8 }), + AiError::LimitExceeded { + limit: "response_bytes", + max: 8 + } + ); + assert!(matches!( + AiError::from(HttpError::InvalidRequest( + "blocked by the SSRF policy: loopback".into() + )), + AiError::Blocked(_) + )); + assert!(matches!( + AiError::from(HttpError::InvalidRequest("bad header".into())), + AiError::Upstream(_) + )); + } + + #[test] + fn display_names_the_limit() { + let e = AiError::LimitExceeded { + limit: "stream_events", + max: 3, + }; + assert_eq!(e.to_string(), "limit exceeded: stream_events (max 3)"); + assert_eq!(AiError::Timeout.to_string(), "request timed out"); + } +} diff --git a/crates/id_effect_ai/src/http_util.rs b/crates/id_effect_ai/src/http_util.rs index 7d5c70d33..04bfb646e 100644 --- a/crates/id_effect_ai/src/http_util.rs +++ b/crates/id_effect_ai/src/http_util.rs @@ -3,7 +3,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use id_effect::{Effect, fail, succeed}; -/// `Authorization: Bearer `. +/// `Authorization: Bearer ` header pair. #[inline] pub fn bearer_header(token: &str) -> (String, String) { ("Authorization".to_string(), format!("Bearer {token}")) @@ -28,7 +28,12 @@ pub(crate) fn from_result(r: Result) -> Effect { /// Maps a vendor response status to `Unauthorized` (401/403) or /// [`AiError::from_http_status`](crate::AiError::from_http_status) (any other non-2xx); `detail` /// is computed only on failure. -#[cfg(any(feature = "openai", feature = "anthropic", feature = "cursor"))] +#[cfg(any( + feature = "openai", + feature = "anthropic", + feature = "ollama", + feature = "cursor" +))] pub(crate) fn check_status( status: u16, detail: impl FnOnce() -> String, @@ -49,3 +54,30 @@ pub fn join_url(base: &str, path: &str) -> String { let path = path.trim_start_matches('/'); format!("{base}/{path}") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn headers_and_urls() { + assert_eq!(bearer_header("t").1, format!("{} t", "Bearer")); + assert_eq!(cursor_basic_auth_header("k").1, "Basic azo="); + assert_eq!(join_url("https://a/", "/v1/x"), "https://a/v1/x"); + assert!( + from_result::(Err(())) + .block_on() + .into_result() + .is_err() + ); + } + + #[cfg(feature = "openai")] + #[test] + fn check_status_maps_non_2xx() { + use crate::error::AiError; + assert_eq!(check_status(200, String::new), Ok(())); + assert_eq!(check_status(401, String::new), Err(AiError::Unauthorized)); + assert!(check_status(500, || "boom".into()).is_err()); + } +} diff --git a/crates/id_effect_ai/src/lib.rs b/crates/id_effect_ai/src/lib.rs index 73a14f7a9..115e4da89 100644 --- a/crates/id_effect_ai/src/lib.rs +++ b/crates/id_effect_ai/src/lib.rs @@ -1,35 +1,77 @@ -//! LLM client traits, streaming completions, and multi-vendor HTTP adapters for `id_effect`. +//! Provider-neutral LLM chat and streaming for `id_effect`, secure by default. +//! +//! - **Chat** ([`chat`]): [`LanguageModel`] is a capability in the typed environment; +//! [`complete`] reads it and returns an `Effect`. Vendor layers: +//! OpenAI (`openai`) and Anthropic (`anthropic`); the Cursor Cloud Agents client sits behind +//! the non-default `cursor` feature. +//! - **Streaming** ([`stream`]): [`complete_stream`] yields an `id_effect::Stream` of +//! [`CompletionChunk`]s, decoded incrementally from the vendor's server-sent events. +//! - **Retries, spans, usage**: transient statuses are retried with backoff +//! ([`retry_transient_ai_http`]); every call runs in an `ai.request` span with vendor, model +//! and token usage ([`with_ai_request_span`]). +//! - **Testing** (`testing`, feature `test-util`): `ScriptedModel` replays deterministic +//! replies, tool calls, streams and failures, so CI never calls a vendor (ADR 0015 D8). +//! +//! Secure by default (ADR 0013): every HTTP call goes through id_effect_platform's client +//! (SSRF policy, no redirects, timeouts, body caps); API keys are `id_effect_config::Secret`; +//! prompts and completions are never logged or put on spans unless +//! [`AiConfig::dangerous_log_prompts`] is taken (recorded in the posture registry); `max_tokens` +//! has a finite default and a stream has byte and event caps ([`AiLimits`]). +//! +//! ``` +//! use id_effect::{HCons, HNil}; +//! use id_effect_ai::{AiError, ChatRequest, LanguageModelService, complete, provide_mock_language_model}; +//! +//! let reply = complete::, _>(ChatRequest::user("mock", "hello")) +//! .provide_layer(provide_mock_language_model::()) +//! .block_on() +//! .into_result() +//! .expect("complete"); +//! assert_eq!(reply.content, "echo:hello"); +//! ``` #![forbid(unsafe_code)] #![deny(missing_docs)] -#![allow(clippy::new_ret_no_self, clippy::unused_unit)] +#![allow(clippy::new_ret_no_self)] +pub mod chat; mod config; mod error; mod http_util; -mod model; mod retry; -mod sse; -mod streaming; +pub mod stream; mod tracing_util; -#[cfg(any(feature = "openai", feature = "anthropic"))] +#[cfg(feature = "test-util")] +pub mod testing; + +#[cfg(any(feature = "openai", feature = "anthropic", feature = "ollama"))] pub mod vendors; #[cfg(feature = "cursor")] pub mod cursor; -pub use config::AiConfig; -pub use error::AiError; -pub use http_util::{bearer_header, cursor_basic_auth_header, join_url}; -pub use model::{ +#[cfg(feature = "embeddings")] +pub mod embeddings; +#[cfg(feature = "mcp")] +pub mod mcp; +#[cfg(feature = "tools")] +pub mod tools; + +pub use chat::{ ChatMessage, ChatRequest, ChatResponse, ChatRole, LanguageModel, LanguageModelService, - MockLanguageModel, complete, provide_mock_language_model, + MockLanguageModel, ToolCall, Usage, complete, provide_language_model, + provide_mock_language_model, }; +pub use config::{AiConfig, AiLimits, DANGEROUS_LOG_PROMPTS}; +pub use error::AiError; +pub use http_util::{bearer_header, cursor_basic_auth_header, join_url}; pub use retry::{default_ai_retry_schedule, retry_transient_ai_http}; -pub use sse::{SseField, SseMessage, SseParser}; -pub use streaming::{CompletionChunk, complete_stream, mock_chunk_stream}; -pub use tracing_util::{with_ai_request_span, with_ai_span}; +pub use stream::{ + CompletionChunk, SseField, SseMessage, SseParser, complete_stream, decode_sse_event, + mock_chunk_stream, +}; +pub use tracing_util::{AI_SPAN, with_ai_request_span, with_ai_span}; #[cfg(feature = "anthropic")] pub use vendors::{AnthropicLanguageModel, provide_anthropic_language_model}; diff --git a/crates/id_effect_ai/src/mcp/mod.rs b/crates/id_effect_ai/src/mcp/mod.rs new file mode 100644 index 000000000..37cf9dce7 --- /dev/null +++ b/crates/id_effect_ai/src/mcp/mod.rs @@ -0,0 +1,3 @@ +//! Model Context Protocol (`feature = "mcp"`; Streamable HTTP with `mcp-http`): an effect-native +//! server and client pinned to one specification revision (ADR 0015 §7), serving the same tools +//! the agent loop uses. diff --git a/crates/id_effect_ai/src/model.rs b/crates/id_effect_ai/src/model.rs deleted file mode 100644 index f3ee13bbb..000000000 --- a/crates/id_effect_ai/src/model.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! Language model trait and mock implementation. - -use crate::error::AiError; -use crate::http_util::from_result; -use crate::streaming::{CompletionChunk, mock_chunk_stream}; -use id_effect::{Effect, HCons, HList, HNil, Layer, Selector, Stream, hlist, need, suspend}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -/// Chat role label. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ChatRole { - /// System prompt. - System, - /// User message. - User, - /// Assistant reply. - Assistant, -} - -/// Single chat message. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ChatMessage { - /// Role of the speaker. - pub role: ChatRole, - /// Message text. - pub content: String, -} - -/// Request for a chat completion. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ChatRequest { - /// Model id (vendor-specific string). - pub model: String, - /// Conversation messages. - pub messages: Vec, -} - -impl ChatRequest { - /// Validates non-empty messages. - pub fn validate(&self) -> Result<(), AiError> { - if self.messages.is_empty() { - return Err(AiError::EmptyRequest); - } - Ok(()) - } -} - -/// Buffered completion response. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ChatResponse { - /// Assistant text. - pub content: String, - /// Token estimate (mock / vendor metadata). - pub tokens_used: u32, -} - -/// Injectable language model service. -pub type LanguageModelService = Arc; - -/// Vendor-neutral language model capability. -pub trait LanguageModel: Send + Sync + 'static { - /// Buffered completion. - fn complete(&self, req: ChatRequest) -> Effect; - /// Streaming token chunks. - fn complete_stream(&self, req: ChatRequest) -> Effect, AiError>; -} - -/// Deterministic mock for tests and examples. -#[derive(Debug, Default, Clone)] -pub struct MockLanguageModel { - reply_prefix: String, -} - -impl MockLanguageModel { - /// Creates a mock that echoes the last user message. - #[inline] - pub fn echo() -> Self { - Self { - reply_prefix: "echo:".to_string(), - } - } - - fn last_user(req: &ChatRequest) -> Result { - req.validate()?; - req - .messages - .iter() - .rev() - .find(|m| m.role == ChatRole::User) - .map(|m| m.content.clone()) - .ok_or(AiError::EmptyRequest) - } -} - -impl LanguageModel for MockLanguageModel { - fn complete(&self, req: ChatRequest) -> Effect { - let prefix = self.reply_prefix.clone(); - suspend(move || { - from_result(Self::last_user(&req).map(|user| { - let content = format!("{prefix}{user}"); - let tokens_used = content.len() as u32; - ChatResponse { - content, - tokens_used, - } - })) - }) - } - - fn complete_stream(&self, req: ChatRequest) -> Effect, AiError> { - let prefix = self.reply_prefix.clone(); - suspend(move || { - from_result( - Self::last_user(&req).map(|user| mock_chunk_stream(&format!("{prefix}{user}"), 4)), - ) - }) - } -} - -/// Register mock language model provider: a [`Layer`] providing [`LanguageModelService`] -/// ([`MockLanguageModel::echo`]). -#[inline] -pub fn provide_mock_language_model() --> Layer, E> { - let model: LanguageModelService = Arc::new(MockLanguageModel::echo()); - Layer::succeed(hlist![model]) -} - -/// Complete using installed [`LanguageModel`] capability. -#[inline] -pub fn complete(req: ChatRequest) -> Effect -where - R: Selector + HList, -{ - need::().flat_map(move |model| model.complete(req).narrow()) -} diff --git a/crates/id_effect_ai/src/sse.rs b/crates/id_effect_ai/src/sse.rs deleted file mode 100644 index 55fb3163f..000000000 --- a/crates/id_effect_ai/src/sse.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! Incremental Server-Sent Events line parser. - -/// One parsed SSE field line (`event:` or `data:`). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SseField { - /// Field name without colon. - pub name: String, - /// Field value (trimmed). - pub value: String, -} - -/// Completed SSE message (one or more field lines terminated by blank line). -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct SseMessage { - /// Optional `event:` value. - pub event: Option, - /// Accumulated `data:` payloads (one per line). - pub data_lines: Vec, -} - -impl SseMessage { - /// Joined `data:` payload. - pub fn data(&self) -> String { - self.data_lines.join("\n") - } -} - -/// Incremental SSE parser over raw bytes. -#[derive(Debug, Default)] -pub struct SseParser { - buffer: String, - current: SseMessage, -} - -impl SseParser { - /// Create an empty parser. - #[inline] - pub fn new() -> Self { - Self::default() - } - - /// Feed more UTF-8 text; returns completed messages. - pub fn feed(&mut self, chunk: &str) -> Vec { - self.buffer.push_str(chunk); - let mut out = Vec::new(); - while let Some(pos) = self.buffer.find('\n') { - let line = self.buffer.drain(..=pos).collect::(); - let line = line.trim_end_matches(['\r', '\n']); - if line.is_empty() { - if !self.current.data_lines.is_empty() || self.current.event.is_some() { - out.push(std::mem::take(&mut self.current)); - } - continue; - } - if let Some((name, value)) = line.split_once(':') { - let name = name.trim().to_string(); - let value = value.trim_start().to_string(); - match name.as_str() { - "event" => self.current.event = Some(value), - "data" => self.current.data_lines.push(value), - _ => {} - } - } - } - out - } - - /// Flush any trailing partial message. - pub fn finish(self) -> Option { - if self.current.data_lines.is_empty() && self.current.event.is_none() { - None - } else { - Some(self.current) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_openai_style_chunk() { - let mut p = SseParser::new(); - let msgs = p.feed("data: {\"x\":1}\n\n"); - assert_eq!(msgs.len(), 1); - assert_eq!(msgs[0].data(), "{\"x\":1}"); - } - - #[test] - fn parses_anthropic_event_and_data() { - let mut p = SseParser::new(); - let msgs = p.feed("event: content_block_delta\ndata: {\"t\":\"a\"}\n\n"); - assert_eq!(msgs.len(), 1); - assert_eq!(msgs[0].event.as_deref(), Some("content_block_delta")); - assert_eq!(msgs[0].data(), "{\"t\":\"a\"}"); - } -} diff --git a/crates/id_effect_ai/src/stream.rs b/crates/id_effect_ai/src/stream.rs new file mode 100644 index 000000000..33ba9e704 --- /dev/null +++ b/crates/id_effect_ai/src/stream.rs @@ -0,0 +1,533 @@ +//! Streaming completions as `id_effect::Stream`, and the bounded server-sent events (SSE) +//! decoder the vendor adapters read their streams with. +//! +//! A vendor stream is decoded incrementally: each pull reads one body chunk from the platform +//! client, feeds it to an [`SseParser`] and turns complete events into [`CompletionChunk`]s. The +//! stream is bounded (ADR 0013 R4): its bytes ([`AiLimits::max_stream_bytes`]), its events +//! ([`AiLimits::max_stream_events`]) and one event's size ([`MAX_SSE_EVENT_BYTES`]) are capped; a +//! stream over a cap fails with [`AiError::LimitExceeded`] and stops reading, which drops (closes) +//! the response body. [`decode_sse_event`] is the decoder's entry point for one event (the +//! `ai_sse_event` fuzz target). +//! +//! [`AiLimits::max_stream_bytes`]: crate::AiLimits::max_stream_bytes +//! [`AiLimits::max_stream_events`]: crate::AiLimits::max_stream_events + +use id_effect::{Effect, HList, Selector, Stream, need}; +use serde::{Deserialize, Serialize}; + +use crate::chat::{ChatRequest, LanguageModelService}; +use crate::error::AiError; + +/// Cap on one server-sent event (its pending line plus accumulated fields): 1 MiB. +pub const MAX_SSE_EVENT_BYTES: usize = 1024 * 1024; + +/// One streamed token or delta from the model. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CompletionChunk { + /// Token text. + pub delta: String, + /// True on final chunk. + pub done: bool, +} + +/// Complete with streaming using installed [`LanguageModel`](crate::chat::LanguageModel) capability. +#[inline] +pub fn complete_stream( + req: ChatRequest, +) -> Effect, AiError, R> +where + R: Selector + HList, +{ + need::().flat_map(move |model| model.complete_stream(req).narrow()) +} + +/// Build a mock chunk stream by splitting `text` into `chunk_size` character runs (the last +/// chunk has `done` set). +pub fn mock_chunk_stream(text: &str, chunk_size: usize) -> Stream { + let parts: Vec = text + .chars() + .collect::>() + .chunks(chunk_size.max(1)) + .map(|c| c.iter().collect()) + .collect(); + let n = parts.len(); + let chunks = parts + .into_iter() + .enumerate() + .map(|(i, delta)| CompletionChunk { + delta, + done: i + 1 == n, + }) + .collect(); + Stream::from_vec(chunks) +} + +/// One parsed SSE field line (`event:` or `data:`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SseField { + /// Field name without colon. + pub name: String, + /// Field value (one leading space removed). + pub value: String, +} + +/// Completed SSE message (one or more field lines terminated by blank line). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SseMessage { + /// Optional `event:` value. + pub event: Option, + /// Accumulated `data:` payloads (one per line). + pub data_lines: Vec, +} + +impl SseMessage { + /// Joined `data:` payload. + pub fn data(&self) -> String { + self.data_lines.join("\n") + } + + fn is_empty(&self) -> bool { + self.event.is_none() && self.data_lines.is_empty() + } +} + +/// Incremental SSE parser over raw bytes (lines end in `\n` or `\r\n`; comment lines and +/// unknown fields are ignored; invalid UTF-8 is replaced, never a panic). +#[derive(Debug, Default)] +pub struct SseParser { + buffer: Vec, + current: SseMessage, + current_bytes: usize, +} + +impl SseParser { + /// Create an empty parser. + #[inline] + pub fn new() -> Self { + Self::default() + } + + /// Feed more UTF-8 text; returns completed messages. + pub fn feed(&mut self, chunk: &str) -> Vec { + self.feed_bytes(chunk.as_bytes()) + } + + /// Feed more bytes (a chunk may end inside a line or a character); returns completed + /// messages. + pub fn feed_bytes(&mut self, chunk: &[u8]) -> Vec { + self.buffer.extend_from_slice(chunk); + let mut out = Vec::new(); + let mut start = 0; + while let Some(offset) = self.buffer[start..].iter().position(|&b| b == b'\n') { + let end = start + offset; + let line = Self::line_text(&self.buffer[start..end]); + start = end + 1; + if let Some(msg) = self.line(&line) { + out.push(msg); + } + } + self.buffer.drain(..start); + out + } + + /// Bytes held for the event being parsed: the unterminated line plus the fields read so far. + #[inline] + pub fn pending_bytes(&self) -> usize { + self.buffer.len() + self.current_bytes + } + + /// Flush any trailing partial message (an unterminated last line counts). + pub fn finish(mut self) -> Option { + if !self.buffer.is_empty() { + let line = Self::line_text(&std::mem::take(&mut self.buffer)); + self.line(&line); + } + self.take_pending() + } + + fn line_text(raw: &[u8]) -> String { + let raw = raw.strip_suffix(b"\r").unwrap_or(raw); + String::from_utf8_lossy(raw).into_owned() + } + + /// Applies one line; a blank line completes the current message. + fn line(&mut self, line: &str) -> Option { + if line.is_empty() { + return self.take_pending(); + } + let (name, value) = match line.split_once(':') { + Some((name, value)) => (name, value.strip_prefix(' ').unwrap_or(value)), + None => (line, ""), + }; + match name { + "event" => self.current.event = Some(value.to_string()), + "data" => self.current.data_lines.push(value.to_string()), + _ => return None, + } + self.current_bytes += line.len(); + None + } + + fn take_pending(&mut self) -> Option { + self.current_bytes = 0; + let msg = std::mem::take(&mut self.current); + (!msg.is_empty()).then_some(msg) + } +} + +/// Decodes the first server-sent event in `raw` (text after its terminating blank line is +/// ignored; an unterminated event counts). `Ok(None)` when `raw` holds no field. Input over +/// [`MAX_SSE_EVENT_BYTES`] is refused with [`AiError::LimitExceeded`]. Never panics, whatever the +/// bytes (the `ai_sse_event` fuzz target). +/// +/// ``` +/// use id_effect_ai::stream::decode_sse_event; +/// +/// let msg = decode_sse_event(b"event: ping\ndata: {}\n\n").unwrap().unwrap(); +/// assert_eq!(msg.event.as_deref(), Some("ping")); +/// assert_eq!(msg.data(), "{}"); +/// ``` +pub fn decode_sse_event(raw: &[u8]) -> Result, AiError> { + if raw.len() > MAX_SSE_EVENT_BYTES { + return Err(AiError::LimitExceeded { + limit: "sse_event_bytes", + max: MAX_SSE_EVENT_BYTES as u64, + }); + } + let mut parser = SseParser::new(); + match parser.feed_bytes(raw).into_iter().next() { + Some(first) => Ok(Some(first)), + None => Ok(parser.finish()), + } +} + +#[cfg(any(feature = "openai", feature = "anthropic", feature = "ollama"))] +#[cfg_attr( + not(any(feature = "openai", feature = "anthropic")), + allow(unused_imports) +)] +pub(crate) use vendor::{SseStep, sse_chunk_stream}; + +#[cfg(any(feature = "openai", feature = "anthropic", feature = "ollama"))] +#[cfg_attr(not(any(feature = "openai", feature = "anthropic")), allow(dead_code))] +mod vendor { + use std::collections::VecDeque; + + use id_effect::{Effect, Stream, fail, succeed}; + use id_effect_platform::error::HttpError; + use id_effect_platform::http::HttpBody; + + use super::{CompletionChunk, MAX_SSE_EVENT_BYTES, SseMessage, SseParser}; + use crate::config::AiLimits; + use crate::error::AiError; + + /// What one vendor event means for the chunk stream. + #[derive(Debug, PartialEq)] + pub(crate) enum SseStep { + /// A non-empty text delta. + Delta(String), + /// The vendor's end-of-stream event: emit the final chunk and stop reading. + Done, + /// An event without text (pings, metadata). + Skip, + /// A vendor error event or an undecodable payload. + Fail(AiError), + } + + /// Turns one SSE message into an [`SseStep`] (one per vendor). + pub(crate) type SseDecoder = fn(&SseMessage) -> SseStep; + + struct SseState { + body: HttpBody, + parser: SseParser, + ready: VecDeque, + bytes: usize, + events: usize, + finished: bool, + max_bytes: usize, + max_events: usize, + decode: SseDecoder, + } + + /// The chunk stream of a vendor SSE `body` under `limits`, decoded incrementally by `decode`. + /// Always ends with a `done` chunk (the vendor's end event or the end of the body). + pub(crate) fn sse_chunk_stream( + body: HttpBody, + limits: &AiLimits, + decode: SseDecoder, + ) -> Stream { + let state = SseState { + body, + parser: SseParser::new(), + ready: VecDeque::new(), + bytes: 0, + events: 0, + finished: false, + max_bytes: limits.max_stream_bytes, + max_events: limits.max_stream_events, + decode, + }; + Stream::unfold_effect(state, next_chunk) + } + + fn over(limit: &'static str, max: usize) -> AiError { + AiError::LimitExceeded { + limit, + max: max as u64, + } + } + + fn next_chunk(mut state: SseState) -> Effect, AiError> { + if let Some(chunk) = state.ready.pop_front() { + return succeed(Some((chunk, state))); + } + if state.finished { + return succeed(None); + } + let max_bytes = state.max_bytes; + state + .body + .next_chunk() + .map_err(move |e| match e { + HttpError::BodyTooLarge { .. } => over("stream_bytes", max_bytes), + other => AiError::from(other), + }) + .flat_map(move |read| match state.absorb(read) { + Ok(()) => next_chunk(state), + Err(e) => fail(e), + }) + } + + impl SseState { + fn absorb(&mut self, read: Option>) -> Result<(), AiError> { + let Some(bytes) = read else { + if let Some(msg) = std::mem::take(&mut self.parser).finish() { + self.event(&msg)?; + } + if !self.finished { + self.done(); + } + return Ok(()); + }; + self.bytes += bytes.len(); + if self.bytes > self.max_bytes { + return Err(over("stream_bytes", self.max_bytes)); + } + for msg in self.parser.feed_bytes(&bytes) { + self.event(&msg)?; + if self.finished { + return Ok(()); + } + } + if self.parser.pending_bytes() > MAX_SSE_EVENT_BYTES { + return Err(over("sse_event_bytes", MAX_SSE_EVENT_BYTES)); + } + Ok(()) + } + + fn event(&mut self, msg: &SseMessage) -> Result<(), AiError> { + self.events += 1; + if self.events > self.max_events { + return Err(over("stream_events", self.max_events)); + } + match (self.decode)(msg) { + SseStep::Delta(delta) => self.ready.push_back(CompletionChunk { delta, done: false }), + SseStep::Done => self.done(), + SseStep::Skip => {} + SseStep::Fail(e) => return Err(e), + } + Ok(()) + } + + fn done(&mut self) { + self.ready.push_back(CompletionChunk { + delta: String::new(), + done: true, + }); + self.finished = true; + } + } + + #[cfg(test)] + mod tests { + use super::*; + + fn decode(msg: &SseMessage) -> SseStep { + match msg.data().as_str() { + "[DONE]" => SseStep::Done, + "" => SseStep::Skip, + "boom" => SseStep::Fail(AiError::Upstream("boom".into())), + text => SseStep::Delta(text.to_string()), + } + } + + fn collect(chunks: &[&str], limits: AiLimits) -> Result, Option> { + let body = HttpBody::from_chunks(chunks.iter().map(|c| c.as_bytes().to_vec())); + sse_chunk_stream(body, &limits, decode) + .run_collect() + .block_on() + .into_result() + .map_err(|cause| cause.fail_option()) + } + + fn text(chunks: &[CompletionChunk]) -> String { + chunks.iter().map(|c| c.delta.as_str()).collect() + } + + #[test] + fn deltas_across_chunk_boundaries_then_done_event() { + let out = collect( + &[ + "data: he", + "llo\n\ndata: ", + "world\n\ndata: [DONE]\n\ndata: late\n\n", + ], + AiLimits::default(), + ) + .expect("stream"); + assert_eq!(text(&out), "helloworld"); + assert!(out.last().unwrap().done); + } + + #[test] + fn end_of_body_flushes_and_finishes() { + let out = + collect(&["data: a\n\n: comment\n\ndata: tail"], AiLimits::default()).expect("stream"); + assert_eq!(text(&out), "atail"); + assert!(out.last().unwrap().done); + let out = collect(&[], AiLimits::default()).expect("empty"); + assert_eq!(out.len(), 1); + } + + #[test] + fn caps_and_vendor_errors_fail_the_stream() { + let limits = AiLimits::default().with_max_stream_events(2); + assert_eq!( + collect(&["data: a\n\ndata: b\n\ndata: c\n\n"], limits), + Err(Some(over("stream_events", 2))) + ); + let limits = AiLimits::default().with_max_stream_bytes(4); + assert_eq!( + collect(&["data: abc\n\n"], limits), + Err(Some(over("stream_bytes", 4))) + ); + assert_eq!( + collect(&["data: boom\n\n"], AiLimits::default()), + Err(Some(AiError::Upstream("boom".into()))) + ); + let huge = format!("data: {}", "x".repeat(MAX_SSE_EVENT_BYTES)); + assert_eq!( + collect(&[huge.as_str()], AiLimits::default()), + Err(Some(over("sse_event_bytes", MAX_SSE_EVENT_BYTES))) + ); + assert_eq!( + collect(&["data: boom"], AiLimits::default()), + Err(Some(AiError::Upstream("boom".into()))) + ); + } + + #[test] + fn body_errors_map_to_ai_errors() { + assert_eq!( + over("stream_bytes", 1), + AiError::LimitExceeded { + limit: "stream_bytes", + max: 1 + } + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mock_chunk_stream_emits_parts() { + let chunks = mock_chunk_stream("abcd", 2) + .run_collect() + .block_on() + .into_result() + .expect("collect"); + let text: String = chunks.iter().map(|c| c.delta.clone()).collect(); + assert_eq!(text, "abcd"); + assert!(chunks.last().unwrap().done); + } + + #[test] + fn completion_chunk_round_trip_json() { + let chunk = CompletionChunk { + delta: "tok".into(), + done: true, + }; + let json = serde_json::to_string(&chunk).unwrap(); + let back: CompletionChunk = serde_json::from_str(&json).unwrap(); + assert_eq!(back, chunk); + } + + #[test] + fn parses_openai_style_chunk() { + let mut p = SseParser::new(); + let msgs = p.feed("data: {\"x\":1}\n\n"); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].data(), "{\"x\":1}"); + } + + #[test] + fn parses_anthropic_event_and_data() { + let mut p = SseParser::new(); + let msgs = p.feed("event: content_block_delta\ndata: {\"t\":\"a\"}\n\n"); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].event.as_deref(), Some("content_block_delta")); + assert_eq!(msgs[0].data(), "{\"t\":\"a\"}"); + } + + #[test] + fn crlf_multiline_data_comments_and_bare_fields() { + let mut p = SseParser::new(); + let msgs = p.feed("data: a\r\ndata:b\r\n: note\r\nid: 7\r\ndata\r\n\r\n\r\n"); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].data_lines, vec!["a", "b", ""]); + assert_eq!(p.pending_bytes(), 0); + assert_eq!(p.finish(), None); + } + + #[test] + fn split_utf8_and_trailing_partial_message() { + let mut p = SseParser::new(); + let bytes = "data: é\n".as_bytes(); + assert!(p.feed_bytes(&bytes[..7]).is_empty()); + assert!(p.pending_bytes() > 0); + assert!(p.feed_bytes(&bytes[7..]).is_empty()); + assert!(p.feed("event: e\ndata: x").is_empty()); + let last = p.finish().expect("trailing"); + assert_eq!(last.data_lines, vec!["é", "x"]); + assert_eq!(last.event.as_deref(), Some("e")); + } + + #[test] + fn decode_sse_event_first_event_partial_empty_and_cap() { + let msg = decode_sse_event(b"data: 1\n\ndata: 2\n\n") + .unwrap() + .unwrap(); + assert_eq!(msg.data(), "1"); + let msg = decode_sse_event(b"data: \xff\xfe").unwrap().unwrap(); + assert_eq!(msg.data(), "\u{fffd}\u{fffd}"); + assert_eq!(decode_sse_event(b"").unwrap(), None); + assert_eq!(decode_sse_event(b": only a comment\n\n").unwrap(), None); + let big = vec![b'a'; MAX_SSE_EVENT_BYTES + 1]; + assert!(matches!( + decode_sse_event(&big), + Err(AiError::LimitExceeded { + limit: "sse_event_bytes", + .. + }) + )); + let field = SseField { + name: "data".into(), + value: "v".into(), + }; + assert_eq!(field.clone(), field); + } +} diff --git a/crates/id_effect_ai/src/streaming.rs b/crates/id_effect_ai/src/streaming.rs deleted file mode 100644 index 41bce81e1..000000000 --- a/crates/id_effect_ai/src/streaming.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Streaming completion chunks as `id_effect::Stream`. - -use crate::error::AiError; -use crate::model::{ChatRequest, LanguageModelService}; -use id_effect::{Effect, HList, Selector, Stream, need}; -use serde::{Deserialize, Serialize}; - -/// One streamed token or delta from the model. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CompletionChunk { - /// Token text. - pub delta: String, - /// True on final chunk. - pub done: bool, -} - -/// Complete with streaming using installed [`LanguageModel`](crate::model::LanguageModel) capability. -#[inline] -pub fn complete_stream( - req: ChatRequest, -) -> Effect, AiError, R> -where - R: Selector + HList, -{ - need::().flat_map(move |model| model.complete_stream(req).narrow()) -} - -/// Build a mock chunk stream by splitting `text` into `chunk_size` character runs. -pub fn mock_chunk_stream(text: &str, chunk_size: usize) -> Stream { - let parts: Vec = text - .chars() - .collect::>() - .chunks(chunk_size.max(1)) - .map(|c| c.iter().collect()) - .collect(); - let n = parts.len(); - let chunks = parts - .into_iter() - .enumerate() - .map(|(i, delta)| CompletionChunk { - delta, - done: i + 1 == n, - }) - .collect(); - Stream::from_vec(chunks) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn mock_chunk_stream_emits_parts() { - let stream = mock_chunk_stream("abcd", 2); - let chunks = stream - .run_collect() - .block_on() - .into_result() - .expect("collect"); - let text: String = chunks.iter().map(|c| c.delta.clone()).collect(); - assert_eq!(text, "abcd"); - } - - #[test] - fn completion_chunk_round_trip_json() { - let chunk = CompletionChunk { - delta: "tok".into(), - done: true, - }; - let json = serde_json::to_string(&chunk).unwrap(); - let back: CompletionChunk = serde_json::from_str(&json).unwrap(); - assert_eq!(back, chunk); - } -} diff --git a/crates/id_effect_ai/src/testing.rs b/crates/id_effect_ai/src/testing.rs new file mode 100644 index 000000000..8c9e49130 --- /dev/null +++ b/crates/id_effect_ai/src/testing.rs @@ -0,0 +1,251 @@ +//! Deterministic test doubles (`feature = "test-util"`, ADR 0015 D8: no live vendor call in CI). +//! +//! [`ScriptedModel`] is a [`LanguageModel`] that answers from a script: each call takes the next +//! [`ScriptedReply`] (a reply, a tool call, a stream of deltas, or a failure) in order and +//! records the request, so a test can assert both what the program asked and what it did with +//! the answer. +//! +//! ``` +//! use id_effect::{HCons, HNil}; +//! use id_effect_ai::testing::ScriptedModel; +//! use id_effect_ai::{AiError, ChatRequest, LanguageModelService, complete}; +//! +//! let model = ScriptedModel::new().reply("first").reply("second"); +//! let ask = |text: &str| { +//! complete::, _>(ChatRequest::user("m", text)) +//! .provide_layer(model.layer::()) +//! .block_on() +//! .into_result() +//! .expect("scripted reply") +//! }; +//! assert_eq!(ask("a").content, "first"); +//! assert_eq!(ask("b").content, "second"); +//! assert_eq!(model.requests().len(), 2); +//! ``` + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use id_effect::{Effect, HCons, HNil, Layer, Stream, fail, succeed, suspend}; + +use crate::chat::{ + ChatRequest, ChatResponse, LanguageModel, LanguageModelService, ToolCall, provide_language_model, +}; +use crate::error::AiError; +use crate::stream::CompletionChunk; + +/// One scripted answer. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum ScriptedReply { + /// A buffered reply (text, usage, tool calls). Streamed, it is one delta. + Reply(ChatResponse), + /// Streamed deltas, in order. Buffered, they are concatenated. + Stream(Vec), + /// A failure of the call. + Fail(AiError), +} + +#[derive(Debug, Default)] +struct Script { + replies: VecDeque, + requests: Vec, +} + +/// A [`LanguageModel`] that replays a script in order and records every request it served. +/// Clones share the script. A call with no reply left fails with [`AiError::Upstream`]; an +/// invalid request fails with its validation error and consumes nothing. +#[derive(Debug, Clone, Default)] +pub struct ScriptedModel { + script: Arc>, +} + +impl ScriptedModel { + /// An empty script. + pub fn new() -> Self { + Self::default() + } + + fn lock(&self) -> MutexGuard<'_, Script> { + self.script.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Appends `reply` to the script (also while the model is in use). + pub fn push(&self, reply: ScriptedReply) { + self.lock().replies.push_back(reply); + } + + /// Appends a text reply. + #[must_use] + pub fn reply(self, text: impl Into) -> Self { + self.respond(ChatResponse::text(text)) + } + + /// Appends a full response (usage, stop reason, tool calls). + #[must_use] + pub fn respond(self, response: ChatResponse) -> Self { + self.push(ScriptedReply::Reply(response)); + self + } + + /// Appends a reply that asks for one tool call (empty text). + #[must_use] + pub fn tool_call( + self, + id: impl Into, + name: impl Into, + arguments: serde_json::Value, + ) -> Self { + self.respond(ChatResponse::text("").with_tool_calls(vec![ToolCall::new(id, name, arguments)])) + } + + /// Appends a streamed reply of `deltas`. + #[must_use] + pub fn stream(self, deltas: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.push(ScriptedReply::Stream( + deltas.into_iter().map(Into::into).collect(), + )); + self + } + + /// Appends a failure. + #[must_use] + pub fn fail(self, error: AiError) -> Self { + self.push(ScriptedReply::Fail(error)); + self + } + + /// The requests served so far, in order. + pub fn requests(&self) -> Vec { + self.lock().requests.clone() + } + + /// Replies not yet used. + pub fn remaining(&self) -> usize { + self.lock().replies.len() + } + + /// This model as a [`LanguageModelService`] (sharing the script). + pub fn service(&self) -> LanguageModelService { + Arc::new(self.clone()) + } + + /// A [`Layer`] providing this model (sharing the script). + pub fn layer(&self) -> Layer, E> { + provide_language_model(self.clone()) + } + + /// Validates `req`, records it and takes the next reply. + fn next(&self, req: ChatRequest) -> Result { + req.validate()?; + let mut script = self.lock(); + script.requests.push(req); + let call = script.requests.len(); + script.replies.pop_front().ok_or_else(|| { + AiError::Upstream(format!( + "ScriptedModel: no scripted reply left for call {call}" + )) + }) + } +} + +impl LanguageModel for ScriptedModel { + fn complete(&self, req: ChatRequest) -> Effect { + let this = self.clone(); + suspend(move || match this.next(req) { + Ok(ScriptedReply::Reply(resp)) => succeed(resp), + Ok(ScriptedReply::Stream(deltas)) => succeed(ChatResponse::text(deltas.concat())), + Ok(ScriptedReply::Fail(e)) | Err(e) => fail(e), + }) + } + + fn complete_stream(&self, req: ChatRequest) -> Effect, AiError> { + let this = self.clone(); + suspend(move || { + let deltas = match this.next(req) { + Ok(ScriptedReply::Reply(resp)) => vec![resp.content], + Ok(ScriptedReply::Stream(deltas)) => deltas, + Ok(ScriptedReply::Fail(e)) | Err(e) => return fail(e), + }; + let mut chunks: Vec = deltas + .into_iter() + .filter(|d| !d.is_empty()) + .map(|delta| CompletionChunk { delta, done: false }) + .collect(); + chunks.push(CompletionChunk { + delta: String::new(), + done: true, + }); + succeed(Stream::from_vec(chunks)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn collect(model: &ScriptedModel, text: &str) -> Result, AiError> { + model + .complete_stream(ChatRequest::user("m", text)) + .flat_map(|s| s.run_collect()) + .block_on() + .into_result() + .map_err(|c| c.fail_option().expect("typed failure")) + } + + #[test] + fn streams_replies_and_deltas() { + let model = ScriptedModel::new() + .stream(["a", "", "b"]) + .reply("whole") + .fail(AiError::Timeout); + let chunks = collect(&model, "1").expect("stream"); + assert_eq!(chunks.len(), 3); + assert!(chunks[2].done); + let chunks = collect(&model, "2").expect("reply"); + assert_eq!(chunks[0].delta, "whole"); + assert_eq!(collect(&model, "3"), Err(AiError::Timeout)); + assert!(matches!(collect(&model, "4"), Err(AiError::Upstream(_)))); + } + + #[test] + fn invalid_requests_consume_nothing() { + let model = ScriptedModel::new().reply("kept"); + let err = model + .complete(ChatRequest::new("m", vec![])) + .block_on() + .into_result() + .unwrap_err(); + assert_eq!(err.fail_option(), Some(AiError::EmptyRequest)); + assert_eq!(model.remaining(), 1); + assert!(model.requests().is_empty()); + } + + #[test] + fn buffered_stream_reply_and_tool_call() { + let model = ScriptedModel::new().stream(["x", "y"]).tool_call( + "c1", + "lookup", + serde_json::json!({"id": 7}), + ); + let service = model.service(); + let resp = service + .complete(ChatRequest::user("m", "q")) + .block_on() + .into_result() + .expect("concat"); + assert_eq!(resp.content, "xy"); + let resp = service + .complete(ChatRequest::user("m", "q")) + .block_on() + .into_result() + .expect("tool call"); + assert_eq!(resp.tool_calls[0].name, "lookup"); + assert_eq!(resp.tool_calls[0].arguments["id"], 7); + } +} diff --git a/crates/id_effect_ai/src/tools/mod.rs b/crates/id_effect_ai/src/tools/mod.rs new file mode 100644 index 000000000..a9be54b03 --- /dev/null +++ b/crates/id_effect_ai/src/tools/mod.rs @@ -0,0 +1,4 @@ +//! Typed tool calling (`feature = "tools"`): tools whose input and output have a `Schema`, the +//! `ToolSet` a call may use, and the deny-by-default rules of ADR 0015 D9 (only registered +//! tools run, arguments are decoded before a handler sees them, write tools need an explicit +//! policy, and every loop is bounded). diff --git a/crates/id_effect_ai/src/tracing_util.rs b/crates/id_effect_ai/src/tracing_util.rs index 51a04a901..d104437b2 100644 --- a/crates/id_effect_ai/src/tracing_util.rs +++ b/crates/id_effect_ai/src/tracing_util.rs @@ -1,10 +1,20 @@ -//! Tracing span helpers for AI vendor calls. +//! Tracing span helpers for AI vendor calls (ai.llm #7). +//! +//! Every vendor call runs inside an `ai.request` span with the attributes `ai.vendor`, +//! `ai.operation` and `ai.model`; a buffered completion adds the annotations +//! `ai.usage.input_tokens`, `ai.usage.output_tokens` and `ai.stop_reason`. Prompts and +//! completions are never put on a span unless the config took +//! [`AiConfig::dangerous_log_prompts`](crate::AiConfig::dangerous_log_prompts) (ADR 0013 R3). use id_effect::Effect; use crate::error::AiError; -/// Wrap an AI vendor effect in a tracing span (no prompt content). +/// The span name of every vendor call. +pub const AI_SPAN: &str = "ai.request"; + +/// Wrap an AI vendor effect in an [`AI_SPAN`] span with the attributes `ai.vendor`, +/// `ai.operation` and `ai.model` (no prompt content). pub fn with_ai_request_span( vendor: &'static str, operation: &'static str, @@ -16,8 +26,14 @@ where E: Send + 'static, R: Send + Sync + 'static, { - let _ = (vendor, operation, model); - effect.with_span("ai.request") + effect.with_span_attrs( + AI_SPAN, + [ + ("ai.vendor", vendor.to_string()), + ("ai.operation", operation.to_string()), + ("ai.model", model.to_string()), + ], + ) } /// Same as [`with_ai_request_span`] for [`AiError`] programs that need no capabilities. @@ -32,3 +48,81 @@ where { with_ai_request_span(vendor, operation, model, effect) } + +#[cfg(any(feature = "openai", feature = "anthropic"))] +pub(crate) use chat_spans::{observe_chat, observe_chat_stream}; + +#[cfg(any(feature = "openai", feature = "anthropic"))] +mod chat_spans { + use id_effect::{Effect, Stream, annotate_current_span, succeed}; + + use super::with_ai_span; + use crate::chat::{ChatRequest, ChatResponse}; + use crate::error::AiError; + use crate::stream::CompletionChunk; + + /// Adds each `(key, value)` to the current span, in order. + fn annotate_all(pairs: Vec<(&'static str, String)>) -> Effect<(), AiError> { + pairs.into_iter().fold(succeed(()), |acc, (key, value)| { + acc.flat_map(move |()| annotate_current_span(key, value)) + }) + } + + /// The prompt as recorded under `dangerous_log_prompts`: one `role: content` line per message. + fn render_prompt(req: &ChatRequest) -> String { + req + .messages + .iter() + .map(|m| format!("{:?}: {}", m.role, m.content)) + .collect::>() + .join("\n") + } + + fn prompt_note(req: &ChatRequest, log_prompts: bool) -> Vec<(&'static str, String)> { + if log_prompts { + vec![("ai.prompt", render_prompt(req))] + } else { + Vec::new() + } + } + + /// `call` inside the vendor's span, annotated with usage and stop reason (and, only when + /// `log_prompts`, the prompt and the completion). + pub(crate) fn observe_chat( + vendor: &'static str, + req: &ChatRequest, + log_prompts: bool, + call: Effect, + ) -> Effect { + let effect = annotate_all(prompt_note(req, log_prompts)) + .flat_map(move |()| call) + .flat_map(move |resp| { + let mut notes = vec![ + ("ai.usage.input_tokens", resp.usage.input_tokens.to_string()), + ( + "ai.usage.output_tokens", + resp.usage.output_tokens.to_string(), + ), + ]; + if let Some(reason) = &resp.stop_reason { + notes.push(("ai.stop_reason", reason.clone())); + } + if log_prompts { + notes.push(("ai.completion", resp.content.clone())); + } + annotate_all(notes).map(move |()| resp) + }); + with_ai_span(vendor, "chat", &req.model, effect) + } + + /// Opening a stream inside the vendor's span (with the prompt only when `log_prompts`). + pub(crate) fn observe_chat_stream( + vendor: &'static str, + req: &ChatRequest, + log_prompts: bool, + open: Effect, AiError>, + ) -> Effect, AiError> { + let effect = annotate_all(prompt_note(req, log_prompts)).flat_map(move |()| open); + with_ai_span(vendor, "chat_stream", &req.model, effect) + } +} diff --git a/crates/id_effect_ai/src/vendors/anthropic.rs b/crates/id_effect_ai/src/vendors/anthropic.rs deleted file mode 100644 index 54a318df5..000000000 --- a/crates/id_effect_ai/src/vendors/anthropic.rs +++ /dev/null @@ -1,247 +0,0 @@ -//! Anthropic Messages API (`/v1/messages`). - -use std::sync::Arc; - -use id_effect::{Effect, HCons, HNil, Layer, Stream, fail, hlist, suspend}; -use id_effect_platform::http::{HttpClient, HttpMethod, HttpRequest, HttpResponse}; -use serde::Deserialize; -use serde_json::json; - -use crate::config::AiConfig; -use crate::error::AiError; -use crate::http_util::{check_status, from_result, join_url}; -use crate::model::{ - ChatMessage, ChatRequest, ChatResponse, ChatRole, LanguageModel, LanguageModelService, -}; -use crate::retry::retry_transient_ai_http; -use crate::sse::SseParser; -use crate::streaming::CompletionChunk; -use crate::tracing_util::with_ai_span; - -/// Anthropic Messages API client. -#[derive(Clone)] -pub struct AnthropicLanguageModel { - client: Arc, - api_key: String, - base_url: String, - max_tokens: u32, -} - -impl AnthropicLanguageModel { - /// Create from HTTP client and config. - pub fn new(client: Arc, config: &AiConfig) -> Result { - let key = config.require_anthropic_key()?.expose().clone(); - Ok(Self { - client, - api_key: key, - base_url: config.anthropic_base_url.clone(), - max_tokens: config.anthropic_max_tokens, - }) - } - - fn role_str(role: ChatRole) -> &'static str { - match role { - ChatRole::System => "user", - ChatRole::User => "user", - ChatRole::Assistant => "assistant", - } - } - - fn split_system(messages: &[ChatMessage]) -> (Option, Vec) { - let mut system = None; - let mut out = Vec::new(); - for m in messages { - if m.role == ChatRole::System { - system = Some(m.content.clone()); - } else { - out.push(json!({ - "role": Self::role_str(m.role), - "content": m.content, - })); - } - } - (system, out) - } - - fn url(&self) -> String { - join_url(&self.base_url, "v1/messages") - } - - fn auth_request(&self, body: Vec, stream: bool) -> HttpRequest { - let mut headers = vec![ - ("Content-Type".to_string(), "application/json".to_string()), - ("x-api-key".to_string(), self.api_key.clone()), - ("anthropic-version".to_string(), "2023-06-01".to_string()), - ]; - if stream { - headers.push(("Accept".to_string(), "text/event-stream".to_string())); - } - HttpRequest { - method: HttpMethod::Post, - url: self.url(), - headers, - body: Some(body), - timeout: None, - max_body_bytes: None, - } - } -} - -#[derive(Deserialize)] -struct AnthropicResponse { - content: Vec, - usage: Option, -} - -#[derive(Deserialize)] -struct AnthropicBlock { - #[serde(rename = "type")] - kind: String, - text: Option, -} - -#[derive(Deserialize)] -struct AnthropicUsage { - output_tokens: Option, -} - -#[derive(Deserialize)] -struct AnthropicStreamEvent { - #[serde(rename = "type")] - kind: String, - delta: Option, -} - -#[derive(Deserialize)] -struct AnthropicStreamDelta { - #[serde(rename = "type")] - _kind: Option, - text: Option, -} - -impl AnthropicLanguageModel { - /// The validated Messages API request for `req` (`stream` selects SSE). - fn messages_request(&self, req: &ChatRequest, stream: bool) -> Result { - req.validate()?; - let (system, msgs) = Self::split_system(&req.messages); - let mut body = if stream { - json!({ - "model": req.model, - "max_tokens": self.max_tokens, - "stream": true, - "messages": msgs, - }) - } else { - json!({ - "model": req.model, - "max_tokens": self.max_tokens, - "messages": msgs, - }) - }; - if let Some(s) = system { - body["system"] = json!(s); - } - let bytes = serde_json::to_vec(&body).map_err(|e| AiError::InvalidJson(e.to_string()))?; - Ok(self.auth_request(bytes, stream)) - } - - fn parse_completion(resp: HttpResponse) -> Result { - check_status(resp.status, || { - String::from_utf8_lossy(&resp.body).to_string() - })?; - let parsed: AnthropicResponse = - serde_json::from_slice(&resp.body).map_err(|e| AiError::InvalidJson(e.to_string()))?; - let content = parsed - .content - .iter() - .find(|b| b.kind == "text") - .and_then(|b| b.text.clone()) - .filter(|s| !s.is_empty()) - .ok_or(AiError::EmptyResponse)?; - let tokens_used = parsed.usage.and_then(|u| u.output_tokens).unwrap_or(0); - Ok(ChatResponse { - content, - tokens_used, - }) - } -} - -impl LanguageModel for AnthropicLanguageModel { - fn complete(&self, req: ChatRequest) -> Effect { - let this = self.clone(); - let model = req.model.clone(); - with_ai_span( - "anthropic", - "complete", - &model, - retry_transient_ai_http(move || { - let this = this.clone(); - let req = req.clone(); - suspend(move || { - let http_req = match this.messages_request(&req, false) { - Ok(r) => r, - Err(e) => return fail(e), - }; - this - .client - .execute(http_req) - .map_err(|e| AiError::Upstream(format!("http: {e}"))) - .flat_map(|resp| from_result(AnthropicLanguageModel::parse_completion(resp))) - }) - }), - ) - } - - fn complete_stream(&self, req: ChatRequest) -> Effect, AiError> { - let this = self.clone(); - suspend(move || { - let http_req = match this.messages_request(&req, true) { - Ok(r) => r, - Err(e) => return fail(e), - }; - this - .client - .execute_stream(http_req) - .map_err(|e| AiError::Upstream(format!("http: {e}"))) - .flat_map(|resp| { - if let Err(e) = check_status(resp.status, || "stream failed".to_string()) { - return fail(e); - } - resp - .body - .collect() - .map_err(|e| AiError::Upstream(format!("stream collect: {e}"))) - .map(|bytes| anthropic_sse_to_chunk_stream(&String::from_utf8_lossy(&bytes))) - }) - }) - } -} - -fn anthropic_sse_to_chunk_stream(text: &str) -> Stream { - let mut chunks = Vec::new(); - let mut parser = SseParser::new(); - for msg in parser.feed(text) { - let data = msg.data(); - if let Ok(ev) = serde_json::from_str::(&data) - && ev.kind == "content_block_delta" - && let Some(delta) = ev.delta.and_then(|d| d.text) - && !delta.is_empty() - { - chunks.push(CompletionChunk { delta, done: false }); - } - } - chunks.push(CompletionChunk { - delta: String::new(), - done: true, - }); - Stream::from_vec(chunks) -} - -/// Register Anthropic [`LanguageModel`]: a [`Layer`] providing [`LanguageModelService`]. -pub fn provide_anthropic_language_model( - client: Arc, - config: AiConfig, -) -> Result, E>, AiError> { - let model: LanguageModelService = Arc::new(AnthropicLanguageModel::new(client, &config)?); - Ok(Layer::succeed(hlist![model])) -} diff --git a/crates/id_effect_ai/src/vendors/anthropic/chat.rs b/crates/id_effect_ai/src/vendors/anthropic/chat.rs new file mode 100644 index 000000000..75f72cdf6 --- /dev/null +++ b/crates/id_effect_ai/src/vendors/anthropic/chat.rs @@ -0,0 +1,304 @@ +//! Anthropic Messages API (`POST /v1/messages`), buffered and streamed (SSE). +//! +//! System messages map to the top-level `system` field (several are joined by a blank line). +//! Every request carries `max_tokens` (the request's +//! [`max_tokens`](crate::ChatRequest::max_tokens), else +//! [`AiLimits::max_tokens`](crate::AiLimits::max_tokens)). The API key is held as a +//! [`Secret`] and only exposed to build the `x-api-key` header. + +use std::fmt; +use std::sync::Arc; + +use id_effect::{Effect, HCons, HNil, Layer, Stream}; +use id_effect_config::Secret; +use id_effect_platform::http::{HttpClient, HttpRequest, HttpResponse}; +use serde::Deserialize; +use serde_json::json; + +use crate::chat::{ + ChatMessage, ChatRequest, ChatResponse, ChatRole, LanguageModel, LanguageModelService, Usage, + provide_language_model, +}; +use crate::config::AiConfig; +use crate::error::{AiError, truncate_detail}; +use crate::http_util::join_url; +use crate::stream::{CompletionChunk, SseMessage, SseStep}; +use crate::tracing_util::{observe_chat, observe_chat_stream}; +use crate::vendors::Transport; + +/// The `anthropic-version` header sent with every request. +pub const ANTHROPIC_VERSION: &str = "2023-06-01"; + +/// Anthropic Messages API client. `Debug` never prints the API key. +#[derive(Clone)] +pub struct AnthropicLanguageModel { + transport: Transport, + api_key: Secret, + base_url: String, +} + +impl fmt::Debug for AnthropicLanguageModel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AnthropicLanguageModel") + .field("base_url", &self.base_url) + .field("api_key", &self.api_key) + .field("transport", &self.transport) + .finish() + } +} + +impl AnthropicLanguageModel { + /// Create from the platform HTTP client and config (requires the API key). + pub fn new(client: Arc, config: &AiConfig) -> Result { + let api_key = config.require_anthropic_key()?.clone(); + Ok(Self { + transport: Transport::new(client, config), + api_key, + base_url: config.anthropic_base_url.clone(), + }) + } + + /// Create with the client [`AiConfig::http_client`] builds (SSRF policy and timeouts of + /// [`AiConfig::http`]). + pub fn from_config(config: &AiConfig) -> Result { + Self::new(config.http_client(), config) + } + + fn split_system(messages: &[ChatMessage]) -> (Option, Vec) { + let mut system = Vec::new(); + let mut out = Vec::new(); + for m in messages { + match m.role { + ChatRole::System => system.push(m.content.as_str()), + ChatRole::User => out.push(json!({ "role": "user", "content": m.content })), + ChatRole::Assistant => out.push(json!({ "role": "assistant", "content": m.content })), + } + } + ((!system.is_empty()).then(|| system.join("\n\n")), out) + } + + /// The validated Messages API request for `req` (`stream` selects SSE). + fn messages_request(&self, req: &ChatRequest, stream: bool) -> Result { + req.validate()?; + let (system, messages) = Self::split_system(&req.messages); + let mut body = json!({ + "model": req.model, + "max_tokens": req.effective_max_tokens(self.transport.limits.max_tokens), + "messages": messages, + }); + if stream { + body["stream"] = json!(true); + } + if let Some(s) = system { + body["system"] = json!(s); + } + self.transport.post_json( + join_url(&self.base_url, "v1/messages"), + vec![ + ("x-api-key".to_string(), self.api_key.expose().clone()), + ( + "anthropic-version".to_string(), + ANTHROPIC_VERSION.to_string(), + ), + ], + &body, + stream, + ) + } + + fn parse_completion(resp: HttpResponse) -> Result { + let parsed: AnthropicResponse = + serde_json::from_slice(&resp.body).map_err(|e| AiError::InvalidJson(e.to_string()))?; + let content: String = parsed + .content + .iter() + .filter(|b| b.kind == "text") + .filter_map(|b| b.text.as_deref()) + .collect(); + if content.is_empty() { + return Err(AiError::EmptyResponse); + } + let usage = parsed.usage.map_or_else(Usage::default, |u| { + Usage::new(u.input_tokens.unwrap_or(0), u.output_tokens.unwrap_or(0)) + }); + let resp = ChatResponse::text(content).with_usage(usage); + Ok(match parsed.stop_reason { + Some(reason) => resp.with_stop_reason(reason), + None => resp, + }) + } +} + +#[derive(Deserialize)] +struct AnthropicResponse { + content: Vec, + usage: Option, + stop_reason: Option, +} + +#[derive(Deserialize)] +struct AnthropicBlock { + #[serde(rename = "type")] + kind: String, + text: Option, +} + +#[derive(Deserialize)] +struct AnthropicUsage { + input_tokens: Option, + output_tokens: Option, +} + +#[derive(Deserialize)] +struct AnthropicStreamEvent { + #[serde(rename = "type")] + kind: String, + delta: Option, + error: Option, +} + +#[derive(Deserialize)] +struct AnthropicStreamDelta { + text: Option, +} + +#[derive(Deserialize)] +struct AnthropicStreamError { + message: Option, +} + +/// One Anthropic stream event: `content_block_delta` text is a delta, `message_stop` ends the +/// stream, `error` fails it, every other type (`ping`, `message_start`, ...) is skipped. +fn decode_stream_event(msg: &SseMessage) -> SseStep { + let data = msg.data(); + if data.trim().is_empty() { + return SseStep::Skip; + } + let ev = match serde_json::from_str::(&data) { + Ok(ev) => ev, + Err(e) => return SseStep::Fail(AiError::InvalidJson(e.to_string())), + }; + match ev.kind.as_str() { + "content_block_delta" => match ev.delta.and_then(|d| d.text) { + Some(text) if !text.is_empty() => SseStep::Delta(text), + _ => SseStep::Skip, + }, + "message_stop" => SseStep::Done, + "error" => SseStep::Fail(AiError::Upstream(truncate_detail(format!( + "stream error: {}", + ev.error.and_then(|e| e.message).unwrap_or_default() + )))), + _ => SseStep::Skip, + } +} + +impl LanguageModel for AnthropicLanguageModel { + fn complete(&self, req: ChatRequest) -> Effect { + let this = self.clone(); + let body = req.clone(); + let call = self.transport.call( + Arc::new(move || this.messages_request(&body, false)), + Self::parse_completion, + ); + observe_chat("anthropic", &req, self.transport.log_prompts, call) + } + + fn complete_stream(&self, req: ChatRequest) -> Effect, AiError> { + let this = self.clone(); + let body = req.clone(); + let open = self.transport.open_stream( + Arc::new(move || this.messages_request(&body, true)), + decode_stream_event, + ); + observe_chat_stream("anthropic", &req, self.transport.log_prompts, open) + } +} + +/// Register Anthropic [`LanguageModel`]: a [`Layer`] providing [`LanguageModelService`]. +pub fn provide_anthropic_language_model( + client: Arc, + config: AiConfig, +) -> Result, E>, AiError> { + Ok(provide_language_model(AnthropicLanguageModel::new( + client, &config, + )?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event(data: &str) -> SseStep { + decode_stream_event(&SseMessage { + event: None, + data_lines: vec![data.to_string()], + }) + } + + #[test] + fn stream_events_decode() { + assert_eq!(event(" "), SseStep::Skip); + assert_eq!( + event(r#"{"type":"content_block_delta","delta":{"type":"text_delta","text":"yo"}}"#), + SseStep::Delta("yo".into()) + ); + assert_eq!( + event(r#"{"type":"content_block_delta","delta":{"type":"input_json_delta"}}"#), + SseStep::Skip + ); + assert_eq!(event(r#"{"type":"ping"}"#), SseStep::Skip); + assert_eq!(event(r#"{"type":"message_stop"}"#), SseStep::Done); + assert!(matches!( + event(r#"{"type":"error","error":{"message":"Overloaded"}}"#), + SseStep::Fail(AiError::Upstream(m)) if m.contains("Overloaded") + )); + assert!(matches!(event("{"), SseStep::Fail(AiError::InvalidJson(_)))); + } + + fn response(body: &str) -> HttpResponse { + HttpResponse { + status: 200, + headers: vec![], + body: body.as_bytes().to_vec(), + } + } + + #[test] + fn completion_parsing() { + let resp = AnthropicLanguageModel::parse_completion(response( + r#"{"content":[{"type":"text","text":"a"},{"type":"tool_use"},{"type":"text","text":"b"}],"usage":{"input_tokens":4,"output_tokens":5},"stop_reason":"end_turn"}"#, + )) + .expect("parse"); + assert_eq!(resp.content, "ab"); + assert_eq!(resp.usage, Usage::new(4, 5)); + assert_eq!(resp.stop_reason.as_deref(), Some("end_turn")); + let resp = AnthropicLanguageModel::parse_completion(response( + r#"{"content":[{"type":"text","text":"x"}]}"#, + )) + .expect("parse"); + assert_eq!(resp.usage, Usage::default()); + assert_eq!( + AnthropicLanguageModel::parse_completion(response(r#"{"content":[]}"#)), + Err(AiError::EmptyResponse) + ); + assert!(matches!( + AnthropicLanguageModel::parse_completion(response("[")), + Err(AiError::InvalidJson(_)) + )); + } + + #[test] + fn system_messages_are_joined() { + let (system, messages) = AnthropicLanguageModel::split_system(&[ + ChatMessage::system("a"), + ChatMessage::user("u"), + ChatMessage::system("b"), + ChatMessage::assistant("x"), + ]); + assert_eq!(system.as_deref(), Some("a\n\nb")); + assert_eq!(messages.len(), 2); + assert_eq!(messages[1]["role"], "assistant"); + let (system, _) = AnthropicLanguageModel::split_system(&[ChatMessage::user("u")]); + assert_eq!(system, None); + } +} diff --git a/crates/id_effect_ai/src/vendors/anthropic/embed.rs b/crates/id_effect_ai/src/vendors/anthropic/embed.rs new file mode 100644 index 000000000..9a0eb330a --- /dev/null +++ b/crates/id_effect_ai/src/vendors/anthropic/embed.rs @@ -0,0 +1,3 @@ +//! Anthropic embeddings (features `anthropic` and `embeddings`): the Anthropic API has no +//! embeddings endpoint, so there is no adapter here; pair Claude chat with the OpenAI or Ollama +//! embedder. diff --git a/crates/id_effect_ai/src/vendors/anthropic/mod.rs b/crates/id_effect_ai/src/vendors/anthropic/mod.rs new file mode 100644 index 000000000..4f47b4638 --- /dev/null +++ b/crates/id_effect_ai/src/vendors/anthropic/mod.rs @@ -0,0 +1,8 @@ +//! Anthropic (`feature = "anthropic"`): the Messages API in [`chat`]. Anthropic serves no +//! embeddings endpoint, so `embed` (feature `embeddings`) holds no adapter. + +pub mod chat; +#[cfg(feature = "embeddings")] +pub mod embed; + +pub use chat::{AnthropicLanguageModel, provide_anthropic_language_model}; diff --git a/crates/id_effect_ai/src/vendors/mod.rs b/crates/id_effect_ai/src/vendors/mod.rs index 69048c11f..7657b86ff 100644 --- a/crates/id_effect_ai/src/vendors/mod.rs +++ b/crates/id_effect_ai/src/vendors/mod.rs @@ -1,7 +1,18 @@ -//! HTTP vendor adapters implementing [`LanguageModel`](crate::model::LanguageModel). +//! HTTP vendor adapters implementing [`LanguageModel`](crate::chat::LanguageModel). +//! +//! One module per vendor, each with `chat` (and, with feature `embeddings`, `embed`). Every +//! adapter sends through id_effect_platform's [`HttpClient`](id_effect_platform::http::HttpClient) +//! (no adapter builds its own client), so the platform's SSRF policy, redirect limit and body +//! caps apply to every provider base URL; each request also carries +//! [`AiLimits::request_timeout`](crate::AiLimits::request_timeout) and +//! [`AiLimits::max_response_bytes`](crate::AiLimits::max_response_bytes) (or +//! [`max_stream_bytes`](crate::AiLimits::max_stream_bytes) for a stream), and transient +//! statuses (429, 502, 503, 504, 529) are retried with backoff. #[cfg(feature = "anthropic")] pub mod anthropic; +#[cfg(feature = "ollama")] +pub mod ollama; #[cfg(feature = "openai")] pub mod openai; @@ -9,3 +20,144 @@ pub mod openai; pub use anthropic::{AnthropicLanguageModel, provide_anthropic_language_model}; #[cfg(feature = "openai")] pub use openai::{OpenAiLanguageModel, provide_openai_language_model}; + +#[cfg(any(feature = "openai", feature = "anthropic", feature = "ollama"))] +#[cfg_attr( + not(any(feature = "openai", feature = "anthropic")), + allow(unused_imports) +)] +pub(crate) use transport::Transport; + +/// Shared by the chat adapters and, with feature `embeddings`, the embedding adapters. +#[cfg(any(feature = "openai", feature = "anthropic", feature = "ollama"))] +#[cfg_attr(not(any(feature = "openai", feature = "anthropic")), allow(dead_code))] +mod transport { + use std::fmt; + use std::sync::Arc; + + use id_effect::{Effect, Stream, fail, suspend}; + use id_effect_platform::http::{HttpClient, HttpMethod, HttpRequest, HttpResponse}; + + use crate::config::{AiConfig, AiLimits}; + use crate::error::AiError; + use crate::http_util::{check_status, from_result}; + use crate::retry::retry_transient_ai_http; + use crate::stream::{CompletionChunk, SseStep, sse_chunk_stream}; + + /// Builds the HTTP request of one attempt (called again on every retry). + pub(crate) type MakeRequest = Arc Result + Send + Sync>; + + /// The request plumbing every chat adapter shares: the platform client, the limits, and + /// whether prompts may be recorded. + #[derive(Clone)] + pub(crate) struct Transport { + pub(crate) client: Arc, + pub(crate) limits: AiLimits, + pub(crate) log_prompts: bool, + } + + impl fmt::Debug for Transport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Transport") + .field("limits", &self.limits) + .field("log_prompts", &self.log_prompts) + .finish_non_exhaustive() + } + } + + impl Transport { + pub(crate) fn new(client: Arc, config: &AiConfig) -> Self { + Self { + client, + limits: config.limits, + log_prompts: config.logs_prompts(), + } + } + + /// A JSON `POST` to `url` with `headers`, the request timeout and the response cap. + pub(crate) fn post_json( + &self, + url: String, + mut headers: Vec<(String, String)>, + body: &serde_json::Value, + stream: bool, + ) -> Result { + let bytes = serde_json::to_vec(body).map_err(|e| AiError::InvalidJson(e.to_string()))?; + headers.push(("Content-Type".to_string(), "application/json".to_string())); + if stream { + headers.push(("Accept".to_string(), "text/event-stream".to_string())); + } + let max_body = if stream { + self.limits.max_stream_bytes + } else { + self.limits.max_response_bytes + }; + Ok(HttpRequest { + method: HttpMethod::Post, + url, + headers, + body: Some(bytes), + timeout: Some(self.limits.request_timeout), + max_body_bytes: Some(max_body), + }) + } + + /// Sends the request `make` builds and parses a 2xx response with `parse`; transient + /// statuses are retried ([`retry_transient_ai_http`]). + pub(crate) fn call( + &self, + make: MakeRequest, + parse: fn(HttpResponse) -> Result, + ) -> Effect + where + A: Send + 'static, + { + let client = Arc::clone(&self.client); + retry_transient_ai_http(move || { + let client = Arc::clone(&client); + let make = Arc::clone(&make); + suspend(move || match make() { + Ok(req) => client + .execute(req) + .map_err(AiError::from) + .flat_map(move |resp| { + from_result( + check_status(resp.status, || { + String::from_utf8_lossy(&resp.body).into_owned() + }) + .and_then(|()| parse(resp)), + ) + }), + Err(e) => fail(e), + }) + }) + } + + /// Opens the SSE stream `make` builds (transient statuses retried) and decodes it + /// incrementally with `decode` under the stream limits. + pub(crate) fn open_stream( + &self, + make: MakeRequest, + decode: fn(&crate::stream::SseMessage) -> SseStep, + ) -> Effect, AiError> { + let client = Arc::clone(&self.client); + let limits = self.limits; + retry_transient_ai_http(move || { + let client = Arc::clone(&client); + let make = Arc::clone(&make); + suspend(move || match make() { + Ok(req) => client + .execute_stream(req) + .map_err(AiError::from) + .flat_map(move |resp| { + if let Err(e) = check_status(resp.status, || "stream refused".to_string()) { + return fail(e); + } + id_effect::succeed(sse_chunk_stream(resp.body, &limits, decode)) + }), + Err(e) => fail(e), + }) + }) + } + } +} diff --git a/crates/id_effect_ai/src/vendors/ollama/chat.rs b/crates/id_effect_ai/src/vendors/ollama/chat.rs new file mode 100644 index 000000000..e34400505 --- /dev/null +++ b/crates/id_effect_ai/src/vendors/ollama/chat.rs @@ -0,0 +1,4 @@ +//! Ollama chat: Ollama serves the OpenAI Chat Completions wire format at +//! `/v1/chat/completions`, so chat goes through the OpenAI adapter (feature +//! `openai`) with [`AiConfig::openai_base_url`](crate::AiConfig::openai_base_url) set to the +//! Ollama URL and any non-empty API key; there is no separate Ollama chat adapter. diff --git a/crates/id_effect_ai/src/vendors/ollama/embed.rs b/crates/id_effect_ai/src/vendors/ollama/embed.rs new file mode 100644 index 000000000..1a579686b --- /dev/null +++ b/crates/id_effect_ai/src/vendors/ollama/embed.rs @@ -0,0 +1 @@ +//! Ollama embeddings (`POST /api/embed`, features `ollama` and `embeddings`). diff --git a/crates/id_effect_ai/src/vendors/ollama/mod.rs b/crates/id_effect_ai/src/vendors/ollama/mod.rs new file mode 100644 index 000000000..d09e105fa --- /dev/null +++ b/crates/id_effect_ai/src/vendors/ollama/mod.rs @@ -0,0 +1,11 @@ +//! Ollama (`feature = "ollama"`): a self-hosted model server at +//! [`AiConfig::ollama_base_url`](crate::AiConfig::ollama_base_url). Its embeddings adapter is in +//! `embed` (feature `embeddings`); for chat see [`chat`]. +//! +//! Ollama usually listens on a loopback or private address, which the platform client's SSRF +//! policy refuses by default: allow the host explicitly in [`AiConfig::http`](crate::AiConfig::http) +//! (`HttpClientConfig::allow_hosts`), never with the private-network opt-out. + +pub mod chat; +#[cfg(feature = "embeddings")] +pub mod embed; diff --git a/crates/id_effect_ai/src/vendors/openai.rs b/crates/id_effect_ai/src/vendors/openai.rs deleted file mode 100644 index 9f841f666..000000000 --- a/crates/id_effect_ai/src/vendors/openai.rs +++ /dev/null @@ -1,229 +0,0 @@ -//! OpenAI Chat Completions API (`/v1/chat/completions`) — covers ChatGPT model IDs. - -use std::sync::Arc; - -use id_effect::{Effect, HCons, HNil, Layer, Stream, fail, hlist, suspend}; -use id_effect_platform::http::{HttpClient, HttpMethod, HttpRequest, HttpResponse}; -use serde::Deserialize; -use serde_json::json; - -use crate::config::AiConfig; -use crate::error::AiError; -use crate::http_util::{bearer_header, check_status, from_result, join_url}; -use crate::model::{ - ChatMessage, ChatRequest, ChatResponse, ChatRole, LanguageModel, LanguageModelService, -}; -use crate::retry::retry_transient_ai_http; -use crate::sse::SseParser; -use crate::streaming::CompletionChunk; -use crate::tracing_util::with_ai_span; - -/// OpenAI-compatible chat client. -#[derive(Clone)] -pub struct OpenAiLanguageModel { - client: Arc, - api_key: String, - base_url: String, -} - -impl OpenAiLanguageModel { - /// Create from HTTP client and config (requires API key). - pub fn new(client: Arc, config: &AiConfig) -> Result { - let key = config.require_openai_key()?.expose().clone(); - Ok(Self { - client, - api_key: key, - base_url: config.openai_base_url.clone(), - }) - } - - fn role_str(role: ChatRole) -> &'static str { - match role { - ChatRole::System => "system", - ChatRole::User => "user", - ChatRole::Assistant => "assistant", - } - } - - fn build_messages(messages: &[ChatMessage]) -> Vec { - messages - .iter() - .map(|m| { - json!({ - "role": Self::role_str(m.role), - "content": m.content, - }) - }) - .collect() - } - - fn url(&self) -> String { - join_url(&self.base_url, "v1/chat/completions") - } - - /// The validated chat-completions request for `req` (`stream` selects SSE). - fn chat_request(&self, req: &ChatRequest, stream: bool) -> Result { - req.validate()?; - let body = json!({ - "model": req.model, - "messages": Self::build_messages(&req.messages), - "stream": stream, - }); - let bytes = serde_json::to_vec(&body).map_err(|e| AiError::InvalidJson(e.to_string()))?; - Ok(self.auth_request(bytes)) - } - - fn parse_completion(resp: HttpResponse) -> Result { - check_status(resp.status, || { - String::from_utf8_lossy(&resp.body).to_string() - })?; - let parsed: OpenAiCompletionResponse = - serde_json::from_slice(&resp.body).map_err(|e| AiError::InvalidJson(e.to_string()))?; - let content = parsed - .choices - .first() - .and_then(|c| c.message.content.clone()) - .filter(|s| !s.is_empty()) - .ok_or(AiError::EmptyResponse)?; - let tokens_used = parsed.usage.and_then(|u| u.total_tokens).unwrap_or(0); - Ok(ChatResponse { - content, - tokens_used, - }) - } - - fn auth_request(&self, body: Vec) -> HttpRequest { - HttpRequest { - method: HttpMethod::Post, - url: self.url(), - headers: vec![ - ("Content-Type".to_string(), "application/json".to_string()), - bearer_header(&self.api_key), - ], - body: Some(body), - timeout: None, - max_body_bytes: None, - } - } -} - -#[derive(Deserialize)] -struct OpenAiCompletionResponse { - choices: Vec, - usage: Option, -} - -#[derive(Deserialize)] -struct OpenAiChoice { - message: OpenAiMessage, -} - -#[derive(Deserialize)] -struct OpenAiMessage { - content: Option, -} - -#[derive(Deserialize)] -struct OpenAiUsage { - total_tokens: Option, -} - -#[derive(Deserialize)] -struct OpenAiStreamChunk { - choices: Vec, -} - -#[derive(Deserialize)] -struct OpenAiStreamChoice { - delta: OpenAiDelta, -} - -#[derive(Deserialize, Default)] -struct OpenAiDelta { - content: Option, -} - -impl LanguageModel for OpenAiLanguageModel { - fn complete(&self, req: ChatRequest) -> Effect { - let this = self.clone(); - let model = req.model.clone(); - with_ai_span( - "openai", - "complete", - &model, - retry_transient_ai_http(move || { - let this = this.clone(); - let req = req.clone(); - suspend(move || { - let http_req = match this.chat_request(&req, false) { - Ok(r) => r, - Err(e) => return fail(e), - }; - this - .client - .execute(http_req) - .map_err(|e| AiError::Upstream(format!("http: {e}"))) - .flat_map(|resp| from_result(OpenAiLanguageModel::parse_completion(resp))) - }) - }), - ) - } - - fn complete_stream(&self, req: ChatRequest) -> Effect, AiError> { - let this = self.clone(); - suspend(move || { - let http_req = match this.chat_request(&req, true) { - Ok(r) => r, - Err(e) => return fail(e), - }; - this - .client - .execute_stream(http_req) - .map_err(|e| AiError::Upstream(format!("http: {e}"))) - .flat_map(|resp| { - if let Err(e) = check_status(resp.status, || "stream failed".to_string()) { - return fail(e); - } - resp - .body - .collect() - .map_err(|e| AiError::Upstream(format!("stream collect: {e}"))) - .map(|bytes| openai_sse_to_chunk_stream(&String::from_utf8_lossy(&bytes))) - }) - }) - } -} - -fn openai_sse_to_chunk_stream(text: &str) -> Stream { - let mut chunks = Vec::new(); - let mut parser = SseParser::new(); - for msg in parser.feed(text) { - let data = msg.data(); - if data == "[DONE]" { - break; - } - if let Ok(chunk) = serde_json::from_str::(&data) { - for choice in chunk.choices { - if let Some(delta) = choice.delta.content - && !delta.is_empty() - { - chunks.push(CompletionChunk { delta, done: false }); - } - } - } - } - chunks.push(CompletionChunk { - delta: String::new(), - done: true, - }); - Stream::from_vec(chunks) -} - -/// Register OpenAI [`LanguageModel`]: a [`Layer`] providing [`LanguageModelService`]. -pub fn provide_openai_language_model( - client: Arc, - config: AiConfig, -) -> Result, E>, AiError> { - let model: LanguageModelService = Arc::new(OpenAiLanguageModel::new(client, &config)?); - Ok(Layer::succeed(hlist![model])) -} diff --git a/crates/id_effect_ai/src/vendors/openai/chat.rs b/crates/id_effect_ai/src/vendors/openai/chat.rs new file mode 100644 index 000000000..e73e6a163 --- /dev/null +++ b/crates/id_effect_ai/src/vendors/openai/chat.rs @@ -0,0 +1,293 @@ +//! OpenAI Chat Completions API (`POST /v1/chat/completions`), buffered and streamed (SSE). +//! +//! Every request carries `max_completion_tokens` (the request's +//! [`max_tokens`](crate::ChatRequest::max_tokens), else +//! [`AiLimits::max_tokens`](crate::AiLimits::max_tokens)). The API key is held as a +//! [`Secret`] and only exposed to build the `Authorization` header. + +use std::fmt; +use std::sync::Arc; + +use id_effect::{Effect, HCons, HNil, Layer, Stream}; +use id_effect_config::Secret; +use id_effect_platform::http::{HttpClient, HttpRequest, HttpResponse}; +use serde::Deserialize; +use serde_json::json; + +use crate::chat::{ + ChatMessage, ChatRequest, ChatResponse, ChatRole, LanguageModel, LanguageModelService, Usage, + provide_language_model, +}; +use crate::config::AiConfig; +use crate::error::{AiError, truncate_detail}; +use crate::http_util::{bearer_header, join_url}; +use crate::stream::{CompletionChunk, SseMessage, SseStep}; +use crate::tracing_util::{observe_chat, observe_chat_stream}; +use crate::vendors::Transport; + +/// OpenAI-compatible chat client. `Debug` never prints the API key. +#[derive(Clone)] +pub struct OpenAiLanguageModel { + transport: Transport, + api_key: Secret, + base_url: String, +} + +impl fmt::Debug for OpenAiLanguageModel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OpenAiLanguageModel") + .field("base_url", &self.base_url) + .field("api_key", &self.api_key) + .field("transport", &self.transport) + .finish() + } +} + +impl OpenAiLanguageModel { + /// Create from the platform HTTP client and config (requires the API key). + pub fn new(client: Arc, config: &AiConfig) -> Result { + let api_key = config.require_openai_key()?.clone(); + Ok(Self { + transport: Transport::new(client, config), + api_key, + base_url: config.openai_base_url.clone(), + }) + } + + /// Create with the client [`AiConfig::http_client`] builds (SSRF policy and timeouts of + /// [`AiConfig::http`]). + pub fn from_config(config: &AiConfig) -> Result { + Self::new(config.http_client(), config) + } + + fn role_str(role: ChatRole) -> &'static str { + match role { + ChatRole::System => "system", + ChatRole::User => "user", + ChatRole::Assistant => "assistant", + } + } + + fn build_messages(messages: &[ChatMessage]) -> Vec { + messages + .iter() + .map(|m| json!({ "role": Self::role_str(m.role), "content": m.content })) + .collect() + } + + /// The validated chat-completions request for `req` (`stream` selects SSE). + fn chat_request(&self, req: &ChatRequest, stream: bool) -> Result { + req.validate()?; + let body = json!({ + "model": req.model, + "messages": Self::build_messages(&req.messages), + "max_completion_tokens": req.effective_max_tokens(self.transport.limits.max_tokens), + "stream": stream, + }); + self.transport.post_json( + join_url(&self.base_url, "v1/chat/completions"), + vec![bearer_header(self.api_key.expose())], + &body, + stream, + ) + } + + fn parse_completion(resp: HttpResponse) -> Result { + let parsed: OpenAiCompletionResponse = + serde_json::from_slice(&resp.body).map_err(|e| AiError::InvalidJson(e.to_string()))?; + let choice = parsed.choices.into_iter().next(); + let stop_reason = choice.as_ref().and_then(|c| c.finish_reason.clone()); + let content = choice + .and_then(|c| c.message.content) + .filter(|s| !s.is_empty()) + .ok_or(AiError::EmptyResponse)?; + let usage = parsed.usage.map_or_else(Usage::default, |u| { + Usage::new( + u.prompt_tokens.unwrap_or(0), + u.completion_tokens.unwrap_or(0), + ) + }); + let resp = ChatResponse::text(content).with_usage(usage); + Ok(match stop_reason { + Some(reason) => resp.with_stop_reason(reason), + None => resp, + }) + } +} + +#[derive(Deserialize)] +struct OpenAiCompletionResponse { + choices: Vec, + usage: Option, +} + +#[derive(Deserialize)] +struct OpenAiChoice { + message: OpenAiMessage, + finish_reason: Option, +} + +#[derive(Deserialize)] +struct OpenAiMessage { + content: Option, +} + +#[derive(Deserialize)] +struct OpenAiUsage { + prompt_tokens: Option, + completion_tokens: Option, +} + +#[derive(Deserialize)] +struct OpenAiStreamChunk { + #[serde(default)] + choices: Vec, + error: Option, +} + +#[derive(Deserialize)] +struct OpenAiStreamChoice { + #[serde(default)] + delta: OpenAiDelta, +} + +#[derive(Deserialize, Default)] +struct OpenAiDelta { + content: Option, +} + +#[derive(Deserialize)] +struct OpenAiStreamError { + message: Option, +} + +/// One OpenAI stream event: `[DONE]` ends the stream, an `error` object fails it, text deltas +/// of every choice are concatenated. +fn decode_stream_event(msg: &SseMessage) -> SseStep { + let data = msg.data(); + let data = data.trim(); + if data == "[DONE]" { + return SseStep::Done; + } + if data.is_empty() { + return SseStep::Skip; + } + match serde_json::from_str::(data) { + Ok(OpenAiStreamChunk { + error: Some(error), .. + }) => SseStep::Fail(AiError::Upstream(truncate_detail(format!( + "stream error: {}", + error.message.unwrap_or_default() + )))), + Ok(chunk) => { + let delta: String = chunk + .choices + .into_iter() + .filter_map(|c| c.delta.content) + .collect(); + if delta.is_empty() { + SseStep::Skip + } else { + SseStep::Delta(delta) + } + } + Err(e) => SseStep::Fail(AiError::InvalidJson(e.to_string())), + } +} + +impl LanguageModel for OpenAiLanguageModel { + fn complete(&self, req: ChatRequest) -> Effect { + let this = self.clone(); + let body = req.clone(); + let call = self.transport.call( + Arc::new(move || this.chat_request(&body, false)), + Self::parse_completion, + ); + observe_chat("openai", &req, self.transport.log_prompts, call) + } + + fn complete_stream(&self, req: ChatRequest) -> Effect, AiError> { + let this = self.clone(); + let body = req.clone(); + let open = self.transport.open_stream( + Arc::new(move || this.chat_request(&body, true)), + decode_stream_event, + ); + observe_chat_stream("openai", &req, self.transport.log_prompts, open) + } +} + +/// Register OpenAI [`LanguageModel`]: a [`Layer`] providing [`LanguageModelService`]. +pub fn provide_openai_language_model( + client: Arc, + config: AiConfig, +) -> Result, E>, AiError> { + Ok(provide_language_model(OpenAiLanguageModel::new( + client, &config, + )?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event(data: &str) -> SseStep { + decode_stream_event(&SseMessage { + event: None, + data_lines: vec![data.to_string()], + }) + } + + #[test] + fn stream_events_decode() { + assert_eq!(event("[DONE]"), SseStep::Done); + assert_eq!(event(""), SseStep::Skip); + assert_eq!( + event(r#"{"choices":[{"delta":{"content":"a"}},{"delta":{"content":"b"}}]}"#), + SseStep::Delta("ab".into()) + ); + assert_eq!(event(r#"{"choices":[{"delta":{}}]}"#), SseStep::Skip); + assert_eq!(event(r#"{"choices":[{}]}"#), SseStep::Skip); + assert!(matches!( + event(r#"{"error":{"message":"overloaded"}}"#), + SseStep::Fail(AiError::Upstream(m)) if m.contains("overloaded") + )); + assert!(matches!( + event("not json"), + SseStep::Fail(AiError::InvalidJson(_)) + )); + } + + fn response(body: &str) -> HttpResponse { + HttpResponse { + status: 200, + headers: vec![], + body: body.as_bytes().to_vec(), + } + } + + #[test] + fn completion_parsing() { + let resp = OpenAiLanguageModel::parse_completion(response( + r#"{"choices":[{"message":{"content":"hi"},"finish_reason":"length"}],"usage":{"prompt_tokens":2,"completion_tokens":3}}"#, + )) + .expect("parse"); + assert_eq!(resp.content, "hi"); + assert_eq!(resp.usage, Usage::new(2, 3)); + assert_eq!(resp.stop_reason.as_deref(), Some("length")); + let resp = OpenAiLanguageModel::parse_completion(response( + r#"{"choices":[{"message":{"content":"x"}}]}"#, + )) + .expect("parse"); + assert_eq!(resp.usage, Usage::default()); + assert_eq!(resp.stop_reason, None); + assert_eq!( + OpenAiLanguageModel::parse_completion(response(r#"{"choices":[]}"#)), + Err(AiError::EmptyResponse) + ); + assert!(matches!( + OpenAiLanguageModel::parse_completion(response("{")), + Err(AiError::InvalidJson(_)) + )); + } +} diff --git a/crates/id_effect_ai/src/vendors/openai/embed.rs b/crates/id_effect_ai/src/vendors/openai/embed.rs new file mode 100644 index 000000000..3a6925d26 --- /dev/null +++ b/crates/id_effect_ai/src/vendors/openai/embed.rs @@ -0,0 +1 @@ +//! OpenAI embeddings (`POST /v1/embeddings`, features `openai` and `embeddings`). diff --git a/crates/id_effect_ai/src/vendors/openai/mod.rs b/crates/id_effect_ai/src/vendors/openai/mod.rs new file mode 100644 index 000000000..66726484c --- /dev/null +++ b/crates/id_effect_ai/src/vendors/openai/mod.rs @@ -0,0 +1,8 @@ +//! OpenAI (`feature = "openai"`): Chat Completions in [`chat`], covering the ChatGPT model ids +//! and OpenAI-compatible servers; embeddings in `embed` (feature `embeddings`). + +pub mod chat; +#[cfg(feature = "embeddings")] +pub mod embed; + +pub use chat::{OpenAiLanguageModel, provide_openai_language_model}; diff --git a/crates/id_effect_ai/tests/anthropic_wiremock.rs b/crates/id_effect_ai/tests/anthropic_wiremock.rs index 8309073be..2d31e2864 100644 --- a/crates/id_effect_ai/tests/anthropic_wiremock.rs +++ b/crates/id_effect_ai/tests/anthropic_wiremock.rs @@ -1,70 +1,79 @@ +//! Anthropic adapter against a wiremock server (no live vendor call, D8). #![cfg(feature = "anthropic")] -use id_effect::Exit; -use id_effect::{Effect, HCons, HNil}; +use std::sync::Arc; + +use id_effect::{Effect, Exit, HCons, HNil}; use id_effect_ai::{ - AiConfig, AiError, AnthropicLanguageModel, ChatMessage, ChatRequest, ChatRole, LanguageModel, - LanguageModelService, complete, provide_anthropic_language_model, + AiConfig, AiError, AnthropicLanguageModel, ChatMessage, ChatRequest, LanguageModel, + LanguageModelService, Usage, complete, provide_anthropic_language_model, }; -use id_effect_config::Secret; +use id_effect_platform::http::HttpClientConfig; use id_effect_tokio::TokioRuntime; -use std::sync::Arc; -use wiremock::matchers::{header, method, path}; +use wiremock::matchers::{body_partial_json, header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; +/// The config for `server`, with the local mock server (127.0.0.1) allowed by the SSRF policy. fn test_config(server_uri: &str) -> AiConfig { - AiConfig { - anthropic_api_key: Some(Secret::new("ant-key".to_string())), - anthropic_base_url: server_uri.to_string(), - ..AiConfig::default() - } -} - -/// The default client with the local mock server (127.0.0.1) exempt from the SSRF guard. -fn local_client() -> id_effect_platform::http::ReqwestHttpClient { - let config = id_effect_platform::http::HttpClientConfig::default().allow_hosts(["127.0.0.1"]); - id_effect_platform::http::ReqwestHttpClient::from_config(&config) + AiConfig::default() + .with_anthropic_api_key("ant-key") + .with_anthropic_base_url(server_uri) + .with_http(HttpClientConfig::default().allow_hosts(["127.0.0.1"])) } async fn run(eff: Effect) -> Exit { TokioRuntime::current().expect("tokio").run(eff).await } +fn hi() -> ChatRequest { + ChatRequest::user("claude-sonnet-4-20250514", "hi") +} + #[tokio::test] async fn anthropic_complete_success() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/messages")) .and(header("x-api-key", "ant-key")) + .and(header("anthropic-version", "2023-06-01")) + .and(body_partial_json(serde_json::json!({ + "system": "be brief", + "messages": [{"role": "user", "content": "hi"}] + }))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "content": [{"type": "text", "text": "claude says hi"}], - "usage": {"output_tokens": 4} + "usage": {"input_tokens": 3, "output_tokens": 4}, + "stop_reason": "end_turn" }))) .mount(&server) .await; - let client = Arc::new(local_client()); let cfg = test_config(&server.uri()); - let layer = provide_anthropic_language_model::(client, cfg).expect("provider"); - - let req = ChatRequest { - model: "claude-sonnet-4-20250514".into(), - messages: vec![ChatMessage { - role: ChatRole::User, - content: "hi".into(), - }], - }; + let layer = + provide_anthropic_language_model::(cfg.http_client(), cfg).expect("provider"); + let req = ChatRequest::new( + "claude-sonnet-4-20250514", + vec![ChatMessage::system("be brief"), ChatMessage::user("hi")], + ); let program = complete::, _>(req).provide_layer(layer); let resp = run(program).await.into_result().expect("complete"); assert_eq!(resp.content, "claude says hi"); + assert_eq!(resp.usage, Usage::new(3, 4)); + assert_eq!(resp.stop_reason.as_deref(), Some("end_turn")); } #[tokio::test] async fn anthropic_stream_parses_sse() { let server = MockServer::start().await; - let body = "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"yo\"}}\n\n"; + let body = concat!( + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{}}\n\n", + "event: ping\ndata: {\"type\":\"ping\"}\n\n", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"yo\"}}\n\n", + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + ); Mock::given(method("POST")) .and(path("/v1/messages")) + .and(body_partial_json(serde_json::json!({"stream": true}))) .respond_with( ResponseTemplate::new(200) .insert_header("content-type", "text/event-stream") @@ -73,17 +82,8 @@ async fn anthropic_stream_parses_sse() { .mount(&server) .await; - let client = Arc::new(local_client()); - let cfg = test_config(&server.uri()); - let model = AnthropicLanguageModel::new(client, &cfg).expect("model"); - let req = ChatRequest { - model: "claude-sonnet-4-20250514".into(), - messages: vec![ChatMessage { - role: ChatRole::User, - content: "hi".into(), - }], - }; - let stream = run(model.complete_stream(req)) + let model = AnthropicLanguageModel::from_config(&test_config(&server.uri())).expect("model"); + let stream = run(model.complete_stream(hi())) .await .into_result() .expect("stream"); @@ -92,5 +92,69 @@ async fn anthropic_stream_parses_sse() { .into_result() .expect("chunks"); let text: String = chunks.iter().map(|c| c.delta.clone()).collect(); - assert!(text.contains("yo")); + assert_eq!(text, "yo"); + assert!(chunks.last().expect("done").done); +} + +#[tokio::test] +async fn anthropic_stream_error_event_fails_the_stream() { + let server = MockServer::start().await; + let body = concat!( + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"text\":\"a\"}}\n\n", + "event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}\n\n", + ); + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(&server) + .await; + let model = AnthropicLanguageModel::from_config(&test_config(&server.uri())).expect("model"); + let stream = run(model.complete_stream(hi())) + .await + .into_result() + .expect("stream"); + let err = run(stream.run_collect()).await.into_result().unwrap_err(); + assert!(matches!(err.fail_option(), Some(AiError::Upstream(m)) if m.contains("Overloaded"))); +} + +#[tokio::test] +async fn anthropic_transient_status_is_retried() { + let server = MockServer::start().await; + // 529 is Anthropic's "overloaded". + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(529)) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "content": [{"type": "text", "text": "back"}] + }))) + .mount(&server) + .await; + let model = AnthropicLanguageModel::from_config(&test_config(&server.uri())).expect("model"); + let resp = run(model.complete(hi())) + .await + .into_result() + .expect("retried"); + assert_eq!(resp.content, "back"); +} + +#[tokio::test] +async fn anthropic_bad_request_is_not_retried() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(400).set_body_string("bad model")) + .expect(1) + .mount(&server) + .await; + let model = AnthropicLanguageModel::from_config(&test_config(&server.uri())).expect("model"); + let err = run(model.complete(hi())).await.into_result().unwrap_err(); + assert!(matches!(err.fail_option(), Some(AiError::Upstream(m)) if m.contains("bad model"))); +} + +#[test] +fn anthropic_requires_its_key() { + assert!(AnthropicLanguageModel::from_config(&AiConfig::default()).is_err()); + let client = Arc::new(id_effect_platform::http::ReqwestHttpClient::default_client()); + assert!(provide_anthropic_language_model::(client, AiConfig::default()).is_err()); } diff --git a/crates/id_effect_ai/tests/cursor_fixtures.rs b/crates/id_effect_ai/tests/cursor_fixtures.rs index 703a6a85c..a0b01ed6c 100644 --- a/crates/id_effect_ai/tests/cursor_fixtures.rs +++ b/crates/id_effect_ai/tests/cursor_fixtures.rs @@ -1,32 +1,67 @@ +//! Cursor Cloud Agents client against fixture responses (D6: kept behind `cursor`). #![cfg(feature = "cursor")] -use id_effect_ai::{AiConfig, CreateAgentRequest, CursorAgentsClient, HttpCursorAgentsClient}; -use id_effect_config::Secret; +use std::sync::Arc; +use std::time::Duration; + +use id_effect_ai::{ + AiConfig, AiError, CreateAgentRequest, CursorAgentsClient, CursorAgentsClientService, CursorRepo, + HttpCursorAgentsClient, provide_cursor_agents_client, +}; use id_effect_platform::error::HttpError; use id_effect_platform::http::{ - HttpClient, HttpMethod, HttpRequest, HttpResponse, StreamingHttpResponse, + HttpBody, HttpClient, HttpMethod, HttpRequest, HttpResponse, StreamingHttpResponse, }; use id_effect_tokio::TokioRuntime; -use std::sync::Arc; -#[derive(Clone, Default)] -struct MockHttpClient; +/// Answers the Cursor endpoints with fixtures; `status` replaces 200 for every call, and a run +/// reports `run_status`. +#[derive(Clone)] +struct MockHttpClient { + status: u16, + run_status: &'static str, + transport_error: Option, +} + +impl Default for MockHttpClient { + fn default() -> Self { + Self { + status: 200, + run_status: "FINISHED", + transport_error: None, + } + } +} impl HttpClient for MockHttpClient { fn execute(&self, req: HttpRequest) -> id_effect::Effect { + if let Some(e) = self.transport_error.clone() { + return id_effect::fail(e); + } + assert!(req.timeout.is_some(), "every request carries a timeout"); + assert!( + req.max_body_bytes.is_some(), + "every request carries a body cap" + ); let body = if req.url.contains("/v1/models") { br#"{"items":[{"id":"composer-2","displayName":"Composer 2"}]}"#.to_vec() } else if req.url.ends_with("/v1/agents") && matches!(req.method, HttpMethod::Post) { br#"{"agent":{"id":"bc-test","name":"t","status":"ACTIVE","latestRunId":"run-1"},"run":{"id":"run-1","agentId":"bc-test","status":"CREATING"}}"#.to_vec() - } else if req.url.contains("/runs/run-1") { - br#"{"id":"run-1","agentId":"bc-test","status":"FINISHED"}"#.to_vec() + } else if req.url.ends_with("/runs") && matches!(req.method, HttpMethod::Post) { + br#"{"run":{"id":"run-2","agentId":"bc-test","status":"RUNNING"}}"#.to_vec() + } else if req.url.contains("/runs/") { + format!( + r#"{{"id":"run-1","agentId":"bc-test","status":"{}"}}"#, + self.run_status + ) + .into_bytes() } else if req.url.contains("/v1/agents/bc-test") { br#"{"id":"bc-test","name":"t","status":"ACTIVE","latestRunId":"run-1"}"#.to_vec() } else { b"{}".to_vec() }; id_effect::succeed(HttpResponse { - status: 200, + status: self.status, headers: vec![], body, }) @@ -36,53 +71,133 @@ impl HttpClient for MockHttpClient { &self, _req: HttpRequest, ) -> id_effect::Effect { - id_effect::fail(HttpError::InvalidRequest( - "stream not supported in mock".into(), - )) + id_effect::succeed(StreamingHttpResponse { + status: 200, + headers: vec![], + body: HttpBody::empty(), + }) } } fn fixture_config() -> AiConfig { - AiConfig { - cursor_api_key: Some(Secret::new("cursor-test".to_string())), - cursor_base_url: "https://api.cursor.com".to_string(), - ..AiConfig::default() - } + AiConfig::default().with_cursor_api_key("cursor-test") } -#[tokio::test] -async fn cursor_list_models_fixture() { - let client = Arc::new(MockHttpClient) as Arc; - let agents = HttpCursorAgentsClient::new(client, fixture_config()).expect("client"); - let models = TokioRuntime::current() +fn agents(client: MockHttpClient) -> HttpCursorAgentsClient { + HttpCursorAgentsClient::new(Arc::new(client), fixture_config()).expect("client") +} + +async fn run(eff: id_effect::Effect) -> Result { + TokioRuntime::current() .expect("tokio") - .run(agents.list_models()) + .run(eff) .await .into_result() + .map_err(|c| c.fail_option().expect("typed failure")) +} + +#[tokio::test] +async fn cursor_list_models_fixture() { + let models = run(agents(MockHttpClient::default()).list_models()) + .await .expect("models"); assert_eq!(models[0].id, "composer-2"); + assert_eq!(models[0].display_name.as_deref(), Some("Composer 2")); } #[tokio::test] async fn cursor_create_and_poll_fixture() { - let client = Arc::new(MockHttpClient) as Arc; - let agents = HttpCursorAgentsClient::new(client, fixture_config()).expect("client"); - let rt = TokioRuntime::current().expect("tokio"); - let (agent, run) = rt - .run(agents.create_agent(CreateAgentRequest { - prompt_text: "hello".into(), - model_id: Some("composer-2".into()), - repos: vec![], - })) - .await - .into_result() - .expect("create"); + let agents = agents(MockHttpClient::default()); + let (agent, run_) = run(agents.create_agent(CreateAgentRequest { + prompt_text: "hello".into(), + model_id: Some("composer-2".into()), + repos: vec![CursorRepo { + url: "https://github.com/example/repo".into(), + starting_ref: Some("main".into()), + }], + })) + .await + .expect("create"); assert_eq!(agent.id, "bc-test"); - assert_eq!(run.id, "run-1"); - let done = rt - .run(agents.wait_until_idle(&agent.id, &run.id, std::time::Duration::from_secs(2))) + assert_eq!(run_.id, "run-1"); + let done = run(agents.wait_until_idle(&agent.id, &run_.id, Duration::from_secs(2))) .await - .into_result() .expect("poll"); assert!(done.status.is_terminal()); } + +#[tokio::test] +async fn cursor_get_agent_and_followup_fixture() { + let agents = agents(MockHttpClient::default()); + let agent = run(agents.get_agent("bc-test")).await.expect("agent"); + assert_eq!(agent.latest_run_id.as_deref(), Some("run-1")); + let followup = run(agents.send_followup("bc-test", "more")) + .await + .expect("followup"); + assert_eq!(followup.id, "run-2"); +} + +#[tokio::test] +async fn cursor_wait_until_idle_times_out() { + let agents = agents(MockHttpClient { + run_status: "RUNNING", + ..MockHttpClient::default() + }); + let err = run(agents.wait_until_idle("bc-test", "run-1", Duration::from_millis(250))) + .await + .unwrap_err(); + assert!(matches!(err, AiError::CursorAgents(m) if m.contains("timeout"))); +} + +#[tokio::test] +async fn cursor_errors_are_typed() { + let unauthorized = agents(MockHttpClient { + status: 401, + ..MockHttpClient::default() + }); + assert_eq!( + run(unauthorized.get_agent("bc-test")).await.unwrap_err(), + AiError::Unauthorized + ); + assert_eq!( + run(unauthorized.list_models()).await.unwrap_err(), + AiError::Unauthorized + ); + let blocked = agents(MockHttpClient { + transport_error: Some(HttpError::InvalidRequest( + "blocked by the SSRF policy: loopback".into(), + )), + ..MockHttpClient::default() + }); + assert!(matches!( + run(blocked.get_agent("bc-test")).await.unwrap_err(), + AiError::Blocked(_) + )); + let broken = agents(MockHttpClient { + transport_error: Some(HttpError::InvalidRequest("reset".into())), + ..MockHttpClient::default() + }); + assert!(matches!( + run(broken.list_models()).await.unwrap_err(), + AiError::CursorAgents(_) + )); +} + +#[test] +fn cursor_requires_its_key() { + let client: Arc = Arc::new(MockHttpClient::default()); + assert!(HttpCursorAgentsClient::new(Arc::clone(&client), AiConfig::default()).is_err()); + assert!( + provide_cursor_agents_client::(Arc::clone(&client), AiConfig::default()).is_err() + ); + let _layer = provide_cursor_agents_client::(client, fixture_config()).expect("layer"); + let _service: Option = None; + let err = id_effect_ai::cursor::models::list_models( + Arc::new(MockHttpClient::default()), + &AiConfig::default(), + ) + .block_on() + .into_result() + .unwrap_err(); + assert!(err.fail_option().is_some()); +} diff --git a/crates/id_effect_ai/tests/mock_complete.rs b/crates/id_effect_ai/tests/mock_complete.rs deleted file mode 100644 index 0f00c99f4..000000000 --- a/crates/id_effect_ai/tests/mock_complete.rs +++ /dev/null @@ -1,15 +0,0 @@ -use id_effect_ai::{ChatMessage, ChatRequest, ChatRole, LanguageModel, MockLanguageModel}; - -#[test] -fn mock_complete_echoes_user() { - let model = MockLanguageModel::echo(); - let req = ChatRequest { - model: "mock".into(), - messages: vec![ChatMessage { - role: ChatRole::User, - content: "hello".into(), - }], - }; - let resp = model.complete(req).block_on().into_result().expect("run"); - assert_eq!(resp.content, "echo:hello"); -} diff --git a/crates/id_effect_ai/tests/model.rs b/crates/id_effect_ai/tests/model.rs index 46332b786..6e641ba06 100644 --- a/crates/id_effect_ai/tests/model.rs +++ b/crates/id_effect_ai/tests/model.rs @@ -1,30 +1,34 @@ +//! The `LanguageModel` capability with the echo mock. + use id_effect::{HCons, HNil}; use id_effect_ai::{ - AiError, ChatMessage, ChatRequest, ChatRole, LanguageModel, LanguageModelService, - MockLanguageModel, complete, provide_mock_language_model, + AiError, ChatMessage, ChatRequest, LanguageModel, LanguageModelService, MockLanguageModel, + complete, provide_mock_language_model, }; use id_effect_tokio::TokioRuntime; #[test] fn chat_request_validate_empty_fails() { - let req = ChatRequest { - model: "mock".into(), - messages: vec![], - }; + let req = ChatRequest::new("mock", Vec::::new()); assert!(req.validate().is_err()); } +#[test] +fn mock_complete_echoes_user() { + let resp = MockLanguageModel::echo() + .complete(ChatRequest::user("mock", "hello")) + .block_on() + .into_result() + .expect("run"); + assert_eq!(resp.content, "echo:hello"); + assert_eq!(resp.usage.output_tokens, 10); +} + #[tokio::test] async fn mock_complete_echoes_user_message() { - let req = ChatRequest { - model: "mock".into(), - messages: vec![ChatMessage { - role: ChatRole::User, - content: "hello".into(), - }], - }; - let program = complete::, _>(req) - .provide_layer(provide_mock_language_model::()); + let program = + complete::, _>(ChatRequest::user("mock", "hello")) + .provide_layer(provide_mock_language_model::()); let resp = TokioRuntime::current() .expect("tokio") .run(program) @@ -36,17 +40,9 @@ async fn mock_complete_echoes_user_message() { #[tokio::test] async fn mock_stream_yields_chunks() { - let model = MockLanguageModel::echo(); - let req = ChatRequest { - model: "mock".into(), - messages: vec![ChatMessage { - role: ChatRole::User, - content: "ab".into(), - }], - }; let rt = TokioRuntime::current().expect("tokio"); let stream = rt - .run(model.complete_stream(req)) + .run(MockLanguageModel::echo().complete_stream(ChatRequest::user("mock", "ab"))) .await .into_result() .expect("stream"); diff --git a/crates/id_effect_ai/tests/openai_wiremock.rs b/crates/id_effect_ai/tests/openai_wiremock.rs index bfc4bd8f8..d40fc1b85 100644 --- a/crates/id_effect_ai/tests/openai_wiremock.rs +++ b/crates/id_effect_ai/tests/openai_wiremock.rs @@ -1,62 +1,71 @@ +//! OpenAI adapter against a wiremock server (no live vendor call, D8). #![cfg(feature = "openai")] -use id_effect::Exit; -use id_effect::{Effect, HCons, HNil}; +use std::sync::Arc; +use std::time::Duration; + +use id_effect::{Effect, Exit, HCons, HNil, Stream}; use id_effect_ai::{ - AiConfig, AiError, ChatMessage, ChatRequest, ChatRole, LanguageModel, LanguageModelService, - OpenAiLanguageModel, complete, provide_openai_language_model, + AiConfig, AiError, AiLimits, ChatMessage, ChatRequest, CompletionChunk, LanguageModel, + LanguageModelService, OpenAiLanguageModel, Usage, complete, provide_openai_language_model, }; -use id_effect_config::Secret; +use id_effect_platform::http::HttpClientConfig; use id_effect_tokio::TokioRuntime; -use std::sync::Arc; -use wiremock::matchers::{header, method, path}; +use wiremock::matchers::{body_partial_json, header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; +const KEY: &str = "test-key"; + +/// The config for `server`, with the local mock server (127.0.0.1) allowed by the SSRF policy. fn test_config(server_uri: &str) -> AiConfig { - AiConfig { - openai_api_key: Some(Secret::new("test-key".to_string())), - openai_base_url: server_uri.to_string(), - ..AiConfig::default() - } + AiConfig::default() + .with_openai_api_key(KEY) + .with_openai_base_url(server_uri) + .with_http(HttpClientConfig::default().allow_hosts(["127.0.0.1"])) } -/// The default client with the local mock server (127.0.0.1) exempt from the SSRF guard. -fn local_client() -> id_effect_platform::http::ReqwestHttpClient { - let config = id_effect_platform::http::HttpClientConfig::default().allow_hosts(["127.0.0.1"]); - id_effect_platform::http::ReqwestHttpClient::from_config(&config) +fn model(server: &MockServer) -> OpenAiLanguageModel { + OpenAiLanguageModel::from_config(&test_config(&server.uri())).expect("model") } async fn run(eff: Effect) -> Exit { TokioRuntime::current().expect("tokio").run(eff).await } +async fn collect( + stream: Stream, +) -> Result, AiError> { + run(stream.run_collect()) + .await + .into_result() + .map_err(|c| c.fail_option().expect("typed failure")) +} + +fn hi() -> ChatRequest { + ChatRequest::user("gpt-4o", "hi") +} + #[tokio::test] async fn openai_complete_success() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/chat/completions")) - .and(header("Authorization", "Bearer test-key")) + .and(header("Authorization", format!("Bearer {KEY}").as_str())) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "choices": [{"message": {"content": "hello"}}], - "usage": {"total_tokens": 3} + "choices": [{"message": {"content": "hello"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3} }))) .mount(&server) .await; - let client = Arc::new(local_client()); - let cfg = test_config(&server.uri()); - let layer = provide_openai_language_model::(client, cfg).expect("provider"); - - let req = ChatRequest { - model: "gpt-4o".into(), - messages: vec![ChatMessage { - role: ChatRole::User, - content: "hi".into(), - }], - }; - let program = complete::, _>(req).provide_layer(layer); + let client = test_config(&server.uri()).http_client(); + let layer = + provide_openai_language_model::(client, test_config(&server.uri())).expect("provider"); + let program = complete::, _>(hi()).provide_layer(layer); let resp = run(program).await.into_result().expect("complete"); assert_eq!(resp.content, "hello"); + assert_eq!(resp.usage, Usage::new(1, 2)); + assert_eq!(resp.stop_reason.as_deref(), Some("stop")); } #[tokio::test] @@ -69,17 +78,10 @@ async fn openai_unauthorized_no_retry() { .mount(&server) .await; - let client = Arc::new(local_client()); - let cfg = test_config(&server.uri()); - let model = OpenAiLanguageModel::new(client, &cfg).expect("model"); - let req = ChatRequest { - model: "gpt-4o".into(), - messages: vec![ChatMessage { - role: ChatRole::User, - content: "hi".into(), - }], - }; - let err = run(model.complete(req)).await.into_result().unwrap_err(); + let err = run(model(&server).complete(hi())) + .await + .into_result() + .unwrap_err(); assert_eq!(err.fail_option(), Some(AiError::Unauthorized)); } @@ -101,19 +103,136 @@ async fn openai_retries_transient_then_succeeds() { .mount(&server) .await; - let client = Arc::new(local_client()); - let cfg = test_config(&server.uri()); - let model = OpenAiLanguageModel::new(client, &cfg).expect("model"); - let req = ChatRequest { - model: "gpt-4o".into(), - messages: vec![ChatMessage { - role: ChatRole::User, - content: "hi".into(), - }], - }; - let resp = run(model.complete(req)) + let resp = run(model(&server).complete(hi())) .await .into_result() .expect("retry ok"); assert_eq!(resp.content, "ok"); } + +#[tokio::test] +async fn openai_upstream_error_detail_is_reported() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(400).set_body_string("model not found")) + .mount(&server) + .await; + let err = run(model(&server).complete(hi())) + .await + .into_result() + .unwrap_err() + .fail_option(); + assert!(matches!(err, Some(AiError::Upstream(d)) if d.contains("model not found"))); +} + +#[tokio::test] +async fn openai_stream_parses_sse() { + let server = MockServer::start().await; + let body = concat!( + "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n", + "data: [DONE]\n\n", + ); + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_partial_json(serde_json::json!({"stream": true}))) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(body), + ) + .mount(&server) + .await; + + let stream = run(model(&server).complete_stream(hi())) + .await + .into_result() + .expect("stream"); + let chunks = collect(stream).await.expect("chunks"); + let text: String = chunks.iter().map(|c| c.delta.as_str()).collect(); + assert_eq!(text, "hello"); + assert!(chunks.last().expect("done").done); +} + +#[tokio::test] +async fn openai_stream_refused_status_is_typed() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(403)) + .mount(&server) + .await; + let err = run(model(&server).complete_stream(hi())) + .await + .into_result() + .unwrap_err(); + assert_eq!(err.fail_option(), Some(AiError::Unauthorized)); +} + +#[tokio::test] +async fn openai_request_timeout_is_typed() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_secs(5)) + .set_body_string("{}"), + ) + .mount(&server) + .await; + let cfg = test_config(&server.uri()) + .with_limits(AiLimits::default().with_request_timeout(Duration::from_millis(100))); + let model = OpenAiLanguageModel::from_config(&cfg).expect("model"); + let err = run(model.complete(hi())).await.into_result().unwrap_err(); + assert_eq!(err.fail_option(), Some(AiError::Timeout)); +} + +#[tokio::test] +async fn openai_response_over_cap_is_refused() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_string("x".repeat(4096))) + .mount(&server) + .await; + let cfg = + test_config(&server.uri()).with_limits(AiLimits::default().with_max_response_bytes(1024)); + let model = OpenAiLanguageModel::from_config(&cfg).expect("model"); + let err = run(model.complete(hi())).await.into_result().unwrap_err(); + assert!(matches!( + err.fail_option(), + Some(AiError::LimitExceeded { + limit: "response_bytes", + .. + }) + )); +} + +#[tokio::test] +async fn openai_invalid_request_is_not_sent() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let model = model(&server); + let empty = ChatRequest::new("gpt-4o", Vec::::new()); + let err = run(model.complete(empty.clone())) + .await + .into_result() + .unwrap_err(); + assert_eq!(err.fail_option(), Some(AiError::EmptyRequest)); + let err = run(model.complete_stream(empty)) + .await + .into_result() + .unwrap_err(); + assert_eq!(err.fail_option(), Some(AiError::EmptyRequest)); +} + +#[test] +fn openai_requires_its_key() { + let err = OpenAiLanguageModel::from_config(&AiConfig::default()).unwrap_err(); + assert!(matches!(err, AiError::Upstream(m) if m.contains("OPENAI_API_KEY"))); + let client = Arc::new(id_effect_platform::http::ReqwestHttpClient::default_client()); + assert!(provide_openai_language_model::(client, AiConfig::default()).is_err()); +} diff --git a/crates/id_effect_ai/tests/scripted.rs b/crates/id_effect_ai/tests/scripted.rs new file mode 100644 index 000000000..78cd59d64 --- /dev/null +++ b/crates/id_effect_ai/tests/scripted.rs @@ -0,0 +1,67 @@ +//! `testing::ScriptedModel`: deterministic replies, tool calls and streams (D8). +#![cfg(feature = "test-util")] + +use id_effect::{HCons, HNil}; +use id_effect_ai::testing::{ScriptedModel, ScriptedReply}; +use id_effect_ai::{ + AiError, ChatRequest, ChatResponse, LanguageModelService, Usage, complete, complete_stream, +}; +use id_effect_tokio::TokioRuntime; + +type Env = HCons; + +#[tokio::test] +async fn scripted_model_replays_in_order() { + let model = ScriptedModel::new() + .reply("one") + .tool_call("call-1", "lookup", serde_json::json!({"sku": "A-1"})) + .respond( + ChatResponse::text("three") + .with_usage(Usage::new(1, 2)) + .with_stop_reason("stop"), + ) + .fail(AiError::Transient { + status: 429, + detail: "rate".into(), + }); + model.push(ScriptedReply::Stream(vec!["fi".into(), "ve".into()])); + let rt = TokioRuntime::current().expect("tokio"); + let ask = |text: &str| { + complete::(ChatRequest::user("m", text)).provide_layer(model.layer::()) + }; + let one = rt.run(ask("q1")).await.into_result().expect("one"); + assert_eq!(one.content, "one"); + let two = rt.run(ask("q2")).await.into_result().expect("two"); + assert_eq!(two.tool_calls[0].name, "lookup"); + assert_eq!(two.tool_calls[0].arguments["sku"], "A-1"); + let three = rt.run(ask("q3")).await.into_result().expect("three"); + assert_eq!(three.usage.total(), 3); + let four = rt.run(ask("q4")).await.into_result().unwrap_err(); + assert!(four.fail_option().expect("typed").is_transient()); + let five = rt + .run( + complete_stream::(ChatRequest::user("m", "q5")) + .provide_layer(model.layer::()), + ) + .await + .into_result() + .expect("stream"); + let chunks = rt + .run(five.run_collect()) + .await + .into_result() + .expect("chunks"); + let text: String = chunks.iter().map(|c| c.delta.as_str()).collect(); + assert_eq!(text, "five"); + + // The requests were recorded in order, and the script is used up. + let asked: Vec = model + .requests() + .iter() + .map(|r| r.messages[0].content.clone()) + .collect(); + assert_eq!(asked, ["q1", "q2", "q3", "q4", "q5"]); + assert_eq!(model.remaining(), 0); + let exhausted = rt.run(ask("q6")).await.into_result().unwrap_err(); + assert!(matches!(exhausted.fail_option(), Some(AiError::Upstream(m)) if m.contains("call 6"))); +} diff --git a/crates/id_effect_ai/tests/security.rs b/crates/id_effect_ai/tests/security.rs new file mode 100644 index 000000000..458549629 --- /dev/null +++ b/crates/id_effect_ai/tests/security.rs @@ -0,0 +1,286 @@ +//! ADR 0013 rules applied to the AI crate (ADR 0015 B13, B14, R6): secrets redacted, prompts not +//! recorded unless the recorded opt-out is taken, provider base URLs under the platform SSRF +//! policy, a finite `max_tokens` on every request, and streams closed over their caps. +#![cfg(all(feature = "openai", feature = "anthropic"))] + +use tokio::sync::{Mutex, MutexGuard}; + +use id_effect::observability::posture::dangerous_opt_outs; +use id_effect::{ + Effect, Exit, SpanRecord, TracingConfig, install_tracing_layer, snapshot_tracing, tracing_hook, +}; +use id_effect_ai::{ + AI_SPAN, AiConfig, AiError, AiLimits, AnthropicLanguageModel, ChatRequest, DANGEROUS_LOG_PROMPTS, + LanguageModel, OpenAiLanguageModel, +}; +use id_effect_platform::http::HttpClientConfig; +use id_effect_tokio::TokioRuntime; +use wiremock::matchers::{any, body_partial_json, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const PROMPT: &str = "my-secret-prompt"; +const COMPLETION: &str = "the-secret-completion"; + +/// Serializes the tests that share the process-global tracing buffer. +async fn trace_lock() -> MutexGuard<'static, ()> { + static LOCK: Mutex<()> = Mutex::const_new(()); + LOCK.lock().await +} + +fn local(cfg: AiConfig, uri: &str) -> AiConfig { + cfg + .with_openai_api_key("sk-openai") + .with_openai_base_url(uri) + .with_anthropic_api_key("sk-anthropic") + .with_anthropic_base_url(uri) + .with_http(HttpClientConfig::default().allow_hosts(["127.0.0.1"])) +} + +async fn run(eff: Effect) -> Exit { + TokioRuntime::current().expect("tokio").run(eff).await +} + +async fn fail_of(eff: Effect) -> Option { + run(eff) + .await + .into_result() + .err() + .and_then(|c| c.fail_option()) +} + +async fn mount_completions(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "choices": [{"message": {"content": COMPLETION}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 5, "completion_tokens": 7} + }))) + .mount(server) + .await; +} + +/// Runs one traced OpenAI completion of [`PROMPT`] and returns the `ai.request` span record. +async fn traced_completion(cfg: AiConfig) -> SpanRecord { + let model = OpenAiLanguageModel::from_config(&cfg).expect("model"); + install_tracing_layer(TracingConfig::enabled()); + let resp = run( + model + .complete(ChatRequest::user("gpt-4o", PROMPT)) + .with_span_hook(tracing_hook()), + ) + .await + .into_result() + .expect("complete"); + assert_eq!(resp.content, COMPLETION); + let snap = snapshot_tracing(); + install_tracing_layer(TracingConfig::default()); + snap + .spans + .into_iter() + .find(|s| s.name == AI_SPAN) + .expect("ai.request span") +} + +#[test] +fn ai_api_key_redacted_in_debug() { + let cfg = AiConfig::default() + .with_openai_api_key("sk-openai-secret") + .with_anthropic_api_key("sk-anthropic-secret") + .with_cursor_api_key("cursor-secret"); + let client = cfg.http_client(); + let openai = OpenAiLanguageModel::new(client.clone(), &cfg).expect("openai"); + let anthropic = AnthropicLanguageModel::new(client, &cfg).expect("anthropic"); + for printed in [ + format!("{cfg:?}"), + format!("{openai:?}"), + format!("{anthropic:?}"), + ] { + for key in ["sk-openai-secret", "sk-anthropic-secret", "cursor-secret"] { + assert!(!printed.contains(key), "{key} printed in {printed}"); + } + assert!(printed.contains(""), "{printed}"); + } +} + +#[tokio::test] +async fn ai_prompts_not_logged_by_default() { + let _g = trace_lock().await; + let server = MockServer::start().await; + mount_completions(&server).await; + let span = traced_completion(local(AiConfig::default(), &server.uri())).await; + for value in span.annotations.values() { + assert!(!value.contains(PROMPT), "prompt recorded: {value}"); + assert!(!value.contains(COMPLETION), "completion recorded: {value}"); + } + assert!(!span.annotations.contains_key("ai.prompt")); + assert!(!span.annotations.contains_key("ai.completion")); + // The metadata is there: vendor, model, usage. + assert_eq!(span.annotations["ai.vendor"], "openai"); + assert_eq!(span.annotations["ai.model"], "gpt-4o"); + assert_eq!(span.annotations["ai.usage.output_tokens"], "7"); +} + +#[tokio::test] +async fn dangerous_log_prompts_is_recorded() { + let _g = trace_lock().await; + let cfg = AiConfig::default(); + assert!(!cfg.logs_prompts()); + let cfg = cfg.dangerous_log_prompts(); + assert!(cfg.logs_prompts()); + assert!( + dangerous_opt_outs() + .iter() + .any(|(id, reopens)| *id == DANGEROUS_LOG_PROMPTS && reopens.starts_with("R3")), + "opt-out not in the posture registry" + ); + // Taken, it does what it says: the prompt and the completion reach the span. + let server = MockServer::start().await; + mount_completions(&server).await; + let span = traced_completion(local(cfg, &server.uri())).await; + assert!(span.annotations["ai.prompt"].contains(PROMPT)); + assert_eq!(span.annotations["ai.completion"], COMPLETION); +} + +#[tokio::test] +async fn ai_base_url_obeys_ssrf_policy() { + // A real server on loopback: the default policy must refuse it without sending anything. + let server = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let loopback = AiConfig::default() + .with_openai_api_key("k") + .with_openai_base_url(server.uri()) + .with_anthropic_api_key("k") + .with_anthropic_base_url(server.uri()); + let openai = OpenAiLanguageModel::from_config(&loopback).expect("openai"); + let anthropic = AnthropicLanguageModel::from_config(&loopback).expect("anthropic"); + let req = ChatRequest::user("m", "hi"); + assert!(matches!( + fail_of(openai.complete(req.clone())).await, + Some(AiError::Blocked(_)) + )); + assert!(matches!( + fail_of(anthropic.complete_stream(req.clone())).await, + Some(AiError::Blocked(_)) + )); + // Cloud metadata and non-HTTP schemes are refused too. + for base in ["http://169.254.169.254", "file:///etc"] { + let cfg = loopback.clone().with_openai_base_url(base); + let model = OpenAiLanguageModel::from_config(&cfg).expect("openai"); + assert!( + matches!( + fail_of(model.complete(req.clone())).await, + Some(AiError::Blocked(_)) + ), + "{base} not blocked" + ); + } +} + +#[tokio::test] +async fn r4_ai_max_tokens_finite_by_default() { + assert_eq!(AiLimits::default().max_tokens, AiLimits::DEFAULT_MAX_TOKENS); + assert_eq!(AiLimits::DEFAULT_MAX_TOKENS, 1024); + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_partial_json( + serde_json::json!({"max_completion_tokens": 1024}), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "choices": [{"message": {"content": "o"}}] + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .and(body_partial_json(serde_json::json!({"max_tokens": 1024}))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "content": [{"type": "text", "text": "a"}] + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .and(body_partial_json(serde_json::json!({"max_tokens": 7}))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "content": [{"type": "text", "text": "seven"}] + }))) + .expect(1) + .mount(&server) + .await; + let cfg = local(AiConfig::default(), &server.uri()); + let openai = OpenAiLanguageModel::from_config(&cfg).expect("openai"); + let anthropic = AnthropicLanguageModel::from_config(&cfg).expect("anthropic"); + let req = ChatRequest::user("m", "hi"); + assert!(req.max_tokens.is_none()); + run(openai.complete(req.clone())) + .await + .into_result() + .expect("openai default cap"); + run(anthropic.complete(req.clone())) + .await + .into_result() + .expect("anthropic default cap"); + let resp = run(anthropic.complete(req.with_max_tokens(7))) + .await + .into_result() + .expect("per-request cap"); + assert_eq!(resp.content, "seven"); +} + +#[tokio::test] +async fn r4_ai_stream_over_cap_closed() { + let server = MockServer::start().await; + let event = "data: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\n\n"; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(event.repeat(1000)), + ) + .mount(&server) + .await; + let req = ChatRequest::user("m", "hi"); + let cases = [ + ( + AiLimits::default().with_max_stream_events(5), + "stream_events", + ), + ( + AiLimits::default().with_max_stream_bytes(256), + "stream_bytes", + ), + ]; + for (limits, name) in cases { + let cfg = local(AiConfig::default(), &server.uri()).with_limits(limits); + let model = OpenAiLanguageModel::from_config(&cfg).expect("openai"); + let stream = run(model.complete_stream(req.clone())) + .await + .into_result() + .expect("stream opens"); + let err = fail_of(stream.run_collect()).await; + assert!( + matches!(err, Some(AiError::LimitExceeded { limit, .. }) if limit == name), + "{name}: {err:?}" + ); + } + // Under the caps the same stream completes. + let cfg = local(AiConfig::default(), &server.uri()); + let model = OpenAiLanguageModel::from_config(&cfg).expect("openai"); + let stream = run(model.complete_stream(req)) + .await + .into_result() + .expect("stream opens"); + let chunks = run(stream.run_collect()) + .await + .into_result() + .expect("within caps"); + assert_eq!(chunks.len(), 1001); +} diff --git a/crates/id_effect_ai/tests/streaming.rs b/crates/id_effect_ai/tests/streaming.rs index 875bdb289..24dcf2f36 100644 --- a/crates/id_effect_ai/tests/streaming.rs +++ b/crates/id_effect_ai/tests/streaming.rs @@ -1,10 +1,15 @@ -use id_effect_ai::mock_chunk_stream; +//! Streaming completions as `id_effect::Stream`. + +use id_effect::{HCons, HNil}; +use id_effect_ai::{ + AiError, ChatRequest, LanguageModelService, complete_stream, mock_chunk_stream, + provide_mock_language_model, +}; use id_effect_tokio::TokioRuntime; #[test] fn mock_chunk_stream_splits_text() { - let stream = mock_chunk_stream("hello-world", 5); - let chunks = stream + let chunks = mock_chunk_stream("hello-world", 5) .run_collect() .block_on() .into_result() @@ -15,13 +20,33 @@ fn mock_chunk_stream_splits_text() { #[tokio::test] async fn mock_chunk_stream_collect_async() { - let stream = mock_chunk_stream("abcd", 2); let chunks = TokioRuntime::current() .expect("tokio") - .run(stream.run_collect()) + .run(mock_chunk_stream("abcd", 2).run_collect()) .await .into_result() .expect("collect"); assert!(!chunks.is_empty()); assert!(!chunks[0].delta.is_empty() || chunks[0].done); } + +#[tokio::test] +async fn complete_stream_reads_the_capability() { + let rt = TokioRuntime::current().expect("tokio"); + let stream = rt + .run( + complete_stream::, _>(ChatRequest::user("m", "xyz")) + .provide_layer(provide_mock_language_model::()), + ) + .await + .into_result() + .expect("stream"); + let chunks = rt + .run(stream.run_collect()) + .await + .into_result() + .expect("collect"); + let text: String = chunks.iter().map(|c| c.delta.as_str()).collect(); + assert_eq!(text, "echo:xyz"); + assert!(chunks.last().expect("last").done); +} diff --git a/crates/id_effect_ai/tests/telemetry.rs b/crates/id_effect_ai/tests/telemetry.rs new file mode 100644 index 000000000..ff1416e5a --- /dev/null +++ b/crates/id_effect_ai/tests/telemetry.rs @@ -0,0 +1,84 @@ +//! ai.llm #7: every vendor call runs in an `ai.request` span with vendor, operation, model and +//! token usage. +#![cfg(all(feature = "openai", feature = "anthropic"))] + +use id_effect::{ + EffectEvent, TracingConfig, install_tracing_layer, snapshot_tracing, tracing_hook, +}; +use id_effect_ai::{ + AI_SPAN, AiConfig, AiError, AnthropicLanguageModel, ChatRequest, LanguageModel, with_ai_span, +}; +use id_effect_platform::http::HttpClientConfig; +use id_effect_tokio::TokioRuntime; +use tokio::sync::Mutex; +use wiremock::matchers::method; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +static TRACE: Mutex<()> = Mutex::const_new(()); + +#[tokio::test] +async fn ai_request_span_records_vendor_model_and_usage() { + let _g = TRACE.lock().await; + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "content": [{"type": "text", "text": "ok"}], + "usage": {"input_tokens": 11, "output_tokens": 13}, + "stop_reason": "max_tokens" + }))) + .mount(&server) + .await; + let cfg = AiConfig::default() + .with_anthropic_api_key("k") + .with_anthropic_base_url(server.uri()) + .with_http(HttpClientConfig::default().allow_hosts(["127.0.0.1"])); + let model = AnthropicLanguageModel::from_config(&cfg).expect("model"); + install_tracing_layer(TracingConfig::enabled()); + TokioRuntime::current() + .expect("tokio") + .run( + model + .complete(ChatRequest::user("claude-x", "hi")) + .with_span_hook(tracing_hook()), + ) + .await + .into_result() + .expect("complete"); + let snap = snapshot_tracing(); + install_tracing_layer(TracingConfig::default()); + let span = snap.spans.iter().find(|s| s.name == AI_SPAN).expect("span"); + let get = |k: &str| span.annotations.get(k).map(String::as_str); + assert_eq!(get("ai.vendor"), Some("anthropic")); + assert_eq!(get("ai.operation"), Some("chat")); + assert_eq!(get("ai.model"), Some("claude-x")); + assert_eq!(get("ai.usage.input_tokens"), Some("11")); + assert_eq!(get("ai.usage.output_tokens"), Some("13")); + assert_eq!(get("ai.stop_reason"), Some("max_tokens")); + assert!(snap.effect_events.contains(&EffectEvent::Success { + span: AI_SPAN.into() + })); +} + +#[tokio::test] +async fn with_ai_span_wraps_any_effect() { + let _g = TRACE.lock().await; + install_tracing_layer(TracingConfig::enabled()); + let eff = with_ai_span( + "custom", + "embed", + "m", + id_effect::fail::(AiError::Timeout), + ); + let exit = TokioRuntime::current() + .expect("tokio") + .run(eff.with_span_hook(tracing_hook())) + .await; + assert!(exit.into_result().is_err()); + let snap = snapshot_tracing(); + install_tracing_layer(TracingConfig::default()); + let span = snap.spans.iter().find(|s| s.name == AI_SPAN).expect("span"); + assert_eq!(span.annotations["ai.vendor"], "custom"); + assert!(snap.effect_events.contains(&EffectEvent::Failure { + span: AI_SPAN.into() + })); +} diff --git a/docs/MIGRATION-0.13.md b/docs/MIGRATION-0.13.md index 6bc6048f7..f7844f014 100644 --- a/docs/MIGRATION-0.13.md +++ b/docs/MIGRATION-0.13.md @@ -97,4 +97,19 @@ id_effect_ai = { path = "../id_effect/labs/id_effect_ai" } id_effect_ai = { path = "../id_effect/crates/id_effect_ai" } # or: id_effect_ai = "0.13" ``` -The API changes of the promotion are listed here by r13-e9-1-promote. +The API changes of the promotion (the labs crate had no semver promise; each has its +replacement): + +| 0.12 (labs) | 0.13 | +|---|---| +| `ChatRequest { model, messages }` literal | `ChatRequest::new(model, messages)` or `ChatRequest::user(model, text)`; the struct is `#[non_exhaustive]` and gains `max_tokens: Option` (`with_max_tokens`) | +| `ChatMessage { role, content }` | still valid; also `ChatMessage::system` / `user` / `assistant` | +| `ChatResponse { content, tokens_used }`, `resp.tokens_used` | `ChatResponse::text(..)` (`#[non_exhaustive]`); `resp.usage.total()` (`Usage { input_tokens, output_tokens }`), plus `stop_reason` and `tool_calls` | +| `AiConfig { openai_api_key: Some(..), .. }` literal | `AiConfig::default().with_openai_api_key(..).with_openai_base_url(..)` (and the Anthropic, Ollama and Cursor twins); fields stay public for reading and assignment | +| `AiConfig::anthropic_max_tokens`, `ANTHROPIC_MAX_TOKENS` | `AiConfig::limits.max_tokens` (`AiLimits`), now sent by both vendors; `AI_MAX_TOKENS` (the old variable is still read) | +| `provide_*_language_model(client, config)` with any platform `HttpClient` | unchanged; `config.http_client()` builds the guarded client from `AiConfig::http` (keep a local mock or Ollama reachable with `HttpClientConfig::allow_hosts`, never by turning the SSRF guard off). Each request now carries `AiLimits::request_timeout` and a body cap | +| `id_effect_ai::model::*`, `id_effect_ai::streaming::*`, `id_effect_ai::sse::*` (private modules, root re-exports) | `id_effect_ai::chat::*` and `id_effect_ai::stream::*`; the root re-exports are unchanged | +| `id_effect_ai::vendors::openai::*`, `vendors::anthropic::*` | `vendors::openai::chat::*`, `vendors::anthropic::chat::*` (re-exported from `vendors::openai` / `vendors::anthropic` and the crate root as before) | +| `AiError` (exhaustive) | `#[non_exhaustive]`, with `InvalidRequest`, `Blocked`, `LimitExceeded` and `Timeout`; HTTP 529 is `Transient` | +| `SseParser::feed` trimmed every leading space of a value | one leading space is removed (the SSE rule); `feed_bytes` takes raw bytes | +| streamed chunks parsed after the whole body was read | decoded incrementally, under `AiLimits::max_stream_bytes` / `max_stream_events` | diff --git a/examples/book-snippets/Cargo.toml b/examples/book-snippets/Cargo.toml index 6cbcca468..744c3c150 100644 --- a/examples/book-snippets/Cargo.toml +++ b/examples/book-snippets/Cargo.toml @@ -19,6 +19,11 @@ id_effect = { path = "../../crates/id_effect", version = "0.12.0", features = [ "schema-regex", "proptest", ] } +id_effect_ai = { path = "../../crates/id_effect_ai", version = "0.12.0", features = [ + "openai", + "anthropic", + "test-util", +] } id_effect_auth = { path = "../../crates/id_effect_auth", version = "0.12.0", features = [ "axum", "jwt", diff --git a/scripts/validate-crate-packaging.sh b/scripts/validate-crate-packaging.sh index bc2c4bf38..d17fa5c21 100755 --- a/scripts/validate-crate-packaging.sh +++ b/scripts/validate-crate-packaging.sh @@ -74,8 +74,8 @@ declare -A LEVEL=( [id_effect_rpc]=5 # 0.13 (ADR 0010 Amendment 9), all at the last level and before id_effect_kit and # id_effect_tool. i18n follows id_effect and id_effect_axum (its `axum` feature); ai follows - # id_effect_platform and id_effect_config (HTTP client, `Secret` keys) and stays in - # PENDING_PROMOTION until r13-e9-1-promote; admin follows id_effect_sql, id_effect_axum, + # id_effect_platform and id_effect_config (HTTP client, `Secret` keys), and its optional + # `mcp-http` feature id_effect_axum and id_effect_authz; admin follows id_effect_sql, id_effect_axum, # id_effect_authz and id_effect_i18n (repository, `ui` views, guards, labels). [id_effect_i18n]=5 [id_effect_ai]=5 @@ -100,10 +100,8 @@ declare -a ORDER=( # Crates in ORDER that still await promotion (ADR 0010 rule 2): either still under labs/, or # already moved into crates/ but `publish = false` until the promoting leaf. Remove the entry in # that leaf (id_effect_rpc: moved in r12-t0-skeletons, publishable since r12-e12-1-promote). -declare -A PENDING_PROMOTION=( - # Moved out of labs/ by r13-t0-skeletons; r13-e9-1-promote makes it publishable. - [id_effect_ai]=r13-e9-1-promote -) +# (id_effect_ai: moved in r13-t0-skeletons, publishable since r13-e9-1-promote.) +declare -A PENDING_PROMOTION=() if [[ "${1:-}" == "--print-order" ]]; then printf '%s\n' "${ORDER[@]}" From afb382b547ac16b1ba91e81d5e5a20d94f7afb18 Mon Sep 17 00:00:00 2001 From: Tom Wieland Date: Wed, 21 Oct 2026 09:11:31 +0200 Subject: [PATCH 15/87] feat(platform,axum): real 0-RTT per route and an HTTP/3 congestion switch (r13-f-h3-settings) F25: the platform QUIC endpoint gains a per-route early-data setting, Route::dangerous_allow_early_data (recorded; refused on idm/1 and on routes that require a verified client). The TLS server offers early data only once a route takes it; such a route receives connections before the handshake completes (AlpnRoute::accept_early with a Handshake), and a connection that sends 0-RTT data on any other route is closed with CLOSE_EARLY_DATA_REFUSED before its handler sees it. Http3Options:: dangerous_allow_zero_rtt_for_queries stops being a no-op: early GET/HEAD requests are served before the handshake completes, everything else is answered 425 Too Early. F36: Http3Options::congestion(CongestionControl::{Bbr, Cubic}) over the route's congestion control (default BBR; the mesh stays CUBIC), read from server.http3.congestion by Http3Options::config(); CongestionControl implements FromStr. Tests: zero_rtt_off_by_default, zero_rtt_admits_only_safe_methods, zero_rtt_mutation_gets_425, mesh_route_never_accepts_early_data, http3_congestion_switch_to_cubic (a UDP relay holds the server's handshake flight so early data is deterministic). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Wieland --- CHANGELOG.md | 14 + crates/id_effect_axum/src/server/h3.rs | 244 +++++++- crates/id_effect_axum/tests/http3_settings.rs | 556 ++++++++++++++++++ .../id_effect_platform/src/quic/congestion.rs | 29 + .../id_effect_platform/src/quic/endpoint.rs | 24 +- crates/id_effect_platform/src/quic/mod.rs | 6 +- crates/id_effect_platform/src/quic/router.rs | 314 +++++++++- .../tests/quic_early_data.rs | 523 ++++++++++++++++ docs/MIGRATION-0.13.md | 17 +- 9 files changed, 1671 insertions(+), 56 deletions(-) create mode 100644 crates/id_effect_axum/tests/http3_settings.rs create mode 100644 crates/id_effect_platform/tests/quic_early_data.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5059f3e42..fe9d0e3ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,20 @@ it arrives instead of stalling until 64 have; pure and bounded sources keep full chunks. No signature changes; backpressure is unchanged (a pull starts no step beyond the one it stops on, and the generator runs only while the consumer pulls). +- HTTP/3 0-RTT made real and a congestion switch (r13-f-h3-settings, 0.12 follow-ups F25, F36): + the platform QUIC endpoint gains a per-route early-data setting, + `Route::dangerous_allow_early_data` (recorded; refused on the `idm/1` mesh and on any route that + requires a verified client). Such a route receives connections before their handshake completes + through `AlpnRoute::accept_early`, with a `Handshake` to check (`is_complete`, `completed`); + `AlpnRoute::accept` keeps delivering completed handshakes only. A connection that sends 0-RTT + data on a route without early data is closed with `CLOSE_EARLY_DATA_REFUSED` before its handler + sees it (`Refusal::EarlyData`, `EndpointStats::refused_early_data`). 0-RTT stays off by default. + `Http3Options::dangerous_allow_zero_rtt_for_queries` stops being a no-op: early `GET` / `HEAD` + requests (RPC queries ride `GET`) are served before the handshake completes, every other early + request is answered `425 Too Early` (RFC 8470) without reaching the router; `OPTIONS` is no + longer admitted. `Http3Options::congestion(CongestionControl::Cubic)` switches the `h3` route + off BBR (still the default; the mesh stays CUBIC), and `Http3Options::config()` reads it from the + config key `server.http3.congestion`; `CongestionControl` implements `FromStr`. ### Documentation diff --git a/crates/id_effect_axum/src/server/h3.rs b/crates/id_effect_axum/src/server/h3.rs index c6c19ef4b..5c8388749 100644 --- a/crates/id_effect_axum/src/server/h3.rs +++ b/crates/id_effect_axum/src/server/h3.rs @@ -36,12 +36,27 @@ //! [`ClientIp`](crate::ClientIp) applies the 0.10.1 trusted-proxy rules to HTTP/3 exactly as to //! TCP, and per-IP rate limits see the same address. //! -//! ## 0-RTT +//! ## 0-RTT (ADR 0013 R10, F25) //! //! Off: the platform endpoint accepts no early data and hands connections over only after the //! handshake. [`Http3Options::dangerous_allow_zero_rtt_for_queries`] records a posture opt-out -//! and admits early requests with safe methods only ([`Http3Options::admits_early`]); the -//! endpoint does not yet offer early data, so today it changes nothing on the wire. +//! and turns early data on for the `h3` route +//! ([`Route::dangerous_allow_early_data`](id_effect_platform::quic::Route::dangerous_allow_early_data); +//! the mesh route never takes it): a resumed client's first-flight requests are then served +//! before the handshake completes when their method is safe (`GET`, `HEAD`: pages, files and +//! RPC `query` procedures, which RPC serves on `GET`; [`Http3Options::admits_early`]). Every +//! other request that arrives before the handshake completes — a mutation, a WebTransport +//! `CONNECT` — is answered **`425 Too Early`** (RFC 8470) without reaching the router; the +//! client retries it once the handshake is done. Early data can be replayed by whoever +//! captured it, until the handshake completes, so only idempotent handlers that reveal nothing +//! a replay could exploit may sit behind safe methods. +//! +//! ## Congestion control (F36) +//! +//! The `h3` route runs BBR (ADR 0014 D9, 0.12 F22); quinn marks its BBR experimental, so +//! [`Http3Options::congestion`] switches to CUBIC without touching the mesh (which stays on +//! CUBIC). [`Http3Options::config`] reads the switch from the config key +//! `server.http3.congestion` (`bbr` or `cubic`, [`CONGESTION_KEY`]). //! //! ```rust,no_run //! use axum::Router; @@ -78,12 +93,15 @@ use ::h3::server::{RequestResolver, RequestStream}; use axum::Router; use axum::body::{Body, Bytes}; use axum::extract::ConnectInfo; -use axum::http::{Method, Response, header}; +use axum::http::{Method, Response, StatusCode, header}; use bytes::Buf; use http_body::Frame; use http_body_util::BodyExt; use id_effect::observability::posture::record_dangerous; -use id_effect_platform::quic::{QuicEndpoint, QuicEndpointConfig, QuicLimits, Route}; +use id_effect_config::{Config, ConfigError}; +use id_effect_platform::quic::{ + CongestionControl, Handshake, QuicEndpoint, QuicEndpointConfig, QuicLimits, Route, +}; use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc, watch}; use tower_service::Service; @@ -126,30 +144,98 @@ async fn next_handoff(handoffs: &mut Option>) -> Handoff } } -/// HTTP/3 settings beyond the [`ConnectionLimits`]: off by default, each a `dangerous_*` -/// opt-out. -#[derive(Clone, Debug, Default, PartialEq, Eq)] +/// The config key of [`Http3Options::config`]: the `h3` route's congestion control, `bbr` +/// (the default) or `cubic` (F36). +pub const CONGESTION_KEY: &str = "server.http3.congestion"; + +/// HTTP/3 settings beyond the [`ConnectionLimits`]: 0-RTT off by default (its opt-out is +/// `dangerous_*`), BBR congestion control. +#[derive(Clone, Debug, PartialEq, Eq)] pub struct Http3Options { zero_rtt_for_queries: bool, + congestion: CongestionControl, #[cfg(feature = "webtransport")] webtransport: bool, } +impl Default for Http3Options { + /// 0-RTT off, BBR, no WebTransport. + fn default() -> Self { + Self { + zero_rtt_for_queries: false, + congestion: CongestionControl::Bbr, + #[cfg(feature = "webtransport")] + webtransport: false, + } + } +} + impl Http3Options { - /// The defaults: 0-RTT off. + /// The defaults: 0-RTT off, BBR. pub fn new() -> Self { Self::default() } - /// **Dangerous.** Admit requests that arrive in 0-RTT early data when their method is safe - /// (`GET`, `HEAD`, `OPTIONS`: RPC queries); every other method is never served from early - /// data. Recorded in the [posture registry](id_effect::observability::posture) as - /// [`DANGEROUS_ALLOW_ZERO_RTT_FOR_QUERIES`]. + /// The options from config: the congestion control at [`CONGESTION_KEY`] + /// (`server.http3.congestion`: `bbr`, the default when the key is absent, or `cubic`, in any + /// case), everything else at its default. In a profile file that is + /// `[server.http3] congestion = "cubic"`; flat providers join the path with their delimiter + /// (`server_http3_congestion` for the environment provider's default). 0-RTT has no config + /// key: its opt-out is code. + // `ConfigError` is the descriptor API's error type (as `HostConfig::config`). + #[allow(clippy::result_large_err)] + pub fn config() -> Config { + Config::optional_string(CONGESTION_KEY).map_attempt(|name| { + let Some(name) = name else { + return Ok(Self::new()); + }; + match name.parse::() { + Ok(congestion) => Ok(Self::new().congestion(congestion)), + Err(e) => Err(ConfigError::Invalid { + path: CONGESTION_KEY.into(), + value: name, + reason: e.to_string(), + }), + } + }) + } + + /// Run `congestion` control on the `h3` route's connections (F36; default + /// [`CongestionControl::Bbr`], which quinn marks experimental: [`CongestionControl::Cubic`] + /// is the escape hatch). The mesh keeps CUBIC either way. + #[must_use] + pub fn congestion(mut self, congestion: CongestionControl) -> Self { + self.congestion = congestion; + self + } + + /// The congestion control of the `h3` route's connections. + pub fn congestion_control(&self) -> CongestionControl { + self.congestion + } + + /// The platform route these options serve: `h3` with their congestion control, taking early + /// data only with [`Self::dangerous_allow_zero_rtt_for_queries`]. + pub fn route(&self) -> Route { + let route = Route::h3().with_congestion_control(self.congestion); + if self.zero_rtt_for_queries { + route.dangerous_allow_early_data() + } else { + route + } + } + + /// **Dangerous.** Take 0-RTT early data on the `h3` route and serve the requests that arrive + /// in it when their method is safe (`GET`, `HEAD`: RPC `query` procedures ride `GET`); + /// every other request that arrives before the handshake completes is answered + /// `425 Too Early` (RFC 8470). Recorded in the + /// [posture registry](id_effect::observability::posture) as + /// [`DANGEROUS_ALLOW_ZERO_RTT_FOR_QUERIES`] (and the route's + /// [`DANGEROUS_ALLOW_EARLY_DATA`](id_effect_platform::quic::DANGEROUS_ALLOW_EARLY_DATA)). /// - /// Early data can be replayed by an attacker who captured it: only handlers that are - /// idempotent **and** reveal nothing a replay could exploit may sit behind safe methods. - /// The platform QUIC endpoint does not offer early data yet, so the opt-out has no effect on - /// the wire until it does. + /// Early data can be replayed by an attacker who captured it, until the handshake + /// completes: only handlers that are idempotent **and** reveal nothing a replay could + /// exploit may sit behind safe methods. There is no opt-out for other methods. #[must_use] pub fn dangerous_allow_zero_rtt_for_queries(mut self) -> Self { record_dangerous( @@ -192,12 +278,18 @@ impl Http3Options { } /// Whether a request with `method` may be served from 0-RTT early data: only with - /// [`Self::dangerous_allow_zero_rtt_for_queries`] and only for `GET`, `HEAD` and `OPTIONS`. + /// [`Self::dangerous_allow_zero_rtt_for_queries`] and only for `GET` and `HEAD` (RPC + /// queries ride `GET`). pub fn admits_early(&self, method: &Method) -> bool { - self.zero_rtt_for_queries && matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) + admits_early(self.zero_rtt_for_queries, method) } } +/// [`Http3Options::admits_early`] for a connection that takes early data when `zero_rtt`. +fn admits_early(zero_rtt: bool, method: &Method) -> bool { + zero_rtt && matches!(*method, Method::GET | Method::HEAD) +} + /// The endpoint limits matching `limits`: the platform defaults (the ceiling), with at most /// [`ConnectionLimits::max_concurrent_streams`] bidirectional streams (requests) per connection /// and the QUIC handshake bounded by [`ConnectionLimits::tls_handshake_timeout`] — both capped @@ -236,7 +328,7 @@ pub async fn serve_h3( where F: Future + Send + 'static, { - let route = endpoint.register(Route::h3()).map_err(io_error)?; + let route = endpoint.register(options.route()).map_err(io_error)?; // Dropping the receiver is the signal; connections watch the sender. let (signal_tx, signal_rx) = watch::channel(()); tokio::spawn(async move { @@ -246,9 +338,6 @@ where // Every connection holds a receiver; the loop waits for the last one to drop. let (close_tx, close_rx) = watch::channel(()); let limits = Arc::new(limits); - // Connections are handed over after the handshake, so no request is early data and the - // 0-RTT rule ([`Http3Options::admits_early`]) has nothing to decide yet. - let _ = options.zero_rtt_for_queries(); let slots = limits .connections_limit() .map(|n| Arc::new(Semaphore::new(n))); @@ -260,9 +349,10 @@ where }, None => None, }; - let conn = tokio::select! { - accepted = route.accept() => match accepted { - Some(Ok(conn)) => conn, + // With 0-RTT on, a connection arrives before its handshake completes (F25). + let (conn, handshake) = tokio::select! { + accepted = route.accept_early() => match accepted { + Some(Ok(accepted)) => accepted, // A refused handshake (reported for logs only). Some(Err(_)) => continue, None => break, @@ -276,6 +366,10 @@ where signal: signal_tx.clone(), held: (close_rx.clone(), permit), webtransport: options.serves_webtransport(), + early: Early { + handshake, + zero_rtt: options.zero_rtt_for_queries(), + }, }; tokio::spawn(task.run()); } @@ -294,6 +388,25 @@ struct ConnectionTask { held: (watch::Receiver<()>, Option), /// Advertise WebTransport, and let a session take the connection over. webtransport: bool, + /// The handshake and the 0-RTT rule its requests follow until it completes. + early: Early, +} + +/// What decides whether a request that arrives before the handshake completes is served +/// (F25). +#[derive(Clone)] +struct Early { + handshake: Handshake, + /// [`Http3Options::dangerous_allow_zero_rtt_for_queries`]. + zero_rtt: bool, +} + +impl Early { + /// Whether a request with `method`, whose header section just arrived, must wait for the + /// handshake: it arrived as early data and is not admitted from it. + fn too_early(&self, method: &Method) -> bool { + !self.handshake.is_complete() && !admits_early(self.zero_rtt, method) + } } impl ConnectionTask { @@ -305,6 +418,7 @@ impl ConnectionTask { signal, held, webtransport, + early, } = self; // Released when the connection is done, whichever way. let _held = held; @@ -353,6 +467,7 @@ impl ConnectionTask { peer, header_timeout, handoff: handoff_tx.clone(), + early: early.clone(), }; requests.spawn(request.run()); if !draining && max_requests.is_some_and(|max| served >= max) { @@ -434,6 +549,8 @@ struct RequestTask { header_timeout: Option, /// Where a WebTransport CONNECT goes once its route accepted it (WebTransport on). handoff: Option>, + /// The connection's handshake and 0-RTT rule. + early: Early, } impl RequestTask { @@ -444,6 +561,7 @@ impl RequestTask { peer, header_timeout, handoff, + early, } = self; let resolve = resolver.resolve_request(); let resolved = match header_timeout { @@ -455,6 +573,11 @@ impl RequestTask { let Some(Ok((head, stream))) = resolved else { return; }; + // Early data a replay could repeat never reaches the router unless it is a safe request + // the opt-out admits (ADR 0013 R10, RFC 8470). + if early.too_early(head.method()) { + return too_early(stream).await; + } #[cfg(feature = "webtransport")] if let Some(handoff) = handoff && crate::server::webtransport::is_connect(&head) @@ -493,6 +616,28 @@ pub(crate) async fn serve_request( finish_request_body(body).await; } +/// Answers a request that arrived in 0-RTT early data and is not admitted from it +/// `425 Too Early` (RFC 8470 §5.2): the client retries it once the handshake completed. The +/// router never sees it; the rest of its body is drained as for any answered request. +async fn too_early(stream: FullStream) { + let (send, recv) = stream.split(); + let mut response = Response::new(Body::from( + "Too Early: this request arrived in TLS early data; retry it once the handshake completed", + )); + *response.status_mut() = StatusCode::TOO_EARLY; + response.headers_mut().insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("text/plain; charset=utf-8"), + ); + let _ = respond(send, response, false).await; + finish_request_body(Arc::new(Mutex::new(BodyState { + stream: recv, + data_done: false, + done: false, + }))) + .await; +} + /// Most a client may still send of an unread request body once the response is out. const DRAIN_LIMIT: usize = 64 * 1024; @@ -709,12 +854,21 @@ mod tests { let options = Http3Options::new(); assert!(!options.zero_rtt_for_queries()); assert!(!options.admits_early(&Method::GET)); + assert!(!options.route().early_data()); let options = options.dangerous_allow_zero_rtt_for_queries(); assert!(options.zero_rtt_for_queries()); - for method in [Method::GET, Method::HEAD, Method::OPTIONS] { + assert!(options.route().early_data()); + for method in [Method::GET, Method::HEAD] { assert!(options.admits_early(&method), "{method}"); } - for method in [Method::POST, Method::PUT, Method::PATCH, Method::DELETE] { + for method in [ + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + Method::CONNECT, + ] { assert!(!options.admits_early(&method), "{method}"); } assert!( @@ -723,4 +877,38 @@ mod tests { .any(|(id, _)| *id == DANGEROUS_ALLOW_ZERO_RTT_FOR_QUERIES) ); } + + #[test] + fn congestion_defaults_to_bbr_and_switches_from_config() { + use id_effect_config::{MapConfigProvider, ProviderOptions}; + + // Dotted paths, as a profile file (`[server.http3] congestion = "cubic"`) provides them. + let dotted = |pairs: Vec<(&str, &str)>| { + MapConfigProvider::from_pairs_with_options( + pairs, + ProviderOptions { + path_delim: ".", + ..ProviderOptions::default() + }, + ) + }; + let options = Http3Options::new(); + assert_eq!(options.congestion_control(), CongestionControl::Bbr); + assert_eq!(options.route().congestion_control(), CongestionControl::Bbr); + let cubic = options.clone().congestion(CongestionControl::Cubic); + assert_eq!(cubic.route().congestion_control(), CongestionControl::Cubic); + + let empty = dotted(Vec::new()); + assert_eq!(Http3Options::config().load(&empty).unwrap(), options); + let set = dotted(vec![(CONGESTION_KEY, "Cubic")]); + assert_eq!(Http3Options::config().load(&set).unwrap(), cubic); + // The environment form joins the path with `_`. + let env = MapConfigProvider::from_pairs([("server_http3_congestion", "cubic")]); + assert_eq!(Http3Options::config().load(&env).unwrap(), cubic); + let bad = dotted(vec![(CONGESTION_KEY, "reno")]); + assert!(matches!( + Http3Options::config().load(&bad), + Err(ConfigError::Invalid { path, value, .. }) if path == CONGESTION_KEY && value == "reno" + )); + } } diff --git a/crates/id_effect_axum/tests/http3_settings.rs b/crates/id_effect_axum/tests/http3_settings.rs new file mode 100644 index 000000000..7f1faa68c --- /dev/null +++ b/crates/id_effect_axum/tests/http3_settings.rs @@ -0,0 +1,556 @@ +//! HTTP/3 settings (r13-f-h3-settings): real 0-RTT on the `h3` route behind +//! `Http3Options::dangerous_allow_zero_rtt_for_queries` (F25: safe methods served from early +//! data, everything else `425 Too Early`) and the congestion-control switch (F36). +//! +//! A UDP relay between client and server holds back the server's handshake flight (its +//! Handshake and 1-RTT packets) on demand: the client cannot complete the handshake and sends +//! its requests in 0-RTT, so they provably reach the server while the handshake is running. +#![cfg(feature = "http3")] + +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::Duration; + +use axum::Router; +use axum::http::{Method, Request, StatusCode}; +use axum::routing::get; +use bytes::{Buf, Bytes, BytesMut}; +use id_effect_axum::ConnectionLimits; +use id_effect_axum::server::h3::{CONGESTION_KEY, Http3Options, quic_limits, serve_h3}; +use id_effect_config::{MapConfigProvider, ProviderOptions}; +use id_effect_platform::quic::{ + ALPN_H3, CongestionControl, QuicEndpoint, QuicEndpointConfig, QuicLimits, transport_config, +}; +use id_effect_platform::tls::{Identity, NodeCertificate, client_tls_config}; +use tokio::net::UdpSocket; +use tokio::sync::Notify; + +const WAIT: Duration = Duration::from_secs(20); + +fn local() -> SocketAddr { + "127.0.0.1:0".parse().unwrap() +} + +// --- a relay that can hold the server's handshake flight ---------------------------------------- + +/// Relays one client's datagrams to `server` and back; while held, server datagrams carrying a +/// Handshake or 1-RTT packet wait, so the client cannot complete the handshake. +struct Relay { + addr: SocketAddr, + hold: Arc, + released: Arc, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for Relay { + fn drop(&mut self) { + self.task.abort(); + } +} + +impl Relay { + async fn start(server: SocketAddr) -> Self { + let socket = UdpSocket::bind(local()).await.unwrap(); + let addr = socket.local_addr().unwrap(); + let hold = Arc::new(AtomicBool::new(false)); + let released = Arc::new(Notify::new()); + let task = tokio::spawn(relay( + socket, + server, + Arc::clone(&hold), + Arc::clone(&released), + )); + Self { + addr, + hold, + released, + task, + } + } + + fn hold(&self) { + self.hold.store(true, Ordering::SeqCst); + } + + fn release(&self) { + self.hold.store(false, Ordering::SeqCst); + self.released.notify_one(); + } +} + +async fn relay( + socket: UdpSocket, + server: SocketAddr, + hold: Arc, + released: Arc, +) { + let mut client = None; + let mut held: Vec> = Vec::new(); + let mut buf = vec![0u8; 65_536]; + loop { + tokio::select! { + received = socket.recv_from(&mut buf) => { + let Ok((n, from)) = received else { return }; + let datagram = &buf[..n]; + if from != server { + client = Some(from); + let _ = socket.send_to(datagram, server).await; + } else if let Some(client) = client { + if hold.load(Ordering::SeqCst) && carries_handshake(datagram) { + held.push(datagram.to_vec()); + } else { + let _ = socket.send_to(datagram, client).await; + } + } + } + () = released.notified() => { + if let Some(client) = client { + for datagram in held.drain(..) { + let _ = socket.send_to(&datagram, client).await; + } + } + } + } + } +} + +/// A QUIC variable-length integer and its length (RFC 9000 §16). +fn varint(bytes: &[u8]) -> Option<(usize, usize)> { + let first = *bytes.first()?; + let len = 1usize << (first >> 6); + let mut value = u64::from(first & 0x3f); + for byte in bytes.get(1..len)? { + value = (value << 8) | u64::from(*byte); + } + Some((usize::try_from(value).ok()?, len)) +} + +/// Whether a datagram carries a Handshake or a 1-RTT packet (coalesced long-header packets +/// are walked, RFC 9000 §12.2, §17.2; a Retry carries neither). +fn carries_handshake(mut datagram: &[u8]) -> bool { + while let Some(&first) = datagram.first() { + if first & 0x80 == 0 { + return true; + } + let kind = (first >> 4) & 0x03; + match kind { + 0b10 => return true, + 0b11 => return false, + _ => {} + } + let mut at = 5; + let Some(&dcid) = datagram.get(at) else { + return false; + }; + at += 1 + usize::from(dcid); + let Some(&scid) = datagram.get(at) else { + return false; + }; + at += 1 + usize::from(scid); + if kind == 0b00 { + let Some((token, n)) = datagram.get(at..).and_then(varint) else { + return false; + }; + at += n + token; + } + let Some((length, n)) = datagram.get(at..).and_then(varint) else { + return false; + }; + let Some(rest) = datagram.get(at + n + length..) else { + return false; + }; + datagram = rest; + } + false +} + +// --- server and client ------------------------------------------------------------------------- + +/// How often each handler ran. +#[derive(Clone, Default)] +struct Ran { + reads: Arc, + writes: Arc, + deletes: Arc, +} + +fn app(ran: &Ran) -> Router { + let (reads, writes, deletes) = ( + Arc::clone(&ran.reads), + Arc::clone(&ran.writes), + Arc::clone(&ran.deletes), + ); + Router::new().route( + "/item", + get(move || { + let reads = Arc::clone(&reads); + async move { + reads.fetch_add(1, Ordering::SeqCst); + "item" + } + }) + .post(move || { + let writes = Arc::clone(&writes); + async move { + writes.fetch_add(1, Ordering::SeqCst); + "written" + } + }) + .delete(move || { + let deletes = Arc::clone(&deletes); + async move { + deletes.fetch_add(1, Ordering::SeqCst); + "deleted" + } + }), + ) +} + +/// An HTTP/3 server on its own endpoint, with `options`. +struct Server { + endpoint: QuicEndpoint, + stop: Option>, + task: tokio::task::JoinHandle>, + cert: NodeCertificate, + identity: Identity, +} + +impl Server { + async fn start(app: Router, options: Http3Options) -> Self { + let cert = NodeCertificate::self_signed("localhost").unwrap(); + let identity = Identity::from_certificate(&cert).unwrap(); + let limits = ConnectionLimits::default(); + let endpoint = QuicEndpoint::bind( + local(), + QuicEndpointConfig::new(identity.clone()).with_limits(quic_limits(&limits)), + ) + .unwrap(); + let (stop, stopped) = tokio::sync::oneshot::channel::<()>(); + let task = { + let endpoint = endpoint.clone(); + tokio::spawn(async move { + serve_h3(&endpoint, app, limits, options, async { + let _ = stopped.await; + }) + .await + }) + }; + // The server task registers the route: wait for it. + tokio::time::timeout(WAIT, async { + while endpoint.router().alpns().is_empty() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + Self { + endpoint, + stop: Some(stop), + task, + cert, + identity, + } + } + + /// A client config asking for early data; clones share its session cache. + fn early_client(&self) -> quinn::ClientConfig { + let mut tls = client_tls_config(&self.identity, None, &[self.cert.cert_sha256()]).unwrap(); + tls.enable_early_data = true; + tls.alpn_protocols = vec![ALPN_H3.to_vec()]; + let crypto = quinn::crypto::rustls::QuicClientConfig::try_from(tls).unwrap(); + let mut config = quinn::ClientConfig::new(Arc::new(crypto)); + config.transport_config(Arc::new(transport_config(&QuicLimits::default()).unwrap())); + config + } + + async fn stop(mut self) { + let _ = self.stop.take().unwrap().send(()); + tokio::time::timeout(WAIT, self.task) + .await + .unwrap() + .unwrap() + .unwrap(); + } +} + +type SendRequest = h3::client::SendRequest; + +/// An HTTP/3 client on `conn` (which may still be in 0-RTT). +async fn h3_client(conn: quinn::Connection) -> SendRequest { + let (mut driver, send) = h3::client::new(h3_quinn::Connection::new(conn)) + .await + .unwrap(); + tokio::spawn(async move { + let _ = std::future::poll_fn(|cx| driver.poll_close(cx)).await; + }); + send +} + +/// One request on `/item`: its status and body. +async fn request( + mut send: SendRequest, + method: Method, + body: Option<&str>, +) -> (StatusCode, String) { + let request = Request::builder() + .method(method) + .uri("https://localhost/item") + .body(()) + .unwrap(); + let exchange = async { + let mut stream = send.send_request(request).await.unwrap(); + if let Some(body) = body { + stream + .send_data(Bytes::copy_from_slice(body.as_bytes())) + .await + .unwrap(); + } + stream.finish().await.unwrap(); + let response = stream.recv_response().await.unwrap(); + let mut bytes = BytesMut::new(); + while let Some(mut chunk) = stream.recv_data().await.unwrap() { + bytes.extend_from_slice(&chunk.copy_to_bytes(chunk.remaining())); + } + ( + response.status(), + String::from_utf8(bytes.to_vec()).unwrap(), + ) + }; + tokio::time::timeout(WAIT, exchange) + .await + .expect("response in time") +} + +/// [`request`] in its own task, sent at once (so in 0-RTT while the relay holds). +fn spawn_request( + send: &SendRequest, + method: Method, + body: Option<&'static str>, +) -> tokio::task::JoinHandle<(StatusCode, String)> { + tokio::spawn(request(send.clone(), method, body)) +} + +/// A first, full handshake through `relay` (the client keeps the ticket), then a second dial +/// in 0-RTT with the server's handshake flight held back. +async fn resumed_in_0rtt( + client: &quinn::Endpoint, + config: &quinn::ClientConfig, + relay: &Relay, +) -> (quinn::Connection, SendRequest) { + let first = tokio::time::timeout( + WAIT, + client + .connect_with(config.clone(), relay.addr, "localhost") + .unwrap(), + ) + .await + .unwrap() + .unwrap(); + let send = h3_client(first.clone()).await; + assert_eq!(request(send, Method::GET, None).await.0, StatusCode::OK); + tokio::time::sleep(Duration::from_millis(50)).await; + first.close(0u32.into(), b"done"); + + relay.hold(); + let connecting = client + .connect_with(config.clone(), relay.addr, "localhost") + .unwrap(); + let Ok((conn, _accepted)) = connecting.into_0rtt() else { + panic!("the ticket allows early data"); + }; + let send = h3_client(conn.clone()).await; + (conn, send) +} + +/// Waits until `counter` reaches `n`. +async fn reaches(counter: &AtomicUsize, n: usize) { + tokio::time::timeout(WAIT, async { + while counter.load(Ordering::SeqCst) < n { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("handler ran"); +} + +// --- tests ------------------------------------------------------------------------------------- + +/// Off by default: `serve_h3` with the default options registers an `h3` route without early +/// data, so a resumed client cannot send 0-RTT. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn zero_rtt_off_by_default() { + let ran = Ran::default(); + let server = Server::start(app(&ran), Http3Options::default()).await; + assert_eq!(server.endpoint.router().early_data(ALPN_H3), Some(false)); + let client = quinn::Endpoint::client(local()).unwrap(); + let config = server.early_client(); + let first = client + .connect_with(config.clone(), server.endpoint.local_addr(), "localhost") + .unwrap() + .await + .unwrap(); + let send = h3_client(first.clone()).await; + assert_eq!(request(send, Method::GET, None).await.0, StatusCode::OK); + tokio::time::sleep(Duration::from_millis(50)).await; + let second = client + .connect_with(config, server.endpoint.local_addr(), "localhost") + .unwrap(); + assert!(second.into_0rtt().is_err(), "the server offers no 0-RTT"); + assert_eq!(server.endpoint.stats().early_handovers, 0); + first.close(0u32.into(), b"done"); + server.stop().await; +} + +/// With the opt-out, requests that arrive before the handshake completes are served when +/// their method is safe (`GET`, `HEAD`) and refused `425` otherwise (`OPTIONS`, `DELETE`); +/// once the handshake completed, every method is served. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn zero_rtt_admits_only_safe_methods() { + let ran = Ran::default(); + let server = Server::start( + app(&ran), + Http3Options::new().dangerous_allow_zero_rtt_for_queries(), + ) + .await; + assert_eq!(server.endpoint.router().early_data(ALPN_H3), Some(true)); + let relay = Relay::start(server.endpoint.local_addr()).await; + let client = quinn::Endpoint::client(local()).unwrap(); + let (conn, send) = resumed_in_0rtt(&client, &server.early_client(), &relay).await; + + let early = [Method::GET, Method::HEAD, Method::OPTIONS, Method::DELETE] + .map(|method| spawn_request(&send, method, None)); + // GET and HEAD reach the handler while the handshake is held (1 read was the first dial). + reaches(&ran.reads, 3).await; + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!(ran.deletes.load(Ordering::SeqCst), 0); + assert_eq!(server.endpoint.stats().handshakes_failed, 0); + + relay.release(); + let mut answers = Vec::new(); + for answer in early { + answers.push(answer.await.unwrap()); + } + assert_eq!(answers[0], (StatusCode::OK, "item".into())); + assert_eq!(answers[1].0, StatusCode::OK); + for (status, body) in &answers[2..] { + assert_eq!(*status, StatusCode::TOO_EARLY); + assert!(body.contains("early data"), "{body}"); + } + assert_eq!(ran.deletes.load(Ordering::SeqCst), 0); + // After the handshake: every method. + assert_eq!( + request(send, Method::DELETE, None).await, + (StatusCode::OK, "deleted".into()) + ); + conn.close(0u32.into(), b"done"); + server.stop().await; +} + +/// A mutation that arrives in early data never reaches its handler: `425 Too Early` +/// (RFC 8470). The client's retry after the handshake is served. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn zero_rtt_mutation_gets_425() { + let ran = Ran::default(); + let server = Server::start( + app(&ran), + Http3Options::new().dangerous_allow_zero_rtt_for_queries(), + ) + .await; + let relay = Relay::start(server.endpoint.local_addr()).await; + let client = quinn::Endpoint::client(local()).unwrap(); + let (conn, send) = resumed_in_0rtt(&client, &server.early_client(), &relay).await; + + let mutation = spawn_request(&send, Method::POST, Some("{\"amount\":100}")); + let query = spawn_request(&send, Method::GET, None); + reaches(&ran.reads, 2).await; + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!(server.endpoint.stats().early_handovers, 2); + + relay.release(); + let (status, body) = mutation.await.unwrap(); + assert_eq!(status, StatusCode::TOO_EARLY); + assert!(body.contains("handshake"), "{body}"); + assert_eq!(query.await.unwrap(), (StatusCode::OK, "item".into())); + assert_eq!( + ran.writes.load(Ordering::SeqCst), + 0, + "the handler never ran" + ); + + // The retry, after the handshake, is served. + assert_eq!( + request(send, Method::POST, Some("{\"amount\":100}")).await, + (StatusCode::OK, "written".into()) + ); + assert_eq!(ran.writes.load(Ordering::SeqCst), 1); + conn.close(0u32.into(), b"done"); + server.stop().await; +} + +/// F36: `Http3Options::congestion(Cubic)` (here from the config key +/// `server.http3.congestion`) runs the `h3` route on CUBIC; the default stays BBR. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn http3_congestion_switch_to_cubic() { + // Dotted paths, as a profile file (`[server.http3] congestion = "cubic"`) provides them. + let provider = MapConfigProvider::from_pairs_with_options( + [(CONGESTION_KEY, "cubic")], + ProviderOptions { + path_delim: ".", + ..ProviderOptions::default() + }, + ); + let options = Http3Options::config().load(&provider).unwrap(); + assert_eq!(options.congestion_control(), CongestionControl::Cubic); + assert_eq!( + Http3Options::default().congestion_control(), + CongestionControl::Bbr + ); + for (options, expected) in [ + (options, CongestionControl::Cubic), + (Http3Options::default(), CongestionControl::Bbr), + ] { + let ran = Ran::default(); + let server = Server::start(app(&ran), options).await; + assert_eq!( + server.endpoint.router().congestion_control(ALPN_H3), + Some(expected) + ); + let client = quinn::Endpoint::client(local()).unwrap(); + let conn = client + .connect_with( + server.early_client(), + server.endpoint.local_addr(), + "localhost", + ) + .unwrap() + .await + .unwrap(); + let send = h3_client(conn.clone()).await; + assert_eq!( + request(send, Method::GET, None).await, + (StatusCode::OK, "item".into()) + ); + let stats = server.endpoint.stats(); + let counted = match expected { + CongestionControl::Cubic => (stats.congestion_cubic, stats.congestion_bbr), + _ => (stats.congestion_bbr, stats.congestion_cubic), + }; + assert_eq!(counted, (1, 0), "{expected}"); + conn.close(0u32.into(), b"done"); + server.stop().await; + } +} + +#[test] +fn the_relay_reads_coalesced_packets() { + let initial = [0xc0, 0, 0, 0, 1, 1, 0xaa, 1, 0xbb, 0, 1, 0x00]; + let handshake = [0xe0, 0, 0, 0, 1, 0, 0, 1, 0x00]; + assert!(!carries_handshake(&initial)); + assert!(carries_handshake(&[&initial[..], &handshake[..]].concat())); + assert!(carries_handshake(&[0x40, 1, 2])); + assert!(!carries_handshake(&[0xf0, 0, 0, 0, 1, 0, 0, 9, 9])); + assert!(!carries_handshake(&[0xc0, 0, 0])); + assert!(!carries_handshake(&[0xc0, 0, 0, 0, 1, 0])); + assert!(!carries_handshake(&[0xc0, 0, 0, 0, 1, 0, 0, 0x40])); +} diff --git a/crates/id_effect_platform/src/quic/congestion.rs b/crates/id_effect_platform/src/quic/congestion.rs index 2e55a3548..29f05e7fb 100644 --- a/crates/id_effect_platform/src/quic/congestion.rs +++ b/crates/id_effect_platform/src/quic/congestion.rs @@ -55,6 +55,22 @@ impl std::fmt::Display for CongestionControl { } } +impl std::str::FromStr for CongestionControl { + type Err = super::QuicError; + + /// `bbr` or `cubic` (any case, surrounding spaces ignored), as a config value names them + /// (F36: `server.http3.congestion`). + fn from_str(name: &str) -> Result { + match name.trim().to_ascii_lowercase().as_str() { + "bbr" => Ok(Self::Bbr), + "cubic" => Ok(Self::Cubic), + _ => Err(super::QuicError::Config(format!( + "unknown congestion control {name:?} (bbr or cubic)" + ))), + } + } +} + /// The algorithm a connection's route asks for, set once the handshake has chosen the ALPN. #[derive(Debug, Default)] pub(crate) struct Selection(OnceLock); @@ -265,4 +281,17 @@ mod tests { let bbr = CongestionControl::Bbr.factory().build(Instant::now(), 1200); assert!(bbr.into_any().downcast::().is_ok()); } + + #[test] + fn parsed_from_config_names() { + assert_eq!("bbr".parse(), Ok(CongestionControl::Bbr)); + assert_eq!(" CUBIC ".parse(), Ok(CongestionControl::Cubic)); + for c in [CongestionControl::Bbr, CongestionControl::Cubic] { + assert_eq!(c.as_str().parse(), Ok(c)); + } + assert!(matches!( + "reno".parse::(), + Err(super::super::QuicError::Config(m)) if m.contains("bbr or cubic") + )); + } } diff --git a/crates/id_effect_platform/src/quic/endpoint.rs b/crates/id_effect_platform/src/quic/endpoint.rs index 500bad1a0..8d738807b 100644 --- a/crates/id_effect_platform/src/quic/endpoint.rs +++ b/crates/id_effect_platform/src/quic/endpoint.rs @@ -9,7 +9,7 @@ use tokio::sync::Semaphore; use super::config::{QuicEndpointConfig, QuicLimits, SocketBuffers, idle_timeout, transport}; use super::congestion::{CongestionControl, RouteCongestion, Selection}; -use super::router::{AlpnRoute, AlpnRouter, Counters, Refusal, Route}; +use super::router::{AlpnRoute, AlpnRouter, Counters, Route}; use super::{QuicError, socket}; use crate::tls::Identity; @@ -34,6 +34,10 @@ pub struct EndpointStats { pub congestion_bbr: u64, /// Connections whose route runs CUBIC. pub congestion_cubic: u64, + /// Connections closed because they sent 0-RTT data on a route that takes none (F25). + pub refused_early_data: u64, + /// Connections handed to a route with early data before their handshake completed (F25). + pub early_handovers: u64, } /// One QUIC endpoint on one UDP port, routing connections by ALPN (see the @@ -219,6 +223,8 @@ impl QuicEndpoint { handshakes_failed: get(&c.handshakes_failed), congestion_bbr: get(&c.congestion_bbr), congestion_cubic: get(&c.congestion_cubic), + refused_early_data: get(&c.refused_early_data), + early_handovers: get(&c.early_handovers), } } @@ -280,17 +286,11 @@ async fn handshake(incoming: quinn::Incoming, shared: &Shared) { CongestionControl::Bbr => &shared.counters.congestion_bbr, CongestionControl::Cubic => &shared.counters.congestion_cubic, }); - let reason = match tokio::time::timeout_at(deadline, connecting).await { - Ok(Ok(conn)) => return shared.router.dispatch(conn, &shared.counters), - Ok(Err(e)) => e.to_string(), - Err(_) => "handshake timed out".to_string(), - }; - failed(); - if let Some(alpn) = alpn { - shared - .router - .refuse(&alpn, Refusal::Handshake { remote, reason }); - } + // The route takes it: after the handshake, or at once when it takes early data (F25). + shared + .router + .complete(connecting, alpn, remote, deadline, &shared.counters) + .await; } #[cfg(test)] diff --git a/crates/id_effect_platform/src/quic/mod.rs b/crates/id_effect_platform/src/quic/mod.rs index 0272e4f55..a66d89ea8 100644 --- a/crates/id_effect_platform/src/quic/mod.rs +++ b/crates/id_effect_platform/src/quic/mod.rs @@ -31,7 +31,9 @@ //! - Address validation (QUIC Retry) is on: a client proves its address before the server //! spends a handshake on it. [`QuicEndpointConfig::dangerous_no_retry`] turns it off and is //! recorded in the posture registry. -//! - 0-RTT is off (the server never accepts early data; there is no opt-in). +//! - 0-RTT is off: the server accepts no early data until a route takes it with +//! [`Route::dangerous_allow_early_data`] (recorded; never the mesh), and connections of the +//! other routes that send early data are closed with [`CLOSE_EARLY_DATA_REFUSED`] (F25). //! - [`QuicLimits`] bound streams per connection, the idle timeout, the UDP payload, the //! receive windows, pending handshakes and the handshake time; datagrams are off. Raising any //! limit above its default needs [`QuicEndpointConfig::dangerous_raise_limits`] (recorded). @@ -81,11 +83,13 @@ pub use config::{ // r12-e12-3-webtransport: QUIC datagrams on for endpoints that serve WebTransport. pub use config::{DEFAULT_DATAGRAM_RECEIVE_BUFFER, MAX_DATAGRAM_RECEIVE_BUFFER}; pub use congestion::CongestionControl; +// r13-f-h3-settings: early data per route (F25). pub use endpoint::{EndpointStats, QuicEndpoint}; pub use router::{ AlpnRoute, AlpnRouter, ClientAuthMode, ClientCertPolicy, Refusal, Route, has_verified_client_chain, negotiated_alpn, }; +pub use router::{CLOSE_EARLY_DATA_REFUSED, DANGEROUS_ALLOW_EARLY_DATA, Handshake}; /// ALPN of HTTP/3 (RFC 9114). pub const ALPN_H3: &[u8] = b"h3"; diff --git a/crates/id_effect_platform/src/quic/router.rs b/crates/id_effect_platform/src/quic/router.rs index c8b920159..dbe13b3d5 100644 --- a/crates/id_effect_platform/src/quic/router.rs +++ b/crates/id_effect_platform/src/quic/router.rs @@ -1,4 +1,4 @@ -//! ALPN routes, the client-certificate rule they imply (D7), and dispatch. +//! ALPN routes, the client-certificate rule they imply (D7), early data (F25), and dispatch. use std::net::SocketAddr; use std::sync::atomic::{AtomicU64, Ordering}; @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex, PoisonError}; use rustls::pki_types::CertificateDer; use rustls::sign::CertifiedKey; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, watch}; use super::{ ALPN_H3, ALPN_MESH, CLOSE_BUSY, CLOSE_CLIENT_CERT_REQUIRED, CLOSE_NO_ROUTE, CongestionControl, @@ -14,6 +14,14 @@ use super::{ }; use crate::tls::{self, ClientAuth, Identity}; +/// Posture id of [`Route::dangerous_allow_early_data`] (ADR 0013 R10). +pub const DANGEROUS_ALLOW_EARLY_DATA: &str = + "id_effect_platform::quic::Route::dangerous_allow_early_data"; + +/// Application close code: the connection sent 0-RTT application data on a route that takes +/// no early data (the mesh, and every route without [`Route::dangerous_allow_early_data`]). +pub const CLOSE_EARLY_DATA_REFUSED: u32 = 0x1da; + /// What a route asks of the client's certificate. #[derive(Clone, Debug)] #[non_exhaustive] @@ -33,28 +41,61 @@ pub enum ClientCertPolicy { }, } -/// One ALPN served by an endpoint: its certificate, its client-certificate policy and the -/// congestion control of its connections. +/// One ALPN served by an endpoint: its certificate, its client-certificate policy, the +/// congestion control of its connections and whether it takes 0-RTT early data. #[derive(Clone, Debug)] pub struct Route { alpn: Vec, identity: Option, policy: ClientCertPolicy, congestion: CongestionControl, + early_data: bool, } impl Route { /// A route for `alpn` presenting the endpoint's certificate, ignoring client certificates, - /// on CUBIC. + /// on CUBIC, without early data. pub fn new(alpn: impl Into>) -> Self { Self { alpn: alpn.into(), identity: None, policy: ClientCertPolicy::Ignore, congestion: CongestionControl::Cubic, + early_data: false, } } + /// **Dangerous.** Take 0-RTT early data on this route (F25): its connections are handed to + /// the route as soon as the handshake has chosen the ALPN, **before the handshake + /// completes**, so requests sent in a resumed client's first flight are read one round trip + /// earlier. Recorded in the posture registry as [`DANGEROUS_ALLOW_EARLY_DATA`]. + /// + /// Early data can be replayed by anyone who captured it, until the handshake completes (a + /// replay never completes it). The route's handler must therefore check + /// [`Handshake::is_complete`] (from [`AlpnRoute::accept_early`]) and act only on idempotent, + /// side-effect-free requests while it is `false` — the HTTP/3 server answers everything else + /// `425 Too Early` (RFC 8470). [`AlpnRoute::accept`] keeps waiting for the handshake. + /// + /// Only for routes without client certificates: registering the mesh ([`ALPN_MESH`]) or a + /// route that requires a verified client with early data is refused + /// ([`QuicError::Config`]). The TLS server offers early data in its session tickets once any + /// route takes it; connections of the other routes that send early data are closed with + /// [`CLOSE_EARLY_DATA_REFUSED`] before their handler sees them. + pub fn dangerous_allow_early_data(mut self) -> Self { + id_effect::observability::posture::record_dangerous( + DANGEROUS_ALLOW_EARLY_DATA, + "R10: the QUIC route takes 0-RTT early data, which can be replayed until the handshake \ + completes", + ); + self.early_data = true; + self + } + + /// Whether this route takes 0-RTT early data ([`Self::dangerous_allow_early_data`]). + pub fn early_data(&self) -> bool { + self.early_data + } + /// HTTP/3 ([`ALPN_H3`]) on [`CongestionControl::Bbr`] (ADR 0014 D9; quinn marks BBR /// experimental, [`Self::with_congestion_control`] picks CUBIC back). pub fn h3() -> Self { @@ -139,6 +180,12 @@ pub enum Refusal { /// The client. remote: SocketAddr, }, + /// The client sent 0-RTT application data on a route that takes no early data (F25); the + /// connection was closed with [`CLOSE_EARLY_DATA_REFUSED`] before any of it was read. + EarlyData { + /// The client. + remote: SocketAddr, + }, } impl std::fmt::Display for Refusal { @@ -149,14 +196,73 @@ impl std::fmt::Display for Refusal { f, "quic: {remote} presented no verified client certificate; refused before any frame" ), + Self::EarlyData { remote } => write!( + f, + "quic: {remote} sent early data (0-RTT) on a route that takes none; refused before any \ + frame" + ), } } } impl std::error::Error for Refusal {} -/// What a route's handler receives: a connection, or a refused attempt (for its logs). -type Delivery = Result; +/// Where a connection's handshake stands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Progress { + /// Still running: what the peer sent so far is 0-RTT early data. + Pending, + /// Complete: the peer holds the session's keys. + Complete, + /// Failed or timed out; the connection is closed. + Failed, +} + +/// The handshake of a connection a route received (see [`AlpnRoute::accept_early`]). +/// +/// Connections of a route without early data are delivered with their handshake complete. +/// On a route with [`Route::dangerous_allow_early_data`] they arrive while it runs: until +/// [`Self::is_complete`] turns `true`, everything read from them is 0-RTT early data, which an +/// attacker who captured it can replay (a replayed flight never completes the handshake). +#[derive(Clone, Debug)] +pub struct Handshake { + progress: watch::Receiver, +} + +impl Handshake { + /// A handshake that has completed. + pub(crate) fn complete() -> Self { + // The value outlives the sender: receivers keep reading it. + let (_, progress) = watch::channel(Progress::Complete); + Self { progress } + } + + /// A running handshake and the sender that settles it. + pub(crate) fn pending() -> (Self, watch::Sender) { + let (tx, progress) = watch::channel(Progress::Pending); + (Self { progress }, tx) + } + + /// Whether the handshake has completed. `false` while it runs (what was read is early data) + /// and after it failed. + pub fn is_complete(&self) -> bool { + *self.progress.borrow() == Progress::Complete + } + + /// Waits until the handshake settles: `true` once it completed, `false` if it failed, timed + /// out or the endpoint is gone. + pub async fn completed(&self) -> bool { + let mut progress = self.progress.clone(); + match progress.wait_for(|p| *p != Progress::Pending).await { + Ok(p) => *p == Progress::Complete, + Err(_) => false, + } + } +} + +/// What a route's handler receives: a connection and its handshake, or a refused attempt (for +/// its logs). +type Delivery = Result<(quinn::Connection, Handshake), Refusal>; /// The receiving end of a [`Route`]: connections that negotiated its ALPN and passed its /// client-certificate policy. Dropping it makes the endpoint refuse its ALPN @@ -173,8 +279,27 @@ impl AlpnRoute { &self.alpn } - /// The next connection or refused attempt; `None` once the endpoint is gone. + /// The next connection whose handshake completed, or refused attempt; `None` once the + /// endpoint is gone. On a route with [early data](Route::dangerous_allow_early_data) this + /// waits for each connection's handshake in turn (a connection whose handshake fails is + /// skipped; its refusal follows): use [`Self::accept_early`] to serve them as they come. pub async fn accept(&self) -> Option> { + loop { + match self.accept_early().await? { + Ok((conn, handshake)) => { + if handshake.completed().await { + return Some(Ok(conn)); + } + } + Err(refusal) => return Some(Err(refusal)), + } + } + } + + /// The next connection with its [`Handshake`], or refused attempt; `None` once the endpoint + /// is gone. On a route with [early data](Route::dangerous_allow_early_data) the handshake + /// may still be running; on any other route it has completed. + pub async fn accept_early(&self) -> Option> { self.rx.lock().await.recv().await } } @@ -190,6 +315,8 @@ pub(crate) struct Counters { pub(crate) handshakes_failed: AtomicU64, pub(crate) congestion_bbr: AtomicU64, pub(crate) congestion_cubic: AtomicU64, + pub(crate) refused_early_data: AtomicU64, + pub(crate) early_handovers: AtomicU64, } impl Counters { @@ -203,6 +330,7 @@ struct Entry { identity: Option, verified: bool, congestion: CongestionControl, + early_data: bool, tx: mpsc::Sender, } @@ -246,7 +374,13 @@ impl Table { }; let mut config = tls::server_tls_config_with(Arc::new(resolver), verifier)?; config.alpn_protocols = self.entries.iter().map(|e| e.alpn.clone()).collect(); - config.max_early_data_size = 0; + // Early data only once a route takes it (QUIC allows 0 or u32::MAX, RFC 9001 §4.6.1); + // rustls then offers it in its single-use, stateful session tickets. + config.max_early_data_size = if self.entries.iter().any(|e| e.early_data) { + u32::MAX + } else { + 0 + }; Ok(config) } } @@ -311,6 +445,16 @@ impl AlpnRouter { .map(|e| e.congestion) } + /// Whether the route for `alpn` takes 0-RTT early data, if it is registered. + pub fn early_data(&self, alpn: &[u8]) -> Option { + self + .table() + .entries + .iter() + .find(|e| e.alpn == alpn) + .map(|e| e.early_data) + } + /// The rustls server config for the current routes. pub(crate) fn tls(&self, default: &Identity) -> Result { self.table().tls(default) @@ -332,6 +476,15 @@ impl AlpnRouter { if table.entries.iter().any(|e| e.alpn == route.alpn) { return Err(QuicError::AlpnTaken(name)); } + // F25: the mesh, and any route that authenticates its clients, never takes early data. + if route.early_data + && (route.alpn == ALPN_MESH || !matches!(route.policy, ClientCertPolicy::Ignore)) + { + return Err(QuicError::Config(format!( + "ALPN {name:?} cannot take early data (0-RTT): the mesh and routes that require a \ + verified client never do" + ))); + } let trust = match route.policy { ClientCertPolicy::Ignore => None, ClientCertPolicy::RequireVerified { roots, pinned } => { @@ -354,6 +507,7 @@ impl AlpnRouter { identity: route.identity, verified, congestion: route.congestion, + early_data: route.early_data, tx, }); if let Err(e) = table.tls(default).and_then(apply) { @@ -384,13 +538,19 @@ impl AlpnRouter { /// Hand an established connection to its route, or close it: no route for its ALPN, no /// verified client chain where the route requires one (D7), or a full backlog. pub(crate) fn dispatch(&self, conn: quinn::Connection, counters: &Counters) { + self.deliver(conn, Handshake::complete(), counters); + } + + /// [`Self::dispatch`] with `handshake` (still running on a route with early data); whether + /// the route took the connection. + fn deliver(&self, conn: quinn::Connection, handshake: Handshake, counters: &Counters) -> bool { let table = self.table(); let entry = negotiated_alpn(&conn).and_then(|alpn| table.entries.iter().find(|e| e.alpn == alpn)); let Some(entry) = entry else { conn.close(CLOSE_NO_ROUTE.into(), b"no route for this ALPN"); Counters::bump(&counters.refused_no_route); - return; + return false; }; if entry.verified && !has_verified_client_chain(&conn) { // Before the handler sees the connection: nothing of it is ever read. @@ -402,13 +562,16 @@ impl AlpnRouter { let _ = entry.tx.try_send(Err(Refusal::ClientCertRequired { remote: conn.remote_address(), })); - return; + return false; } - match entry.tx.try_send(Ok(conn)) { - Ok(()) => Counters::bump(&counters.accepted), + match entry.tx.try_send(Ok((conn, handshake))) { + Ok(()) => { + Counters::bump(&counters.accepted); + true + } Err(e) => { let full = matches!(e, mpsc::error::TrySendError::Full(_)); - if let Ok(conn) = e.into_inner() { + if let Ok((conn, _)) = e.into_inner() { if full { conn.close(CLOSE_BUSY.into(), b"busy"); Counters::bump(&counters.refused_busy); @@ -417,8 +580,112 @@ impl AlpnRouter { Counters::bump(&counters.refused_no_route); } } + false + } + } + } + + /// Finish the handshake of `connecting`, whose ALPN (`alpn`) the handshake chose, by + /// `deadline`, and hand the connection to its route: at once on a route with early data + /// (its [`Handshake`] settles later), once the handshake completed on any other. Failures + /// are counted and reported to the route. + pub(crate) async fn complete( + &self, + connecting: quinn::Connecting, + alpn: Option>, + remote: SocketAddr, + deadline: tokio::time::Instant, + counters: &Counters, + ) { + // A server-side `Connecting` always converts (quinn: 0.5-RTT). Nothing reads or writes the + // connection before the handshake completes unless its route takes early data. + let Ok((conn, established)) = connecting.into_0rtt() else { + return Counters::bump(&counters.handshakes_failed); + }; + let early = alpn + .as_deref() + .and_then(|alpn| self.early_data(alpn)) + .unwrap_or(false); + let progress = if early { + let (handshake, progress) = Handshake::pending(); + if !self.deliver(conn.clone(), handshake, counters) { + return; + } + Counters::bump(&counters.early_handovers); + Some(progress) + } else { + None + }; + let settled = tokio::time::timeout_at(deadline, settle(&conn, established, early)) + .await + .unwrap_or_else(|_| Err(Unsettled::Failed("handshake timed out".into()))); + let refusal = match settled { + Ok(()) => { + match progress { + Some(progress) => { + progress.send_replace(Progress::Complete); + } + None => self.dispatch(conn, counters), + } + return; + } + Err(Unsettled::EarlyData) => { + // Before the handler sees the connection: none of its early data is ever read. + conn.close(CLOSE_EARLY_DATA_REFUSED.into(), b"early data refused"); + Counters::bump(&counters.refused_early_data); + Refusal::EarlyData { remote } } + Err(Unsettled::Failed(reason)) => { + if let Some(progress) = progress { + progress.send_replace(Progress::Failed); + } + conn.close(0u32.into(), b"handshake failed"); + Counters::bump(&counters.handshakes_failed); + Refusal::Handshake { remote, reason } + } + }; + if let Some(alpn) = alpn { + self.refuse(&alpn, refusal); + } + } +} + +/// Why a handshake did not complete into a connection for its route. +enum Unsettled { + /// The peer opened a stream in 0-RTT on a route without early data. + EarlyData, + /// The handshake failed or timed out. + Failed(String), +} + +/// Wait for the handshake of `conn` to complete. On a route without `early` data a stream the +/// peer opens before that was sent in 0-RTT (a server processes 1-RTT packets only once the +/// handshake completed, RFC 9001 §5.7), and refuses the connection. +async fn settle( + conn: &quinn::Connection, + established: quinn::ZeroRttAccepted, + early: bool, +) -> Result<(), Unsettled> { + let early_stream = async { + if early { + return std::future::pending::>().await; } + tokio::select! { + opened = conn.accept_bi() => opened.map(drop), + opened = conn.accept_uni() => opened.map(drop), + } + }; + tokio::select! { + // A completed handshake wins over the streams it releases. + biased; + _ = established => match conn.close_reason() { + None => Ok(()), + Some(e) => Err(Unsettled::Failed(e.to_string())), + }, + opened = early_stream => match opened { + Ok(()) => Err(Unsettled::EarlyData), + Err(e) => Err(Unsettled::Failed(e.to_string())), + }, } } @@ -449,6 +716,25 @@ mod tests { use crate::quic::{QuicEndpoint, QuicEndpointConfig, QuicLimits, client_config}; use crate::tls::{NodeCertificate, client_tls_config}; + /// A handshake reads as complete only once it completed; a failed one, or one whose endpoint + /// is gone, never does. + #[tokio::test] + async fn handshake_settles_once() { + let done = Handshake::complete(); + assert!(done.is_complete()); + assert!(done.completed().await); + + let (pending, progress) = Handshake::pending(); + assert!(!pending.is_complete()); + progress.send_replace(Progress::Failed); + assert!(!pending.completed().await); + assert!(!pending.is_complete()); + + let (pending, progress) = Handshake::pending(); + drop(progress); + assert!(!pending.completed().await); + } + /// A connection whose ALPN the router does not (or no longer) know is closed. #[tokio::test] async fn a_connection_without_a_route_is_closed() { diff --git a/crates/id_effect_platform/tests/quic_early_data.rs b/crates/id_effect_platform/tests/quic_early_data.rs new file mode 100644 index 000000000..175d61750 --- /dev/null +++ b/crates/id_effect_platform/tests/quic_early_data.rs @@ -0,0 +1,523 @@ +//! 0-RTT early data per route (F25, ADR 0013 R10): off by default; a route opts in with the +//! recorded `Route::dangerous_allow_early_data` and then receives connections before their +//! handshake completes; the mesh (`idm/1`) never takes early data. +//! +//! A UDP relay between client and server holds back the server's handshake flight (its +//! Handshake and 1-RTT packets) on demand: the client keeps sending 0-RTT and cannot finish +//! the handshake, so "before the handshake completes" is a state the tests control instead of +//! a race on loopback. +#![cfg(feature = "quic")] + +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use id_effect::observability::posture::dangerous_opt_outs; +use id_effect_platform::quic::{ + ALPN_H3, ALPN_MESH, AlpnRoute, DANGEROUS_ALLOW_EARLY_DATA, EndpointStats, QuicEndpoint, + QuicEndpointConfig, QuicError, QuicLimits, Refusal, Route, transport_config, +}; +use id_effect_platform::tls::{Identity, NodeCertificate, client_tls_config}; +use tokio::net::UdpSocket; +use tokio::sync::Notify; + +const WAIT: Duration = Duration::from_secs(10); + +fn local() -> SocketAddr { + "127.0.0.1:0".parse().expect("addr") +} + +/// The server's certificate, also the client certificate the mesh pins. +struct Peer { + cert: NodeCertificate, + identity: Identity, +} + +impl Peer { + fn new() -> Self { + let cert = NodeCertificate::self_signed("localhost").expect("cert"); + let identity = Identity::from_certificate(&cert).expect("identity"); + Self { cert, identity } + } + + fn endpoint(&self, limits: QuicLimits) -> QuicEndpoint { + QuicEndpoint::bind( + local(), + QuicEndpointConfig::new(self.identity.clone()).with_limits(limits), + ) + .expect("bind") + } + + /// A client config offering `alpn` and asking for early data; its session cache (shared by + /// clones) keeps the server's tickets for the next dial. + fn early_client(&self, alpn: &[u8]) -> quinn::ClientConfig { + let mut tls = client_tls_config(&self.identity, None, &[self.cert.cert_sha256()]).expect("tls"); + tls.enable_early_data = true; + tls.alpn_protocols = vec![alpn.to_vec()]; + let crypto = quinn::crypto::rustls::QuicClientConfig::try_from(tls).expect("quic tls"); + let mut config = quinn::ClientConfig::new(Arc::new(crypto)); + config.transport_config(Arc::new( + transport_config(&QuicLimits::default()).expect("transport"), + )); + config + } +} + +// --- a relay that can hold the server's handshake flight ---------------------------------------- + +/// Relays one client's datagrams to `server` and back; while held, server datagrams carrying a +/// Handshake or 1-RTT packet (its `Finished`, and everything after it) wait, so the client +/// cannot complete the handshake and keeps sending 0-RTT. +struct Relay { + addr: SocketAddr, + hold: Arc, + released: Arc, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for Relay { + fn drop(&mut self) { + self.task.abort(); + } +} + +impl Relay { + async fn start(server: SocketAddr) -> Self { + let socket = UdpSocket::bind(local()).await.expect("relay"); + let addr = socket.local_addr().expect("relay addr"); + let hold = Arc::new(AtomicBool::new(false)); + let released = Arc::new(Notify::new()); + let task = tokio::spawn(relay( + socket, + server, + Arc::clone(&hold), + Arc::clone(&released), + )); + Self { + addr, + hold, + released, + task, + } + } + + /// From now on, hold the server's handshake flight. + fn hold(&self) { + self.hold.store(true, Ordering::SeqCst); + } + + /// Send what was held and relay everything again. + fn release(&self) { + self.hold.store(false, Ordering::SeqCst); + self.released.notify_one(); + } +} + +async fn relay( + socket: UdpSocket, + server: SocketAddr, + hold: Arc, + released: Arc, +) { + let mut client = None; + let mut held: Vec> = Vec::new(); + let mut buf = vec![0u8; 65_536]; + loop { + tokio::select! { + received = socket.recv_from(&mut buf) => { + let Ok((n, from)) = received else { return }; + let datagram = &buf[..n]; + if from != server { + client = Some(from); + let _ = socket.send_to(datagram, server).await; + } else if let Some(client) = client { + if hold.load(Ordering::SeqCst) && carries_handshake(datagram) { + held.push(datagram.to_vec()); + } else { + let _ = socket.send_to(datagram, client).await; + } + } + } + () = released.notified() => { + if let Some(client) = client { + for datagram in held.drain(..) { + let _ = socket.send_to(&datagram, client).await; + } + } + } + } + } +} + +/// A QUIC variable-length integer and its length (RFC 9000 §16). +fn varint(bytes: &[u8]) -> Option<(usize, usize)> { + let first = *bytes.first()?; + let len = 1usize << (first >> 6); + let mut value = u64::from(first & 0x3f); + for byte in bytes.get(1..len)? { + value = (value << 8) | u64::from(*byte); + } + Some((usize::try_from(value).ok()?, len)) +} + +/// Whether a datagram carries a Handshake or a 1-RTT packet: the coalesced long-header +/// packets are walked (RFC 9000 §12.2, §17.2); a Retry carries neither. +fn carries_handshake(mut datagram: &[u8]) -> bool { + while let Some(&first) = datagram.first() { + if first & 0x80 == 0 { + return true; // short header: 1-RTT + } + match (first >> 4) & 0x03 { + 0b10 => return true, // Handshake + 0b11 => return false, // Retry + kind => match skip_long_header(datagram, kind) { + Some(rest) => datagram = rest, + None => return false, + }, + } + } + false +} + +/// The bytes after the long-header packet that starts `datagram` (of type `kind`). +fn skip_long_header(datagram: &[u8], kind: u8) -> Option<&[u8]> { + let mut at = 5; // first byte, version + let dcid = usize::from(*datagram.get(at)?); + at += 1 + dcid; + let scid = usize::from(*datagram.get(at)?); + at += 1 + scid; + if kind == 0b00 { + let (token, n) = varint(datagram.get(at..)?)?; + at += n + token; + } + let (length, n) = varint(datagram.get(at..)?)?; + datagram.get(at + n + length..) +} + +// --- helpers --------------------------------------------------------------------------------- + +/// One bidirectional exchange: `ping` out, the reply back. +async fn exchange(conn: &quinn::Connection, ping: &[u8]) -> Vec { + let (mut send, mut recv) = conn.open_bi().await.expect("open"); + send.write_all(ping).await.expect("write"); + send.finish().expect("finish"); + tokio::time::timeout(WAIT, recv.read_to_end(1024)) + .await + .expect("reply in time") + .expect("reply") +} + +/// Answer every stream of `conn` with `pong`. +fn echo(conn: quinn::Connection) { + tokio::spawn(async move { + while let Ok((mut send, mut recv)) = conn.accept_bi().await { + let _ = recv.read_to_end(1024).await; + let _ = send.write_all(b"pong").await; + let _ = send.finish(); + } + }); +} + +/// A first, full handshake through `relay`: the client keeps the ticket for the next dial. +async fn first_dial(client: &quinn::Endpoint, config: &quinn::ClientConfig, relay: &Relay) { + let conn = client + .connect_with(config.clone(), relay.addr, "localhost") + .expect("connect") + .await + .expect("first handshake"); + assert_eq!(exchange(&conn, b"ping").await, b"pong"); + // The ticket rides with the server's first 1-RTT flight; the exchange waited for it. + tokio::time::sleep(Duration::from_millis(50)).await; + conn.close(0u32.into(), b"done"); +} + +async fn next_early( + route: &AlpnRoute, +) -> Result<(quinn::Connection, id_effect_platform::quic::Handshake), Refusal> { + tokio::time::timeout(WAIT, route.accept_early()) + .await + .expect("route saw the attempt") + .expect("endpoint running") +} + +async fn until(endpoint: &QuicEndpoint, done: impl Fn(&EndpointStats) -> bool) -> EndpointStats { + tokio::time::timeout(WAIT, async { + loop { + let stats = endpoint.stats(); + if done(&stats) { + return stats; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("counter reached") +} + +// --- tests ----------------------------------------------------------------------------------- + +/// Off by default: the tickets of an endpoint whose routes take no early data allow none, so a +/// resumed client cannot send 0-RTT; its connections are delivered after the handshake. +#[tokio::test] +async fn zero_rtt_off_by_default() { + let peer = Peer::new(); + let endpoint = peer.endpoint(QuicLimits::default()); + assert!(!Route::h3().early_data()); + let h3 = endpoint.register(Route::h3()).expect("h3"); + let router = endpoint.router(); + assert_eq!(router.early_data(ALPN_H3), Some(false)); + assert_eq!(router.early_data(b"other"), None); + let relay = Relay::start(endpoint.local_addr()).await; + let client = quinn::Endpoint::client(local()).expect("client"); + let config = peer.early_client(ALPN_H3); + let server = tokio::spawn(async move { + let (conn, handshake) = next_early(&h3).await.expect("delivered"); + assert!(handshake.is_complete(), "delivered after the handshake"); + assert!(handshake.completed().await); + echo(conn); + h3 + }); + first_dial(&client, &config, &relay).await; + let _h3 = server.await.expect("server"); + let second = client + .connect_with(config, relay.addr, "localhost") + .expect("connect"); + assert!(second.into_0rtt().is_err(), "the server offers no 0-RTT"); + assert_eq!(endpoint.stats().early_handovers, 0); +} + +/// The opt-out: an `h3` route with early data receives a resumed client's connection before +/// the handshake completes and reads its 0-RTT request; the handshake then completes. +#[tokio::test] +async fn h3_route_with_early_data_reads_0rtt_before_the_handshake() { + let peer = Peer::new(); + let endpoint = peer.endpoint(QuicLimits::default()); + let route = Route::h3().dangerous_allow_early_data(); + assert!(route.early_data()); + assert!( + dangerous_opt_outs() + .iter() + .any(|(id, _)| *id == DANGEROUS_ALLOW_EARLY_DATA) + ); + let h3 = endpoint.register(route).expect("h3"); + assert_eq!(endpoint.router().early_data(ALPN_H3), Some(true)); + let relay = Relay::start(endpoint.local_addr()).await; + let client = quinn::Endpoint::client(local()).expect("client"); + let config = peer.early_client(ALPN_H3); + + let server = tokio::spawn(async move { + // The first connection (a full handshake) is served like any other. + let (first, _) = next_early(&h3).await.expect("first"); + echo(first); + let (conn, handshake) = next_early(&h3).await.expect("second"); + let early_at_accept = !handshake.is_complete(); + let (mut send, mut recv) = conn.accept_bi().await.expect("early stream"); + let request = recv.read_to_end(1024).await.expect("early request"); + let early_at_read = !handshake.is_complete(); + send.write_all(b"seen").await.expect("reply"); + send.finish().expect("finish"); + (early_at_accept, early_at_read, request, handshake, conn) + }); + first_dial(&client, &config, &relay).await; + + relay.hold(); + let connecting = client + .connect_with(config, relay.addr, "localhost") + .expect("connect"); + let Ok((conn, accepted)) = connecting.into_0rtt() else { + panic!("the ticket allows early data"); + }; + // Sent in 0-RTT; the server reads and answers it while its handshake flight is held back. + let reply = { + let conn = conn.clone(); + tokio::spawn(async move { exchange(&conn, b"early").await }) + }; + let (early_at_accept, early_at_read, request, handshake, _served) = + tokio::time::timeout(WAIT, server) + .await + .expect("server in time") + .expect("server"); + assert!(early_at_accept && early_at_read); + assert_eq!(request, b"early"); + assert!(!handshake.is_complete()); + + relay.release(); + assert!( + tokio::time::timeout(WAIT, handshake.completed()) + .await + .expect("handshake in time") + ); + assert!(handshake.is_complete()); + assert_eq!(reply.await.expect("client"), b"seen"); + assert!(accepted.await, "the server accepted the 0-RTT data"); + let stats = until(&endpoint, |s| s.early_handovers == 2).await; + assert_eq!(stats.refused_early_data, 0); +} + +/// The mesh never takes early data: the opt-in is refused on `idm/1` and on any route that +/// requires a verified client, and a mesh client that sends 0-RTT on an endpoint whose `h3` +/// route takes early data is closed before the mesh sees anything of it. +#[tokio::test] +async fn mesh_route_never_accepts_early_data() { + let peer = Peer::new(); + let endpoint = peer.endpoint(QuicLimits::default()); + let pins = vec![peer.cert.cert_sha256()]; + for route in [ + Route::mesh(None, pins.clone()), + Route::new(b"custom".to_vec()).require_verified_client(None, pins.clone()), + ] { + let refused = endpoint.register(route.dangerous_allow_early_data()); + assert!( + matches!(&refused, Err(QuicError::Config(m)) if m.contains("cannot take early data")), + "{refused:?}" + ); + } + assert!(endpoint.router().alpns().is_empty()); + + let _h3 = endpoint + .register(Route::h3().dangerous_allow_early_data()) + .expect("h3"); + let mesh = endpoint.register(Route::mesh(None, pins)).expect("mesh"); + assert_eq!(endpoint.router().early_data(ALPN_MESH), Some(false)); + let relay = Relay::start(endpoint.local_addr()).await; + let client = quinn::Endpoint::client(local()).expect("client"); + let config = peer.early_client(ALPN_MESH); + + let server = tokio::spawn(async move { + let (first, handshake) = next_early(&mesh).await.expect("first mesh connection"); + assert!( + handshake.is_complete(), + "the mesh gets connections after the handshake" + ); + echo(first); + next_early(&mesh).await + }); + first_dial(&client, &config, &relay).await; + + relay.hold(); + let connecting = client + .connect_with(config, relay.addr, "localhost") + .expect("connect"); + // The endpoint's tickets allow early data (its h3 route takes it), so the client tries. + let Ok((conn, _accepted)) = connecting.into_0rtt() else { + panic!("the ticket allows early data"); + }; + let (mut send, _recv) = conn.open_bi().await.expect("open"); + send.write_all(b"mesh frame").await.expect("write"); + let _ = send.finish(); + + let refusal = tokio::time::timeout(WAIT, server) + .await + .expect("server in time") + .expect("server"); + assert!( + matches!(refusal, Err(Refusal::EarlyData { .. })), + "{refusal:?}" + ); + assert!( + refusal + .unwrap_err() + .to_string() + .contains("early data (0-RTT)") + ); + let stats = until(&endpoint, |s| s.refused_early_data == 1).await; + assert_eq!(stats.early_handovers, 0); + relay.release(); + tokio::time::timeout(WAIT, conn.closed()) + .await + .expect("the client sees the close"); +} + +/// An early connection whose handshake never completes is closed at the handshake timeout; +/// `AlpnRoute::accept` (which waits for handshakes) skips it and reports the refusal. +#[tokio::test] +async fn early_connection_without_a_completed_handshake_times_out() { + let peer = Peer::new(); + let mut limits = QuicLimits::default(); + limits.handshake_timeout = Duration::from_millis(500); + let endpoint = peer.endpoint(limits); + let h3 = endpoint + .register(Route::h3().dangerous_allow_early_data()) + .expect("h3"); + let relay = Relay::start(endpoint.local_addr()).await; + let client = quinn::Endpoint::client(local()).expect("client"); + let config = peer.early_client(ALPN_H3); + + let server = tokio::spawn(async move { + let first = tokio::time::timeout(WAIT, h3.accept()) + .await + .expect("in time") + .expect("running") + .expect("first"); + echo(first); + tokio::time::timeout(WAIT, h3.accept()) + .await + .expect("in time") + .expect("running") + }); + first_dial(&client, &config, &relay).await; + + relay.hold(); + let connecting = client + .connect_with(config, relay.addr, "localhost") + .expect("connect"); + let Ok((conn, _accepted)) = connecting.into_0rtt() else { + panic!("the ticket allows early data"); + }; + let (mut send, _recv) = conn.open_bi().await.expect("open"); + send.write_all(b"never confirmed").await.expect("write"); + let _ = send.finish(); + + let refused = tokio::time::timeout(WAIT, server) + .await + .expect("server in time") + .expect("server"); + assert!( + matches!(&refused, Err(Refusal::Handshake { reason, .. }) if reason.contains("timed out")), + "{refused:?}" + ); + let stats = until(&endpoint, |s| s.handshakes_failed == 1).await; + assert_eq!(stats.early_handovers, 2); +} + +/// An early route whose receiver is gone closes the connection at once (as any route). +#[tokio::test] +async fn early_route_without_a_receiver_closes_its_connections() { + let peer = Peer::new(); + let endpoint = peer.endpoint(QuicLimits::default()); + let h3 = endpoint + .register(Route::h3().dangerous_allow_early_data()) + .expect("h3"); + drop(h3); + let client = quinn::Endpoint::client(local()).expect("client"); + let config = peer.early_client(ALPN_H3); + let connecting = client + .connect_with(config, endpoint.local_addr(), "localhost") + .expect("connect"); + if let Ok(Ok(conn)) = tokio::time::timeout(WAIT, connecting).await { + tokio::time::timeout(WAIT, conn.closed()) + .await + .expect("closed"); + } + let stats = until(&endpoint, |s| s.refused_no_route == 1).await; + assert_eq!(stats.early_handovers, 0); +} + +#[test] +fn the_relay_reads_coalesced_packets() { + // Initial (empty token, 1-byte payload) coalesced with a Handshake packet. + let initial = [0xc0, 0, 0, 0, 1, 1, 0xaa, 1, 0xbb, 0, 1, 0x00]; + let handshake = [0xe0, 0, 0, 0, 1, 0, 0, 1, 0x00]; + assert!(!carries_handshake(&initial)); + assert!(carries_handshake(&[&initial[..], &handshake[..]].concat())); + // 0-RTT only; a short header; a Retry. + let zero_rtt = [0xd0, 0, 0, 0, 1, 0, 0, 0x40, 0x01, 0x00]; + assert!(!carries_handshake(&zero_rtt)); + assert!(carries_handshake(&[0x40, 1, 2])); + assert!(!carries_handshake(&[0xf0, 0, 0, 0, 1, 0, 0, 9, 9])); + // Truncated headers are not held. + assert!(!carries_handshake(&[0xc0, 0, 0])); + assert_eq!(varint(&[0x40, 0x10]), Some((16, 2))); + assert_eq!(varint(&[]), None); +} diff --git a/docs/MIGRATION-0.13.md b/docs/MIGRATION-0.13.md index f7844f014..79e342518 100644 --- a/docs/MIGRATION-0.13.md +++ b/docs/MIGRATION-0.13.md @@ -35,7 +35,22 @@ Owner: r13-f-ws-credit. ## Axum / platform: `Http3Options` early-data and congestion settings -Owner: r13-f-h3-settings. +Owner: r13-f-h3-settings. No signature breaks (new items only; `Refusal` and `EndpointStats` +are `#[non_exhaustive]`); two behaviour changes: + +- `Http3Options::dangerous_allow_zero_rtt_for_queries` is no longer a recorded no-op: the `h3` + route now takes 0-RTT early data (`Route::dangerous_allow_early_data`, also recorded) and + serves early `GET` / `HEAD` requests before the handshake completes; every other early + request is answered `425 Too Early`. `Http3Options::admits_early` no longer admits `OPTIONS`. + If you set the opt-out "for later", remove it unless your safe-method handlers are idempotent + and reveal nothing a replay could exploit. +- `AlpnRoute::accept` on a route with early data waits for each connection's handshake; use + `AlpnRoute::accept_early` to receive connections at once with their `Handshake`. Routes + without early data (the default, and always the mesh) are unchanged. + +New: `Http3Options::congestion(CongestionControl::Cubic)` (default stays BBR) and +`Http3Options::config()` for the config key `server.http3.congestion`; `CongestionControl` +implements `FromStr` (`bbr`, `cubic`). ## RPC: `HttpTransport::with_header` / `with_bearer` From 41f7fcb82c9d4b7e3db44e39a7ff9cf2aac36a3b Mon Sep 17 00:00:00 2001 From: Tom Wieland Date: Wed, 21 Oct 2026 09:22:57 +0200 Subject: [PATCH 16/87] feat(sql): relations, eager loading and entity field metadata (r13-e3-3-relations) belongs_to / has_many / many_to_many(through) on #[derive(Entity)]; Repository::with loads each relation in one IN query per level (max 3), re-scoping children by their tenant and soft-delete filters (ADR 0013 R1); Entity::FIELDS / RELATIONS / LABEL with defaults. repository.rs split into repository/{mod,relations,page,hooks}.rs with every public path kept. Book chapter 28.6. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Wieland --- CHANGELOG.md | 14 + Cargo.lock | 1 + crates/id_effect/book/src/SUMMARY.md | 1 + .../book/src/part6/ch28-03-repositories.md | 4 + .../book/src/part6/ch28-06-relations.md | 230 ++++ crates/id_effect_proc_macro/src/repository.rs | 513 ++++++- crates/id_effect_sql/Cargo.toml | 3 + crates/id_effect_sql/src/lib.rs | 2 + crates/id_effect_sql/src/repository/hooks.rs | 2 + .../src/{repository.rs => repository/mod.rs} | 57 +- crates/id_effect_sql/src/repository/page.rs | 1 + .../id_effect_sql/src/repository/relations.rs | 1184 +++++++++++++++++ crates/id_effect_sql/tests/relations.rs | 649 +++++++++ .../tests/repository_relations_pg.rs | 86 ++ examples/book-snippets/src/part6.rs | 4 + 15 files changed, 2738 insertions(+), 13 deletions(-) create mode 100644 crates/id_effect/book/src/part6/ch28-06-relations.md create mode 100644 crates/id_effect_sql/src/repository/hooks.rs rename crates/id_effect_sql/src/{repository.rs => repository/mod.rs} (90%) create mode 100644 crates/id_effect_sql/src/repository/page.rs create mode 100644 crates/id_effect_sql/src/repository/relations.rs create mode 100644 crates/id_effect_sql/tests/relations.rs create mode 100644 crates/id_effect_sql/tests/repository_relations_pg.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fe9d0e3ef..7f762c6ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ ### Added +- Relations and eager loading in `id_effect_sql` (r13-e3-3-relations, data.orm #3): + `#[entity(belongs_to(..))]`, `has_many(..)` and `many_to_many(.., through = "..")` generate + `Relation` constructors (`Order::lines()`); `Repository::with(rel)` returns an + `EagerRepository` whose `find` / `find_all` / `find_by` / `find_page` / `load` return `Loaded` + values, loading each relation in one `IN (…)` query per relation per level (no N+1; keys + deduplicated, chunked at 1 000), nested with `Relation::with` at most `MAX_EAGER_DEPTH` (3) + levels, on PostgreSQL, MySQL and SQLite. Secure by default (ADR 0013 R1): children are read + with the child entity's tenant, soft-delete and custom filters (inside the join for N:M), so a + relation never returns another tenant's row, and a tenant-scoped child without a tenant fails + closed before any query. `Entity::FIELDS` (`FieldMeta`: column, type, nullable, + `#[entity(editable)]`, `#[entity(label = "..")]`, relation), `Entity::RELATIONS` + (`RelationMeta`) and `Entity::LABEL`, all defaulted so hand-written impls compile unchanged. + `repository.rs` becomes `repository/{mod,relations}.rs`; every public path is kept. Book: + chapter 28.6. - `id_effect_ai` promoted to tier `integration` and published (r13-e9-1-promote, D6; ai.llm #1 #2 #7): provider-neutral chat (`chat`: `LanguageModel`, `ChatRequest::new` / `user` with a per-request `max_tokens`, `ChatResponse` with `Usage`, `stop_reason` and `ToolCall`s) and diff --git a/Cargo.lock b/Cargo.lock index 9d5ae21f5..0126ef0bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3220,6 +3220,7 @@ version = "0.12.0" dependencies = [ "id_effect", "id_effect_proc_macro", + "id_effect_test", "id_effect_tokio", "jiff", "proptest", diff --git a/crates/id_effect/book/src/SUMMARY.md b/crates/id_effect/book/src/SUMMARY.md index d73c586d8..16586f362 100644 --- a/crates/id_effect/book/src/SUMMARY.md +++ b/crates/id_effect/book/src/SUMMARY.md @@ -165,6 +165,7 @@ - [Query builder, entities and repositories](./part6/ch28-03-repositories.md) - [Redis](./part6/ch28-04-redis.md) - [Object storage (`id_effect_storage`)](./part6/ch28-05-storage.md) + - [Relations and eager loading](./part6/ch28-06-relations.md) - [API boundaries (`id_effect_rpc`)](./part6/ch29-00-api-boundaries.md) - [Typed routes and validation](./part6/ch29-01-typed-routes.md) - [OpenAPI, API docs and the route table](./part6/ch29-02-openapi.md) diff --git a/crates/id_effect/book/src/part6/ch28-03-repositories.md b/crates/id_effect/book/src/part6/ch28-03-repositories.md index 63b5209ab..5eb6194f2 100644 --- a/crates/id_effect/book/src/part6/ch28-03-repositories.md +++ b/crates/id_effect/book/src/part6/ch28-03-repositories.md @@ -365,6 +365,10 @@ assert_eq!( [Tenancy and the audit log](ch30-07-audit-tenancy.md) shows the whole request path: from the authenticated principal to `CurrentTenant`, the filtered repository and an audit record in the same transaction. +## Relations + +`#[entity(belongs_to(..))]`, `has_many(..)` and `many_to_many(.., through = "..")` declare relations between entities, and `repo.with(Order::lines())` loads them with the rows in one query per relation (no N+1), each child re-scoped by its own tenant and soft-delete filters. [Relations and eager loading](ch28-06-relations.md) covers them, with the field metadata (`Entity::FIELDS`) generic tools read. + ## Dialects The repository renders each statement for its `Dialect`. PostgreSQL and SQLite insert with `RETURNING`. MySQL has no `RETURNING`, so an insert is followed by a read of the row by id, and a `generated` id is `SqlError::Unsupported` there (assign ids in the application, e.g. UUIDs). diff --git a/crates/id_effect/book/src/part6/ch28-06-relations.md b/crates/id_effect/book/src/part6/ch28-06-relations.md new file mode 100644 index 000000000..cc551febe --- /dev/null +++ b/crates/id_effect/book/src/part6/ch28-06-relations.md @@ -0,0 +1,230 @@ +# Relations and eager loading + +[Repositories](ch28-03-repositories.md) read one entity's table. Most screens need a row and the rows around it: an order with its lines and its customer, a post with its tags. Loading those one parent at a time is the N+1 problem: one query for the orders, then one more per order. `id_effect_sql` (feature `repository`) declares relations on the entity and loads them in **one query per relation per level**, whatever the number of rows. + +## Declaring relations + +Three attributes on `#[derive(Entity)]`, each naming the target entity first: + +| Attribute | Kind | Children are the target rows whose … | +|---|---|---| +| `belongs_to(Customer, foreign_key = "customer_id")` | N:1 | primary key is this row's `customer_id` (a field) | +| `has_many(Line, foreign_key = "order_id")` | 1:N | `order_id` is this row's primary key | +| `many_to_many(Tag, through = "order_tags", local_key = "order_id", foreign_key = "tag_id")` | N:M | primary key is paired with this row's in the join table | + +`name = ".."` names the relation. The defaults follow the usual conventions: the name is the target in snake case (`customer`), plus `s` for the two "many" kinds (`lines`, `tags`); `belongs_to`'s foreign key is `_id`, `has_many`'s is `_id`, and the join table's keys are `_id` and `_id`. A `belongs_to` foreign key must be a column of the entity (the derive checks it). + +Each relation becomes a constructor, `Order::lines() -> Relation`, and an entry of `Entity::RELATIONS`: + +```rust +use id_effect_sql::repository::RelationKind; +use id_effect_sql::{Entity, FromRow}; + +#[derive(Clone, Debug, PartialEq, FromRow, Entity)] +#[entity(table = "customers", tenant = "org_id")] +struct Customer { + #[entity(id)] + id: i64, + name: String, +} + +#[derive(Clone, Debug, PartialEq, FromRow, Entity)] +#[entity(table = "order_lines", tenant = "org_id", soft_delete = "deleted_at")] +struct Line { + #[entity(id)] + id: i64, + order_id: i64, + qty: i64, +} + +#[derive(Clone, Debug, PartialEq, FromRow, Entity)] +#[entity(table = "tags", tenant = "org_id")] +struct Tag { + #[entity(id)] + id: i64, + name: String, +} + +#[derive(Clone, Debug, PartialEq, FromRow, Entity)] +#[entity(table = "orders", tenant = "org_id")] +#[entity(belongs_to(Customer), has_many(Line), many_to_many(Tag, through = "order_tags"))] +struct Order { + #[entity(id)] + id: i64, + customer_id: Option, + title: String, +} + +assert_eq!(Order::lines().kind(), RelationKind::HasMany { foreign_key: "order_id" }); +assert_eq!( + Order::tags().kind(), + RelationKind::ManyToMany { through: "order_tags", local_key: "order_id", foreign_key: "tag_id" } +); +assert_eq!(Order::RELATIONS.iter().map(|r| r.name).collect::>(), ["customer", "lines", "tags"]); +assert_eq!(Order::RELATIONS[0].target, "Customer"); +``` + +Without the derive, `Relation::belongs_to(name, foreign_key)`, `Relation::has_many(name, foreign_key)` and `Relation::many_to_many(name, through, local_key, foreign_key)` build the same values for a hand-written `impl Entity`. + +## Eager loading: `Repository::with` + +`repo.with(Order::lines())` returns an `EagerRepository` with the repository's reads (`find`, `find_all`, `find_by`, `find_page`, and `load` for entities you already have, e.g. a keyset page). Each returns `Loaded` values: they deref to the order, and `get(&Order::lines())` returns that order's lines (`one(..)` the single parent of a `belongs_to`). Chain `with` for more relations; `on(executor)` runs them in a transaction as `Repository::on` does. + +After the parent query, each relation runs **one** query with the keys of all parents in an `IN (…)` list (deduplicated; sent in chunks of 1 000 keys for very large reads). Each child row carries its owner's key as `__id_effect_key`, which is how the children are handed to their parents: + +```rust +use id_effect::Exit; +use id_effect_sql::{Dialect, Entity, FromRow, Repository, SqlClient, SqlClientEnv, SqlRow, SqlValue, TestSqlClient, sql_client_env}; + +#[derive(Clone, Debug, PartialEq, FromRow, Entity)] +#[entity(table = "customers", tenant = "org_id")] +struct Customer { + #[entity(id)] + id: i64, + name: String, +} + +#[derive(Clone, Debug, PartialEq, FromRow, Entity)] +#[entity(table = "order_lines", tenant = "org_id")] +struct Line { + #[entity(id)] + id: i64, + order_id: i64, + qty: i64, +} + +#[derive(Clone, Debug, PartialEq, FromRow, Entity)] +#[entity(table = "orders", tenant = "org_id", belongs_to(Customer), has_many(Line))] +struct Order { + #[entity(id)] + id: i64, + customer_id: Option, + title: String, +} + +let test = TestSqlClient::new(); +let _ = test.connect().block_on(); +let order = |id: i64| SqlRow::from_pairs([("id", SqlValue::I64(id)), ("customer_id", SqlValue::I64(3)), ("title", "t".into())]); +test.script_query( + r#"SELECT "id", "customer_id", "title" FROM "orders" WHERE "org_id" = $1 ORDER BY "id" ASC"#, + vec![order(1), order(2)], +); +let line = |id: i64, order_id: i64| SqlRow::from_pairs([ + ("id", id), ("order_id", order_id), ("qty", 1), ("__id_effect_key", order_id), +]); +test.script_query( + r#"SELECT "id", "order_id", "qty", "order_id" AS "__id_effect_key" FROM "order_lines" WHERE "org_id" = $1 AND "order_id" IN ($2, $3) ORDER BY "id" ASC"#, + vec![line(10, 1), line(11, 1), line(12, 2)], +); +test.script_query( + r#"SELECT "id", "name", "id" AS "__id_effect_key" FROM "customers" WHERE "org_id" = $1 AND "id" IN ($2) ORDER BY "id" ASC"#, + vec![SqlRow::from_pairs([("id", SqlValue::I64(3)), ("name", "ada".into()), ("__id_effect_key", SqlValue::I64(3))])], +); + +let orders = Repository::::new(Dialect::Postgres).with_tenant(7i64); +let loaded = orders + .with(Order::lines()) + .with(Order::customer()) + .find_all::() + .provide_all(sql_client_env(test.clone())) + .block_on(); +let Exit::Success(loaded) = loaded else { panic!("{loaded:?}") }; + +let lines_of = |i: usize| loaded[i].get(&Order::lines()).iter().map(|l| l.id).collect::>(); +assert_eq!((lines_of(0), lines_of(1)), (vec![10, 11], vec![12])); +assert_eq!(loaded[1].one(&Order::customer()).map(|c| c.name.as_str()), Some("ada")); +assert_eq!(loaded[0].title, "t"); // Loaded derefs to the order + +// Two orders, two relations: three queries, not five. +assert_eq!(test.recorded_queries().len(), 3); +``` + +### Nesting + +`Relation::with` loads the relations of the children: `Order::lines().with(Line::product().with(Product::vendor()))` reads orders, lines, products and vendors in four queries. Chains are at most `MAX_EAGER_DEPTH` (3) relations deep; a deeper one fails with `SqlError::Unsupported` before any query runs, so an accidental fan-out never reaches the database. + +## Secure by default: children keep their filters + +A relation reads its children the way the parent repository reads (ADR 0013 R1): with the same tenant and filter mode, so the **child's** own global filters are AND-ed into the relation query (inside the join for `many_to_many`): + +- a tenant-scoped child is filtered by the repository's tenant. A foreign key or join-table row that points at another tenant's row loads nothing: a relation can never return another tenant's data, however the rows were written; +- a soft-deleted child is hidden (`with_deleted()` on the repository shows it, as it does for the parents); +- a tenant-scoped child under a repository without a tenant fails closed (`SqlError::Unsupported`), even when the parent entity itself is not tenant-scoped; +- `unscoped()` lifts every filter of the parents and the children: the explicit escape hatch. + +```rust +use id_effect::{Cause, Exit}; +use id_effect_sql::{Dialect, Entity, FromRow, Repository, SqlClient, SqlClientEnv, SqlError, TestSqlClient, sql_client_env}; + +#[derive(Clone, Debug, PartialEq, FromRow, Entity)] +#[entity(table = "tags", tenant = "org_id", soft_delete = "deleted_at")] +struct Tag { + #[entity(id)] + id: i64, + name: String, +} + +/// Posts are not tenant-scoped; their tags are. +#[derive(Clone, Debug, PartialEq, FromRow, Entity)] +#[entity(table = "posts", many_to_many(Tag, through = "post_tags"))] +struct Post { + #[entity(id)] + id: i64, +} + +let test = TestSqlClient::new(); +let _ = test.connect().block_on(); +let no_tenant = Repository::::new(Dialect::Postgres) + .with(Post::tags()) + .find_all::() + .provide_all(sql_client_env(test.clone())) + .block_on(); +assert!(matches!(no_tenant, Exit::Failure(Cause::Fail(SqlError::Unsupported(_))))); +assert!(test.recorded_queries().is_empty(), "nothing was read"); +``` + +`crates/id_effect_sql/tests/relations.rs` proves both rules on SQLite and `tests/repository_relations_pg.rs` on PostgreSQL: `m19_eager_loaded_children_scoped_to_tenant` (a line of tenant 2 pointing at an order of tenant 1, a customer of another tenant, soft-deleted lines) and `relation_join_cannot_cross_tenant` (join-table rows pairing an order with another tenant's tag). + +## Field metadata + +Generic tools (an admin, exports, form builders) need to know what an entity's fields are. The derive fills `Entity::FIELDS` with one `FieldMeta` per field: name, column, the type as written (without `Option`), `nullable`, `editable`, a label key and, for a `belongs_to` foreign key, the relation's name. `Entity::LABEL` names the entity. + +- `#[entity(editable)]` marks a field a generic form may bind. Nothing is editable by default (no mass assignment), and the id, version, tenant and soft-delete columns can never be. +- `#[entity(label = "..")]` on a field or the struct sets the label key, e.g. a translation key; the defaults are `.` and `` in snake case. + +```rust +use id_effect_sql::repository::FieldMeta; +use id_effect_sql::{Entity, FromRow}; + +#[derive(Clone, Debug, PartialEq, FromRow, Entity)] +#[entity(table = "customers")] +struct Customer { + #[entity(id)] + id: i64, +} + +#[derive(Clone, Debug, PartialEq, FromRow, Entity)] +#[entity(table = "invoices", label = "billing.invoice", belongs_to(Customer))] +struct Invoice { + #[entity(id)] + id: i64, + customer_id: i64, + #[entity(editable, label = "billing.invoice.memo")] + memo: Option, +} + +assert_eq!(Invoice::LABEL, "billing.invoice"); +assert_eq!( + Invoice::FIELDS[1], + FieldMeta::new("customer_id", "customer_id", "i64").label("invoice.customer_id").relation("customer") +); +let memo = Invoice::FIELDS[2]; +assert_eq!((memo.schema_type, memo.nullable, memo.editable, memo.label), ("String", true, true, "billing.invoice.memo")); +``` + +Both constants default to empty in the `Entity` trait, so a hand-written `impl Entity` from 0.12 compiles unchanged (`manual_entity_impl_still_compiles`). + +## Tests + +- `crates/id_effect_sql/tests/relations.rs`: `eager_load_issues_one_query_per_relation` (a query-count probe on the executor), `many_to_many_through_join_table`, `nested_eager_load_three_levels`, `m19_eager_loaded_children_scoped_to_tenant`, `relation_join_cannot_cross_tenant`, `entity_fields_metadata_from_derive` and `manual_entity_impl_still_compiles`, on SQLite in memory: `cargo nextest run -p id_effect_sql --features repository,sqlite --test relations`. +- `tests/repository_relations_pg.rs` runs the same suite on PostgreSQL in its own container: `ID_EFFECT_REQUIRE_PG=1 cargo nextest run -p id_effect_sql --features repository,pg --test repository_relations_pg`. diff --git a/crates/id_effect_proc_macro/src/repository.rs b/crates/id_effect_proc_macro/src/repository.rs index 14c9e03ae..49f8c26eb 100644 --- a/crates/id_effect_proc_macro/src/repository.rs +++ b/crates/id_effect_proc_macro/src/repository.rs @@ -1,15 +1,23 @@ //! `#[derive(Entity)]` — generate an `id_effect_sql::repository::Entity` impl mapping a -//! `#[derive(FromRow)]` struct to a table. +//! `#[derive(FromRow)]` struct to a table, its field and relation metadata, and one relation +//! constructor per declared relation. //! //! ```text -//! #[entity(table = "accounts", // default: the type name in snake case +//! #[entity(table = "orders", // default: the type name in snake case //! tenant = "org_id", // scope every statement to the repository's tenant //! soft_delete = "deleted_at", // hide rows whose column is not NULL //! scope = active_only, // fn() -> impl Into; repeatable +//! label = "shop.order", // Entity::LABEL (default: the snake-case name) +//! belongs_to(Customer, foreign_key = "customer_id", name = "customer"), +//! has_many(Line, foreign_key = "order_id", name = "lines"), +//! many_to_many(Tag, through = "order_tags", local_key = "order_id", +//! foreign_key = "tag_id", name = "tags"), //! crate = path)] // all optional -//! struct Account { +//! struct Order { //! #[entity(id, generated)] id: i64, // primary key; `generated`: assigned by the database //! #[from_row(rename = "title")] name: String, // columns are the FromRow columns +//! #[entity(editable, label = "shop.order.note")] note: Option, // admin metadata +//! customer_id: i64, //! #[entity(version)] version: i64, // optimistic locking //! } //! ``` @@ -18,13 +26,28 @@ //! the field name) so the two derives always agree; `#[from_row(flatten)]` fields have no single //! column and are rejected. Each value is `::to_sql_value(&self.field)`, //! spanned at the field type, so a type without `ToSqlValue` is reported there. +//! +//! Relations: the first item is the target entity; `name` defaults to the target in snake case +//! (`belongs_to`) or that plus `s` (`has_many`, `many_to_many`); keys default to +//! `_id` (`belongs_to`'s foreign key on this entity, which must be a column), `_id` +//! (`has_many`'s foreign key on the target) and `_id` / `_id` (the join table's +//! `local_key` / `foreign_key`). Each becomes `Self::() -> Relation` and an +//! `Entity::RELATIONS` entry. +//! +//! Field metadata (`Entity::FIELDS`): `editable` marks a field generic forms may bind (never the +//! id, version, tenant or soft-delete column); `label` sets its label key (default +//! `.`). use proc_macro::TokenStream; use proc_macro2::{Span, TokenStream as TokenStream2}; -use quote::{quote, quote_spanned}; +use quote::{ToTokens, quote, quote_spanned}; use syn::ext::IdentExt; +use syn::meta::ParseNestedMeta; use syn::spanned::Spanned; -use syn::{Data, DeriveInput, Field, Fields, Ident, LitStr, Path, parse_macro_input}; +use syn::{ + Data, DeriveInput, Field, Fields, GenericArgument, Ident, LitStr, Path, PathArguments, Type, + parse_macro_input, +}; pub(crate) fn derive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); @@ -34,15 +57,113 @@ pub(crate) fn derive(input: TokenStream) -> TokenStream { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RelKind { + BelongsTo, + HasMany, + ManyToMany, +} + +impl RelKind { + fn of(path: &Path) -> Option { + if path.is_ident("belongs_to") { + Some(Self::BelongsTo) + } else if path.is_ident("has_many") { + Some(Self::HasMany) + } else if path.is_ident("many_to_many") { + Some(Self::ManyToMany) + } else { + None + } + } + + fn attr(self) -> &'static str { + match self { + Self::BelongsTo => "belongs_to", + Self::HasMany => "has_many", + Self::ManyToMany => "many_to_many", + } + } +} + +/// One `belongs_to(..)` / `has_many(..)` / `many_to_many(..)` as written. +struct RelAttr { + kind: RelKind, + target: Path, + name: Option, + foreign_key: Option, + through: Option, + local_key: Option, + span: Span, +} + #[derive(Default)] struct ContainerAttrs { table: Option, tenant: Option, soft_delete: Option, + label: Option, scopes: Vec, + relations: Vec, krate: Option, } +fn parse_relation(kind: RelKind, meta: &ParseNestedMeta<'_>) -> syn::Result { + let span = meta.path.span(); + let (mut name, mut foreign_key, mut through, mut local_key) = (None, None, None, None); + let mut target = None; + meta.parse_nested_meta(|m| { + let join_table = kind == RelKind::ManyToMany; + if m.path.is_ident("name") { + name = Some(m.value()?.parse()?); + } else if m.path.is_ident("foreign_key") { + foreign_key = Some(m.value()?.parse()?); + } else if join_table && m.path.is_ident("through") { + through = Some(m.value()?.parse()?); + } else if join_table && m.path.is_ident("local_key") { + local_key = Some(m.value()?.parse()?); + } else if target.is_none() && (m.input.is_empty() || m.input.peek(syn::Token![,])) { + target = Some(m.path.clone()); + } else { + let keys = if join_table { + "`through = \"..\"`, `local_key = \"..\"`, `foreign_key = \"..\"` or `name = \"..\"`" + } else { + "`foreign_key = \"..\"` or `name = \"..\"`" + }; + return Err(m.error(format!( + "expected the target entity, then {keys} in `{}(..)`", + kind.attr() + ))); + } + Ok(()) + })?; + let target = target.ok_or_else(|| { + syn::Error::new( + span, + format!( + "`{}(..)` needs the target entity first, e.g. `{}(Line, ..)`", + kind.attr(), + kind.attr() + ), + ) + })?; + if kind == RelKind::ManyToMany && through.is_none() { + return Err(syn::Error::new( + span, + "`many_to_many(..)` needs its join table: `through = \"..\"`", + )); + } + Ok(RelAttr { + kind, + target, + name, + foreign_key, + through, + local_key, + span, + }) +} + fn parse_container(input: &DeriveInput) -> syn::Result { let mut attrs = ContainerAttrs::default(); for attr in input.attrs.iter().filter(|a| a.path().is_ident("entity")) { @@ -53,14 +174,19 @@ fn parse_container(input: &DeriveInput) -> syn::Result { attrs.tenant = Some(meta.value()?.parse()?); } else if meta.path.is_ident("soft_delete") { attrs.soft_delete = Some(meta.value()?.parse()?); + } else if meta.path.is_ident("label") { + attrs.label = Some(meta.value()?.parse()?); } else if meta.path.is_ident("scope") { attrs.scopes.push(meta.value()?.parse()?); } else if meta.path.is_ident("crate") { attrs.krate = Some(meta.value()?.parse()?); + } else if let Some(kind) = RelKind::of(&meta.path) { + attrs.relations.push(parse_relation(kind, &meta)?); } else { return Err(meta.error( - "expected `table = \"..\"`, `tenant = \"..\"`, `soft_delete = \"..\"`, `scope = path` \ - or `crate = path` on the struct", + "expected `table = \"..\"`, `tenant = \"..\"`, `soft_delete = \"..\"`, `scope = path`, \ + `label = \"..\"`, `belongs_to(..)`, `has_many(..)`, `many_to_many(..)` or \ + `crate = path` on the struct", )); } Ok(()) @@ -74,6 +200,8 @@ struct FieldAttrs { id: bool, generated: bool, version: bool, + editable: bool, + label: Option, /// The first `#[entity(..)]` attribute, for duplicate errors. span: Option, } @@ -89,8 +217,14 @@ fn parse_field(field: &Field) -> syn::Result { attrs.generated = true; } else if meta.path.is_ident("version") { attrs.version = true; + } else if meta.path.is_ident("editable") { + attrs.editable = true; + } else if meta.path.is_ident("label") { + attrs.label = Some(meta.value()?.parse()?); } else { - return Err(meta.error("expected `id`, `generated` or `version`")); + return Err( + meta.error("expected `id`, `generated`, `version`, `editable` or `label = \"..\"`"), + ); } Ok(()) })?; @@ -107,6 +241,12 @@ fn parse_field(field: &Field) -> syn::Result { "a field cannot be both the `id` and the `version` column", )); } + if attrs.editable && (attrs.id || attrs.version) { + return Err(syn::Error::new_spanned( + field, + "the `id` and `version` columns are never `editable` (no form may write them)", + )); + } Ok(attrs) } @@ -163,6 +303,96 @@ fn option(lit: Option<&LitStr>) -> TokenStream2 { } } +/// `T` when `ty` is `Option` (by its last path segment). +fn option_inner(ty: &Type) -> Option<&Type> { + let Type::Path(path) = ty else { + return None; + }; + let segment = path.path.segments.last()?; + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + match ( + segment.ident == "Option", + args.args.first(), + args.args.len(), + ) { + (true, Some(GenericArgument::Type(inner)), 1) => Some(inner), + _ => None, + } +} + +/// `ty` as written, without the token spacing `quote` adds (`Vec < u8 >` → `Vec`). +fn type_name(ty: &Type) -> String { + ty.to_token_stream() + .to_string() + .replace(" :: ", "::") + .replace(":: ", "::") + .replace(" < ", "<") + .replace("< ", "<") + .replace(" >", ">") + .replace(" ,", ",") + .replace("& ", "&") +} + +/// A relation with its defaults resolved. +struct Resolved { + kind: RelKind, + target: Path, + name: LitStr, + ident: Ident, + foreign_key: LitStr, + through: Option, + local_key: LitStr, +} + +fn resolve(rel: RelAttr, entity: &str, columns: &[LitStr]) -> syn::Result { + let span = rel.span; + let target = rel + .target + .segments + .last() + .map_or_else(String::new, |s| snake_case(&s.ident)); + let lit = |value: String| LitStr::new(&value, span); + let name = rel.name.unwrap_or_else(|| match rel.kind { + RelKind::BelongsTo => lit(target.clone()), + RelKind::HasMany | RelKind::ManyToMany => lit(format!("{target}s")), + }); + let ident = syn::parse_str::(&name.value()).map_err(|_| { + syn::Error::new( + name.span(), + format!( + "relation name `{}` must be an identifier (it names `Self::{}()`)", + name.value(), + name.value() + ), + ) + })?; + let foreign_key = rel.foreign_key.unwrap_or_else(|| match rel.kind { + RelKind::BelongsTo | RelKind::ManyToMany => lit(format!("{target}_id")), + RelKind::HasMany => lit(format!("{entity}_id")), + }); + if rel.kind == RelKind::BelongsTo && !columns.iter().any(|c| c.value() == foreign_key.value()) { + return Err(syn::Error::new( + foreign_key.span(), + format!( + "`belongs_to` foreign key `{}` is not a column of this entity (name it with \ + `foreign_key = \"..\"`)", + foreign_key.value() + ), + )); + } + Ok(Resolved { + kind: rel.kind, + target: rel.target, + name, + ident, + foreign_key, + through: rel.through, + local_key: rel.local_key.unwrap_or_else(|| lit(format!("{entity}_id"))), + }) +} + pub(crate) fn expand(input: DeriveInput) -> syn::Result { let name = &input.ident; let Data::Struct(data) = &input.data else { @@ -180,11 +410,14 @@ pub(crate) fn expand(input: DeriveInput) -> syn::Result { let attrs = parse_container(&input)?; let krate = attrs .krate + .clone() .unwrap_or_else(|| syn::parse_quote!(::id_effect_sql)); let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let entity = snake_case(name); + let protected = [attrs.tenant.as_ref(), attrs.soft_delete.as_ref()]; let (mut id, mut version) = (None, None); - let (mut columns, mut values) = (Vec::new(), Vec::new()); + let (mut columns, mut values, mut field_attrs) = (Vec::new(), Vec::new(), Vec::new()); for field in &fields.named { let fa = parse_field(field)?; let (column, flatten) = from_row_column(field)?; @@ -195,6 +428,17 @@ pub(crate) fn expand(input: DeriveInput) -> syn::Result { supported", )); } + if fa.editable + && protected + .iter() + .flatten() + .any(|c| c.value() == column.value()) + { + return Err(syn::Error::new_spanned( + field, + "the tenant and soft-delete columns are never `editable` (the repository writes them)", + )); + } let ident = field.ident.as_ref().expect("named field"); let ty = &field.ty; let span = fa.span.unwrap_or_else(|| field.span()); @@ -220,6 +464,7 @@ pub(crate) fn expand(input: DeriveInput) -> syn::Result { (#column, <#ty as #krate::ToSqlValue>::to_sql_value(&self.#ident)) }); columns.push(column); + field_attrs.push((field, fa)); } let Some((id_ident, id_ty, id_column, generated)) = id else { return Err(syn::Error::new( @@ -228,10 +473,132 @@ pub(crate) fn expand(input: DeriveInput) -> syn::Result { )); }; + let mut relations: Vec = Vec::new(); + for rel in attrs.relations { + let rel = resolve(rel, &entity, &columns)?; + if relations.iter().any(|r| r.name.value() == rel.name.value()) { + return Err(syn::Error::new( + rel.name.span(), + format!( + "two relations are named `{}` (set `name = \"..\"`)", + rel.name.value() + ), + )); + } + relations.push(rel); + } + + let fields_meta = field_attrs + .iter() + .zip(&columns) + .map(|((field, fa), column)| { + let ident = field.ident.as_ref().expect("named field"); + let field_name = ident.unraw().to_string(); + let (ty, nullable) = match option_inner(&field.ty) { + Some(inner) => (type_name(inner), true), + None => (type_name(&field.ty), false), + }; + let label = fa + .label + .clone() + .unwrap_or_else(|| LitStr::new(&format!("{entity}.{field_name}"), ident.span())); + let editable = fa.editable; + let relation = relations + .iter() + .find(|r| r.kind == RelKind::BelongsTo && r.foreign_key.value() == column.value()) + .map(|r| { + let name = &r.name; + quote! { .relation(#name) } + }); + quote! { + #krate::repository::FieldMeta::new(#field_name, #column, #ty) + .nullable(#nullable) + .editable(#editable) + .label(#label) + #relation + } + }) + .collect::>(); + + let relations_meta = relations.iter().map(|r| { + let (name, target, fk) = (&r.name, &r.target, &r.foreign_key); + let kind = match r.kind { + RelKind::BelongsTo => quote! { BelongsTo { foreign_key: #fk } }, + RelKind::HasMany => quote! { HasMany { foreign_key: #fk } }, + RelKind::ManyToMany => { + let (through, local) = (&r.through, &r.local_key); + quote! { ManyToMany { through: #through, local_key: #local, foreign_key: #fk } } + } + }; + quote! { + #krate::repository::RelationMeta::new( + #name, + #krate::repository::RelationKind::#kind, + <#target as #krate::repository::Entity>::NAME, + <#target as #krate::repository::Entity>::TABLE, + ) + } + }); + + let constructors = relations.iter().map(|r| { + let (ident, name, target, fk) = (&r.ident, &r.name, &r.target, &r.foreign_key); + let vis = &input.vis; + let target_name = type_name(&syn::parse_quote!(#target)); + let (build, doc) = match (r.kind, &r.through) { + (RelKind::ManyToMany, Some(through)) => { + let local = &r.local_key; + ( + quote! { many_to_many(#name, #through, #local, #fk) }, + format!( + "The `{}` relation: the `{target_name}` rows paired with this row in `{}` \ + (`many_to_many`).", + name.value(), + through.value() + ), + ) + } + (RelKind::HasMany, _) => ( + quote! { has_many(#name, #fk) }, + format!( + "The `{}` relation: the `{target_name}` rows whose `{}` is this row's id \ + (`has_many`).", + name.value(), + fk.value() + ), + ), + _ => ( + quote! { belongs_to(#name, #fk) }, + format!( + "The `{}` relation: the `{target_name}` this row's `{}` references (`belongs_to`).", + name.value(), + fk.value() + ), + ), + }; + quote! { + #[doc = #doc] + #[allow(dead_code)] + #vis fn #ident() -> #krate::repository::Relation { + #krate::repository::Relation::#build + } + } + }); + let constructors = (!relations.is_empty()).then(|| { + quote! { + #[automatically_derived] + impl #impl_generics #name #ty_generics #where_clause { + #(#constructors)* + } + } + }); + let name_str = name.unraw().to_string(); let table = attrs .table - .unwrap_or_else(|| LitStr::new(&snake_case(name), name.span())); + .unwrap_or_else(|| LitStr::new(&entity, name.span())); + let label = attrs + .label + .unwrap_or_else(|| LitStr::new(&entity, name.span())); let version_column = option(version.as_ref().map(|(_, _, c)| c)); let tenant = option(attrs.tenant.as_ref()); let soft_delete = option(attrs.soft_delete.as_ref()); @@ -265,6 +632,9 @@ pub(crate) fn expand(input: DeriveInput) -> syn::Result { const VERSION: ::core::option::Option<&'static str> = #version_column; const SOFT_DELETE: ::core::option::Option<&'static str> = #soft_delete; const TENANT: ::core::option::Option<&'static str> = #tenant; + const LABEL: &'static str = #label; + const FIELDS: &'static [#krate::repository::FieldMeta] = &[#(#fields_meta),*]; + const RELATIONS: &'static [#krate::repository::RelationMeta] = &[#(#relations_meta),*]; fn id(&self) -> Self::Id { ::core::clone::Clone::clone(&self.#id_ident) @@ -277,6 +647,8 @@ pub(crate) fn expand(input: DeriveInput) -> syn::Result { #bump #scope } + + #constructors }) } @@ -316,11 +688,81 @@ mod tests { "const VERSION : :: core :: option :: Option < & 'static str > = :: core :: option :: Option :: Some (\"version\") ;", "const SOFT_DELETE : :: core :: option :: Option < & 'static str > = :: core :: option :: Option :: Some (\"deleted_at\") ;", "const TENANT : :: core :: option :: Option < & 'static str > = :: core :: option :: Option :: Some (\"org_id\") ;", + "const LABEL : & 'static str = \"account\" ;", "(\"title\" , < String as :: id_effect_sql :: ToSqlValue > :: to_sql_value (& self . name))", "(\"type\" , < String as :: id_effect_sql :: ToSqlValue > :: to_sql_value (& self . r#type))", "fn bump_version (& mut self) { self . version += 1 ; }", "Condition :: all () . add (active ()) . add (crate :: visible ())", ":: core :: clone :: Clone :: clone (& self . id)", + "const RELATIONS : & 'static [:: id_effect_sql :: repository :: RelationMeta] = & [] ;", + ] { + assert!(out.contains(part), "missing `{part}` in {out}"); + } + } + + #[test] + fn fields_carry_type_nullability_editability_and_labels() { + let out = expand_str( + "#[entity(label = \"crm.account\")] + struct Account { + #[entity(id)] id: i64, + #[entity(editable, label = \"crm.account.nick\")] #[from_row(rename = \"nick_name\")] + nick: Option, + #[entity(editable)] tags: Vec, + pairs: ::std::collections::HashMap, + r#type: &'static str, + }", + ) + .unwrap(); + for part in [ + "const LABEL : & 'static str = \"crm.account\" ;", + "FieldMeta :: new (\"id\" , \"id\" , \"i64\") . nullable (false) . editable (false) . label (\"account.id\")", + "FieldMeta :: new (\"nick\" , \"nick_name\" , \"std::string::String\") . nullable (true) . editable (true) . label (\"crm.account.nick\")", + "FieldMeta :: new (\"tags\" , \"tags\" , \"Vec\") . nullable (false) . editable (true)", + "FieldMeta :: new (\"pairs\" , \"pairs\" , \"::std::collections::HashMap\")", + "FieldMeta :: new (\"type\" , \"type\" , \"&'static str\")", + ] { + assert!(out.contains(part), "missing `{part}` in {out}"); + } + assert!( + !out.contains("impl Account"), + "no relations, no constructors: {out}" + ); + assert!(option_inner(&syn::parse_str("Option").unwrap()).is_none()); + assert!(option_inner(&syn::parse_str("Option").unwrap()).is_none()); + assert!(option_inner(&syn::parse_str("[u8; 4]").unwrap()).is_none()); + } + + #[test] + fn relations_generate_metadata_and_constructors() { + let out = expand_str( + "#[entity(table = \"orders\")] + #[entity(belongs_to(Customer), has_many(crate::shop::OrderLine, name = \"lines\"))] + #[entity(many_to_many(Tag, through = \"order_tags\"))] + #[entity(belongs_to(Customer, foreign_key = \"billed_to\", name = \"payer\"))] + #[entity(many_to_many(Tag, through = \"t\", local_key = \"o\", foreign_key = \"g\", name = \"labels\"))] + pub struct ShopOrder { + #[entity(id)] id: i64, + customer_id: i64, + billed_to: Option, + }", + ) + .unwrap(); + for part in [ + "RelationMeta :: new (\"customer\" , :: id_effect_sql :: repository :: RelationKind :: BelongsTo { foreign_key : \"customer_id\" } , < Customer as :: id_effect_sql :: repository :: Entity > :: NAME , < Customer as :: id_effect_sql :: repository :: Entity > :: TABLE ,)", + "RelationKind :: HasMany { foreign_key : \"shop_order_id\" }", + "< crate :: shop :: OrderLine as :: id_effect_sql :: repository :: Entity > :: NAME", + "RelationKind :: ManyToMany { through : \"order_tags\" , local_key : \"shop_order_id\" , foreign_key : \"tag_id\" }", + "RelationKind :: ManyToMany { through : \"t\" , local_key : \"o\" , foreign_key : \"g\" }", + "pub fn customer () -> :: id_effect_sql :: repository :: Relation < Self , Customer > { :: id_effect_sql :: repository :: Relation :: belongs_to (\"customer\" , \"customer_id\") }", + "pub fn lines () -> :: id_effect_sql :: repository :: Relation < Self , crate :: shop :: OrderLine > { :: id_effect_sql :: repository :: Relation :: has_many (\"lines\" , \"shop_order_id\") }", + "pub fn tags () -> :: id_effect_sql :: repository :: Relation < Self , Tag > { :: id_effect_sql :: repository :: Relation :: many_to_many (\"tags\" , \"order_tags\" , \"shop_order_id\" , \"tag_id\") }", + "the `crate::shop::OrderLine` rows whose `shop_order_id` is this row's id", + "pub fn payer ()", + "pub fn labels ()", + "FieldMeta :: new (\"customer_id\" , \"customer_id\" , \"i64\") . nullable (false) . editable (false) . label (\"shop_order.customer_id\") . relation (\"customer\")", + ". label (\"shop_order.billed_to\") . relation (\"payer\")", + "impl ShopOrder {", ] { assert!(out.contains(part), "missing `{part}` in {out}"); } @@ -340,6 +782,7 @@ mod tests { "const ID_GENERATED : bool = false ;", "const VERSION : :: core :: option :: Option < & 'static str > = :: core :: option :: Option :: None ;", "my :: sql :: SqlValue", + "my :: sql :: repository :: FieldMeta :: new (\"t\" , \"t\" , \"T\")", ] { assert!(out.contains(part), "missing `{part}` in {out}"); } @@ -374,7 +817,7 @@ mod tests { ), ( "struct S { #[entity(key)] a: i64 }", - "expected `id`, `generated` or `version`", + "expected `id`, `generated`, `version`, `editable` or `label", ), ( "struct S { #[entity(generated)] a: i64 }", @@ -400,6 +843,54 @@ mod tests { "struct S { #[entity(id)] #[from_row(rename = 1)] a: i64 }", "expected string literal", ), + ( + "struct S { #[entity(id, editable)] a: i64 }", + "never `editable`", + ), + ( + "struct S { #[entity(id)] a: i64, #[entity(version, editable)] v: i64 }", + "never `editable`", + ), + ( + "#[entity(tenant = \"org\")] struct S { #[entity(id)] a: i64, #[entity(editable)] org: i64 }", + "tenant and soft-delete columns are never `editable`", + ), + ( + "struct S { #[entity(id)] a: i64, #[entity(label = 1)] b: i64 }", + "expected string literal", + ), + ( + "#[entity(belongs_to(Customer))] struct S { #[entity(id)] a: i64 }", + "`belongs_to` foreign key `customer_id` is not a column", + ), + ( + "#[entity(has_many(foreign_key = \"x\"))] struct S { #[entity(id)] a: i64 }", + "needs the target entity first, e.g. `has_many(Line, ..)`", + ), + ( + "#[entity(has_many(Line, through = \"x\"))] struct S { #[entity(id)] a: i64 }", + "expected the target entity, then `foreign_key = \"..\"` or `name = \"..\"` in `has_many(..)`", + ), + ( + "#[entity(many_to_many(Tag, Other))] struct S { #[entity(id)] a: i64 }", + "`through = \"..\"`, `local_key", + ), + ( + "#[entity(many_to_many(Tag))] struct S { #[entity(id)] a: i64 }", + "needs its join table", + ), + ( + "#[entity(has_many(Line), has_many(Line))] struct S { #[entity(id)] a: i64 }", + "two relations are named `lines`", + ), + ( + "#[entity(has_many(Line, name = \"not ok\"))] struct S { #[entity(id)] a: i64 }", + "relation name `not ok` must be an identifier", + ), + ( + "#[entity(has_many(Line, name = 1))] struct S { #[entity(id)] a: i64 }", + "expected string literal", + ), ]; for (src, expected) in cases { let err = expand_str(src).unwrap_err(); diff --git a/crates/id_effect_sql/Cargo.toml b/crates/id_effect_sql/Cargo.toml index 2548edca7..65c8e8ec6 100644 --- a/crates/id_effect_sql/Cargo.toml +++ b/crates/id_effect_sql/Cargo.toml @@ -89,6 +89,9 @@ trybuild = "1" # MySQL 8 container for tests/conformance/mysql.rs (feature `mysql`), and the TLS servers of # tests/tls.rs. testcontainers = { version = "0.28", default-features = false } +# tests/repository_relations_pg.rs: its own PostgreSQL container. Path-only and without the `sql` +# feature, so it does not depend back on this crate. +id_effect_test = { path = "../id_effect_test" } # tests/tls.rs: a throwaway CA and server certificate (aws-lc-rs, as rustls uses). rcgen = { version = "0.14", default-features = false, features = [ "aws_lc_rs", diff --git a/crates/id_effect_sql/src/lib.rs b/crates/id_effect_sql/src/lib.rs index 7024d62e6..0dda3ad4d 100644 --- a/crates/id_effect_sql/src/lib.rs +++ b/crates/id_effect_sql/src/lib.rs @@ -142,6 +142,8 @@ pub mod repository; pub use id_effect_proc_macro::Entity; #[cfg(feature = "repository")] pub use repository::{BoundRepository, CurrentTenant, Entity, Page, Repository, SqlExecutor}; +#[cfg(feature = "repository")] +pub use repository::{EagerRepository, FieldMeta, Loaded, Relation, RelationMeta}; // MySQL driver (E3.1b, feature `mysql`). #[cfg(feature = "mysql")] diff --git a/crates/id_effect_sql/src/repository/hooks.rs b/crates/id_effect_sql/src/repository/hooks.rs new file mode 100644 index 000000000..9737e449c --- /dev/null +++ b/crates/id_effect_sql/src/repository/hooks.rs @@ -0,0 +1,2 @@ +//! Entity lifecycle hooks run inside the write's transaction (r13-e3-3-hooks, 0.13 wave 2). +//! Empty until then. diff --git a/crates/id_effect_sql/src/repository.rs b/crates/id_effect_sql/src/repository/mod.rs similarity index 90% rename from crates/id_effect_sql/src/repository.rs rename to crates/id_effect_sql/src/repository/mod.rs index cb7102598..01f2b2997 100644 --- a/crates/id_effect_sql/src/repository.rs +++ b/crates/id_effect_sql/src/repository/mod.rs @@ -23,6 +23,17 @@ //! Into`, repeatable). A tenant-scoped entity without a tenant fails closed //! ([`SqlError::Unsupported`]). [`Repository::with_deleted`] lifts the soft-delete filter only; //! [`Repository::unscoped`] lifts every filter (the explicit escape hatch). +//! - **Relations and eager loading** (data.orm #3). `#[entity(belongs_to(..))]`, +//! `#[entity(has_many(..))]` and `#[entity(many_to_many(.., through = ".."))]` declare +//! [`Relation`]s; [`Repository::with`] loads them with the rows, one `IN (…)` query per relation +//! per level (no N+1), nested at most [`MAX_EAGER_DEPTH`] levels, into [`Loaded`] values. The +//! children are read the way the parent repository reads: with the child entity's tenant, +//! soft-delete and custom filters, so a relation never returns another tenant's row. See +//! [`Relation`]. +//! - **Field metadata**. [`Entity::FIELDS`] ([`FieldMeta`]: column, type, nullability, +//! `#[entity(editable)]`, `#[entity(label = "..")]`, relation) and [`Entity::RELATIONS`] +//! ([`RelationMeta`]) describe the entity to generic tools such as an admin; both default to +//! empty, so a hand-written `impl Entity` needs neither. //! //! Dialects: PostgreSQL and SQLite insert with `RETURNING`; MySQL inserts and then reads the row //! back by id, so a `generated` id is [`SqlError::Unsupported`] there. @@ -76,6 +87,16 @@ use crate::row::SqlRow; use crate::transaction::{SqlTransaction, SqlTransactionService}; use crate::value::{SqlValue, ToSqlValue}; +// Keyset pagination (r13-e3-3-pagination) and lifecycle hooks (r13-e3-3-hooks) land here. +mod hooks; +mod page; +pub mod relations; + +pub use relations::{ + BoundEagerRepository, EagerRepository, FieldMeta, Loaded, MAX_EAGER_DEPTH, Relation, + RelationKind, RelationMeta, +}; + /// A struct mapped to a table: what `#[derive(Entity)]` generates (see the [module docs](self)). /// /// The constants are the mapping; [`values`](Self::values) are the column values to write. @@ -100,6 +121,14 @@ pub trait Entity: FromRow + Send + 'static { const SOFT_DELETE: Option<&'static str> = None; /// The tenant (or owner) column every statement is scoped to, if any. It need not be a field. const TENANT: Option<&'static str> = None; + /// The entity's label key (`#[entity(label = "..")]`; the derive defaults to the type name + /// in snake case), e.g. a translation key for an admin. Defaults to [`NAME`](Self::NAME). + const LABEL: &'static str = Self::NAME; + /// What each field is ([`FieldMeta`]), in field order. The derive fills it; the default is + /// empty (no metadata), so a hand-written impl compiles without it. + const FIELDS: &'static [FieldMeta] = &[]; + /// The declared relations ([`RelationMeta`]). The derive fills it; the default is empty. + const RELATIONS: &'static [RelationMeta] = &[]; /// The primary key. fn id(&self) -> Self::Id; @@ -381,20 +410,44 @@ impl Repository { /// The global filters, without the soft-delete one when `soft_delete` is false. fn scope(&self, soft_delete: bool) -> Result { + self.scope_in(None, soft_delete) + } + + /// [`scope`](Self::scope) with the tenant and soft-delete columns qualified by `table` (for a + /// join). Custom scopes are added as they are. + fn scope_in( + &self, + table: Option<&'static str>, + soft_delete: bool, + ) -> Result { let tenant = self.tenant_value()?; let mut cond = Condition::all(); if self.filters == Filters::None { return Ok(cond); } + let column = |col: &'static str| match table { + Some(table) => Expr::col((table, col)), + None => Expr::col(col), + }; if let (Some(col), Some(v)) = (T::TENANT, tenant) { - cond = cond.add(Expr::col(col).eq(Value::from(v))); + cond = cond.add(column(col).eq(Value::from(v))); } if let (Some(col), true, Filters::All) = (T::SOFT_DELETE, soft_delete, self.filters) { - cond = cond.add(Expr::col(col).is_null()); + cond = cond.add(column(col).is_null()); } Ok(and(cond, T::scope())) } + /// The same dialect, tenant and filters for the entity `C` (how relations read children). + fn cast(&self) -> Repository { + Repository { + dialect: self.dialect, + tenant: self.tenant.clone(), + filters: self.filters, + entity: PhantomData, + } + } + fn id_is(id: &T::Id) -> Condition { Condition::all().add(Expr::col(T::ID).eq(Value::from(id.to_sql_value()))) } diff --git a/crates/id_effect_sql/src/repository/page.rs b/crates/id_effect_sql/src/repository/page.rs new file mode 100644 index 000000000..555a6ef94 --- /dev/null +++ b/crates/id_effect_sql/src/repository/page.rs @@ -0,0 +1 @@ +//! Keyset pagination over a repository (r13-e3-3-pagination, 0.13 wave 2). Empty until then. diff --git a/crates/id_effect_sql/src/repository/relations.rs b/crates/id_effect_sql/src/repository/relations.rs new file mode 100644 index 000000000..d625c7749 --- /dev/null +++ b/crates/id_effect_sql/src/repository/relations.rs @@ -0,0 +1,1184 @@ +//! Relations between entities, eager loading ([`Repository::with`]) and the field and relation +//! metadata of [`Entity`] ([`FieldMeta`], [`RelationMeta`]). +//! +//! A [`Relation`] leads from a parent entity `P` to child entities `C`: +//! +//! | Kind | Declared on `P` as | Children are the `C` rows whose … | +//! |---|---|---| +//! | [`belongs_to`](Relation::belongs_to) (N:1) | `belongs_to(C, foreign_key = "c_id")` | primary key is `P`'s `c_id` | +//! | [`has_many`](Relation::has_many) (1:N) | `has_many(C, foreign_key = "p_id")` | `p_id` is `P`'s primary key | +//! | [`many_to_many`](Relation::many_to_many) (N:M) | `many_to_many(C, through = "p_c")` | primary key is paired with `P`'s in the join table `p_c` | +//! +//! [`Repository::with`] loads the children of every parent row in **one `IN (…)` query per +//! relation per level** (the keys of all parents at once, never one query per row), then the +//! nested relations of those children ([`Relation::with`]) the same way, at most +//! [`MAX_EAGER_DEPTH`] levels deep. Keys are deduplicated and sent in chunks of 1 000 so a +//! statement stays within every driver's parameter limit. +//! +//! **Secure by default** (ADR 0013 R1): children are read the way the parent repository reads +//! (same dialect, tenant and filter mode), so the child entity's own tenant, soft-delete and +//! custom filters are AND-ed into the relation query, inside the join for `many_to_many`. A +//! foreign key or join-table row pointing at another tenant's row loads nothing; a tenant-scoped +//! child under a repository without a tenant fails closed ([`SqlError::Unsupported`]). +//! [`Repository::unscoped`] lifts the filters of the children too. + +use std::any::Any; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fmt; +use std::marker::PhantomData; +use std::ops::Deref; +use std::sync::Arc; + +use id_effect::{Effect, HList, Selector, succeed}; +use sea_query::{Condition, Expr, ExprTrait, IntoCondition, Order, Query, Value}; + +use super::{BoundRepository, Entity, Page, Repository, Run, SqlExecutor, Stmt, and, rows}; +use crate::client::SqlClientService; +use crate::error::SqlError; +use crate::from_row::{FromRow, from_rows}; +use crate::row::SqlRow; +use crate::value::{SqlValue, ToSqlValue}; + +/// How many relation levels [`Repository::with`] loads at most (`a → b → c`); a deeper chain +/// fails with [`SqlError::Unsupported`] before any query runs. +pub const MAX_EAGER_DEPTH: usize = 3; + +/// The most keys one relation query carries (portable across driver parameter limits). +const IN_CHUNK: usize = 1000; + +/// The alias of the owner key every relation query selects next to the child's columns. +const KEY: &str = "__id_effect_key"; + +// ---- metadata ---- + +/// What one field of an entity is: [`Entity::FIELDS`], generated by `#[derive(Entity)]` for +/// generic tools (an admin's lists and forms, exports). +/// +/// Build one with [`FieldMeta::new`] and the `const` setters (a hand-written impl can list its +/// fields in a `const`). +/// +/// ```rust +/// use id_effect_sql::repository::FieldMeta; +/// +/// const NAME: FieldMeta = FieldMeta::new("name", "display_name", "String") +/// .editable(true) +/// .label("account.name"); +/// assert_eq!((NAME.column, NAME.nullable, NAME.editable), ("display_name", false, true)); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub struct FieldMeta { + /// The Rust field name. + pub name: &'static str, + /// The column (the `FromRow` column: `#[from_row(rename)]`, else `#[schema("..")]`, else the + /// field name). + pub column: &'static str, + /// The type its `Schema` / `FromSqlValue` decodes, as written in the struct without the outer + /// `Option` (`"i64"`, `"String"`, `"jiff::Timestamp"`). + pub schema_type: &'static str, + /// Whether the field is an `Option` (the column admits NULL). + pub nullable: bool, + /// Whether a generic form may bind it (`#[entity(editable)]`). Off by default: only declared + /// fields are ever written from user input (no mass assignment). + pub editable: bool, + /// The label key (`#[entity(label = "..")]`, by default `"."` in snake case), + /// e.g. a translation key. + pub label: &'static str, + /// The [`belongs_to`](Relation::belongs_to) relation this field is the foreign key of: its + /// name in [`Entity::RELATIONS`], whose [`RelationMeta::target`] is the referenced entity. + pub relation: Option<&'static str>, +} + +impl FieldMeta { + /// The field `name` stored in `column`, of type `schema_type`: not nullable, not editable, + /// labelled `name`, no relation. + pub const fn new(name: &'static str, column: &'static str, schema_type: &'static str) -> Self { + Self { + name, + column, + schema_type, + nullable: false, + editable: false, + label: name, + relation: None, + } + } + + /// Whether the field admits NULL. + #[must_use] + pub const fn nullable(mut self, nullable: bool) -> Self { + self.nullable = nullable; + self + } + + /// Whether a generic form may bind the field. + #[must_use] + pub const fn editable(mut self, editable: bool) -> Self { + self.editable = editable; + self + } + + /// The label key. + #[must_use] + pub const fn label(mut self, label: &'static str) -> Self { + self.label = label; + self + } + + /// The `belongs_to` relation the field is the foreign key of. + #[must_use] + pub const fn relation(mut self, relation: &'static str) -> Self { + self.relation = Some(relation); + self + } +} + +/// The shape of a [`Relation`]: which keys join the parent `P` to the child `C`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum RelationKind { + /// N:1 — `P.foreign_key` holds the child's primary key. + BelongsTo { + /// The column of `P` referencing `C`'s primary key. + foreign_key: &'static str, + }, + /// 1:N — `C.foreign_key` holds the parent's primary key. + HasMany { + /// The column of `C` referencing `P`'s primary key. + foreign_key: &'static str, + }, + /// N:M — rows of the join table `through` pair `local_key` (`P`'s primary key) with + /// `foreign_key` (`C`'s primary key). + ManyToMany { + /// The join table. + through: &'static str, + /// The join-table column referencing `P`'s primary key. + local_key: &'static str, + /// The join-table column referencing `C`'s primary key. + foreign_key: &'static str, + }, +} + +/// One declared relation of an entity: [`Entity::RELATIONS`] (what [`Relation::meta`] returns). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub struct RelationMeta { + /// The relation's name (the generated `Parent::name()` constructor). + pub name: &'static str, + /// Its keys. + pub kind: RelationKind, + /// The child entity's [`Entity::NAME`]. + pub target: &'static str, + /// The child entity's [`Entity::TABLE`]. + pub target_table: &'static str, +} + +impl RelationMeta { + /// The relation `name` of `kind` to the entity `target` stored in `target_table`. + pub const fn new( + name: &'static str, + kind: RelationKind, + target: &'static str, + target_table: &'static str, + ) -> Self { + Self { + name, + kind, + target, + target_table, + } + } +} + +// ---- relations ---- + +/// A relation from the entity `P` to the entity `C`, to eager-load with [`Repository::with`] +/// and read back with [`Loaded::get`] (see the [module docs](self)). +/// +/// `#[derive(Entity)]` generates one constructor per declared relation (`Order::lines()`); +/// [`belongs_to`](Self::belongs_to), [`has_many`](Self::has_many) and +/// [`many_to_many`](Self::many_to_many) build one by hand. [`with`](Self::with) nests the +/// relations of `C`. +/// +/// ```rust +/// use id_effect_sql::repository::{Relation, RelationKind}; +/// use id_effect_sql::{Entity, FromRow}; +/// +/// #[derive(Clone, Debug, FromRow, Entity)] +/// #[entity(table = "orders", has_many(Line, foreign_key = "order_id", name = "lines"))] +/// struct Order { +/// #[entity(id)] +/// id: i64, +/// } +/// +/// #[derive(Clone, Debug, FromRow, Entity)] +/// #[entity(table = "lines")] +/// struct Line { +/// #[entity(id)] +/// id: i64, +/// order_id: i64, +/// } +/// +/// let lines: Relation = Order::lines(); +/// assert_eq!(lines.kind(), RelationKind::HasMany { foreign_key: "order_id" }); +/// assert_eq!(Order::RELATIONS, [lines.meta()]); +/// ``` +pub struct Relation { + name: &'static str, + kind: RelationKind, + nested: Vec>>, + entities: PhantomData (P, C)>, +} + +impl Clone for Relation { + fn clone(&self) -> Self { + Self { + name: self.name, + kind: self.kind, + nested: self.nested.clone(), + entities: PhantomData, + } + } +} + +impl fmt::Debug for Relation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Relation") + .field("name", &self.name) + .field("kind", &self.kind) + .field("nested", &self.relations()) + .finish() + } +} + +impl Relation { + fn new(name: &'static str, kind: RelationKind) -> Self { + Self { + name, + kind, + nested: Vec::new(), + entities: PhantomData, + } + } + + /// N:1: the `C` whose primary key is `P`'s `foreign_key` column (a field of `P`; NULL loads + /// nothing). + pub fn belongs_to(name: &'static str, foreign_key: &'static str) -> Self { + Self::new(name, RelationKind::BelongsTo { foreign_key }) + } + + /// 1:N: the `C`s whose `foreign_key` column holds `P`'s primary key. + pub fn has_many(name: &'static str, foreign_key: &'static str) -> Self { + Self::new(name, RelationKind::HasMany { foreign_key }) + } + + /// N:M: the `C`s paired with `P` in the join table `through` (`local_key` references `P`'s + /// primary key, `foreign_key` `C`'s). + pub fn many_to_many( + name: &'static str, + through: &'static str, + local_key: &'static str, + foreign_key: &'static str, + ) -> Self { + Self::new( + name, + RelationKind::ManyToMany { + through, + local_key, + foreign_key, + }, + ) + } + + /// The relation's name. + #[inline] + pub fn name(&self) -> &'static str { + self.name + } + + /// The relation's keys. + #[inline] + pub fn kind(&self) -> RelationKind { + self.kind + } + + /// The names of the nested relations loaded with the children. + pub fn relations(&self) -> Vec<&'static str> { + self.nested.iter().map(|l| l.name()).collect() + } + + /// The levels this relation loads: 1, plus its deepest nested relation. + pub fn depth(&self) -> usize { + 1 + max_depth(&self.nested) + } +} + +impl Relation { + /// This relation, also loading `rel` on each child (`Order::lines().with(Line::product())`). + /// A nested relation of the same name replaces the earlier one. + #[must_use] + pub fn with(mut self, rel: Relation) -> Self { + add_loader(&mut self.nested, rel); + self + } + + /// The relation as metadata (an [`Entity::RELATIONS`] entry). + pub fn meta(&self) -> RelationMeta { + RelationMeta::new(self.name, self.kind, C::NAME, C::TABLE) + } +} + +/// A relation loader for the parent type `P`, type-erased over the child type. +trait Loader

: Send + Sync { + fn name(&self) -> &'static str; + fn depth(&self) -> usize; + /// Whether the children (and theirs) can be scoped by `repo`'s tenant and filters: checked + /// before the parent query, so a failing relation reads nothing. + fn check(&self, repo: &Repository

) -> Result<(), SqlError>; + /// Load the relation for `parents` (read through `repo`'s scoping) and attach it. + fn load( + &self, + ex: Arc, + repo: &Repository

, + parents: Vec>, + ) -> Effect>, SqlError>; +} + +fn max_depth

(loaders: &[Arc>]) -> usize { + loaders.iter().map(|l| l.depth()).max().unwrap_or(0) +} + +fn add_loader( + loaders: &mut Vec>>, + rel: Relation, +) { + loaders.retain(|l| l.name() != rel.name); + loaders.push(Arc::new(rel)); +} + +/// A key value, comparable across the integer widths and text encodings drivers return. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct Key(String); + +impl Key { + fn of(value: &SqlValue) -> Self { + Self(value.render()) + } +} + +impl Relation { + /// The key of `parent` this relation matches children on (`None`: a NULL foreign key). + fn parent_key(&self, parent: &P) -> Result, SqlError> { + let RelationKind::BelongsTo { foreign_key } = self.kind else { + return Ok(Some(parent.id().to_sql_value())); + }; + match parent + .values() + .into_iter() + .find(|(col, _)| *col == foreign_key) + { + Some((_, SqlValue::Null)) => Ok(None), + Some((_, value)) => Ok(Some(value)), + None => Err(SqlError::Unsupported(format!( + "relation `{}`: entity `{}` has no column `{foreign_key}` (the belongs_to foreign key \ + must be a field)", + self.name, + P::NAME + ))), + } + } + + /// The children of the parents with keys `values`, each row with its owner key as [`KEY`]. + fn statement(&self, child: &Repository, values: &[SqlValue]) -> Result { + let keys = || values.iter().cloned().map(Value::from); + let mut stmt = Query::select(); + let key = match self.kind { + RelationKind::ManyToMany { + through, + local_key, + foreign_key, + } => { + let cond = Condition::all().add(Expr::col((through, local_key)).is_in(keys())); + stmt + .columns(C::COLUMNS.iter().map(|col| (C::TABLE, *col))) + .expr_as(Expr::col((through, local_key)), KEY) + .from(C::TABLE) + .inner_join( + through, + Expr::col((through, foreign_key)).equals((C::TABLE, C::ID)), + ) + .cond_where(and(child.scope_in(Some(C::TABLE), true)?, cond)) + .order_by((C::TABLE, C::ID), Order::Asc); + return child.render(&stmt); + } + RelationKind::HasMany { foreign_key } => foreign_key, + RelationKind::BelongsTo { .. } => C::ID, + }; + stmt + .columns(C::COLUMNS.iter().copied()) + .expr_as(Expr::col(key), KEY) + .from(C::TABLE); + child.filter( + &mut stmt, + Condition::all().add(Expr::col(key).is_in(keys())), + )?; + stmt.order_by(C::ID, Order::Asc); + child.render(&stmt) + } + + /// The parents' keys (in parent order) and the statements loading their children. + fn prepare( + &self, + child: &Repository, + parents: &[Loaded

], + ) -> Result<(Vec>, VecDeque), SqlError> { + // Fail closed on a scoping error even when there is nothing to load. + child.scope(true)?; + let mut keys = Vec::with_capacity(parents.len()); + let (mut values, mut seen) = (Vec::new(), HashSet::new()); + for parent in parents { + let key = self.parent_key(&parent.entity)?.map(|value| { + let key = Key::of(&value); + if seen.insert(key.clone()) { + values.push(value); + } + key + }); + keys.push(key); + } + let stmts = values + .chunks(IN_CHUNK) + .map(|chunk| self.statement(child, chunk)) + .collect::>()?; + Ok((keys, stmts)) + } +} + +impl Loader

for Relation { + fn name(&self) -> &'static str { + self.name + } + + fn depth(&self) -> usize { + Relation::depth(self) + } + + fn check(&self, repo: &Repository

) -> Result<(), SqlError> { + let child = repo.cast::(); + child.scope(true)?; + self.nested.iter().try_for_each(|l| l.check(&child)) + } + + fn load( + &self, + ex: Arc, + repo: &Repository

, + parents: Vec>, + ) -> Effect>, SqlError> { + let child = repo.cast::(); + let (keys, stmts) = match self.prepare(&child, &parents) { + Ok(prepared) => prepared, + Err(e) => return id_effect::fail(e), + }; + let (name, nested) = (self.name, self.nested.clone()); + query_all(Arc::clone(&ex), stmts, Vec::new()).flat_map(move |rows| { + let (owners, children): (Vec, Vec>) = match decode::(&rows) { + Ok(decoded) => decoded.into_iter().unzip(), + Err(e) => return id_effect::fail(e), + }; + load_all(ex, child, children, nested.into()) + .map(move |children| attach(name, parents, &keys, owners, children)) + }) + } +} + +/// Run `stmts` one after the other, concatenating their rows. +fn query_all( + ex: Arc, + mut stmts: VecDeque, + mut acc: Vec, +) -> Effect, SqlError> { + let Some((sql, params)) = stmts.pop_front() else { + return succeed(acc); + }; + ex.query(&sql, ¶ms).flat_map(move |rows| { + acc.extend(rows); + query_all(ex, stmts, acc) + }) +} + +/// Each row's owner key and child. +fn decode(rows: &[SqlRow]) -> Result)>, SqlError> { + rows + .iter() + .map(|row| Ok((Key::of(row.value(KEY)?), Loaded::new(C::from_row(row)?)))) + .collect() +} + +/// Load `loaders` one after the other onto `parents`. +fn load_all( + ex: Arc, + repo: Repository

, + parents: Vec>, + mut loaders: VecDeque>>, +) -> Effect>, SqlError> { + let Some(loader) = loaders.pop_front() else { + return succeed(parents); + }; + loader + .load(Arc::clone(&ex), &repo, parents) + .flat_map(move |parents| load_all(ex, repo, parents, loaders)) +} + +/// Give each parent the children whose owner key is its key (cloned only when parents share +/// them). +fn attach( + name: &'static str, + parents: Vec>, + keys: &[Option], + owners: Vec, + children: Vec>, +) -> Vec> { + let mut groups: HashMap>> = HashMap::new(); + for (owner, child) in owners.into_iter().zip(children) { + groups.entry(owner).or_default().push(child); + } + let mut uses: HashMap<&Key, usize> = HashMap::new(); + for key in keys.iter().flatten() { + *uses.entry(key).or_default() += 1; + } + parents + .into_iter() + .zip(keys) + .map(|(mut parent, key)| { + let children = match key { + None => Vec::new(), + Some(key) => match uses.get_mut(key) { + Some(left) if *left > 1 => { + *left -= 1; + groups.get(key).cloned().unwrap_or_default() + } + _ => groups.remove(key).unwrap_or_default(), + }, + }; + parent.slots.push(Slot { + name, + children: Arc::new(children), + }); + parent + }) + .collect() +} + +// ---- loaded values ---- + +/// A loaded relation: its name and the `Vec>` of one parent. +#[derive(Clone)] +struct Slot { + name: &'static str, + children: Arc, +} + +/// An entity with its eager-loaded relations ([`Repository::with`]). Derefs to the entity; +/// [`get`](Self::get) reads a relation. +/// +/// ```rust +/// use id_effect_sql::repository::{Loaded, Relation}; +/// +/// # #[derive(Clone, Debug, PartialEq)] struct Order { id: i64 } +/// # #[derive(Clone, Debug, PartialEq)] struct Line { id: i64 } +/// let order = Loaded::new(Order { id: 1 }); +/// let lines = Relation::::has_many("lines", "order_id"); +/// assert_eq!(order.id, 1); +/// assert!(order.get(&lines).is_empty()); +/// assert!(order.try_get(&lines).is_none(), "not loaded"); +/// ``` +pub struct Loaded { + entity: T, + slots: Vec, +} + +impl Loaded { + /// `entity` with no relation loaded. + pub fn new(entity: T) -> Self { + Self { + entity, + slots: Vec::new(), + } + } + + /// The entity. + #[inline] + pub fn entity(&self) -> &T { + &self.entity + } + + /// The entity, without its relations. + #[inline] + pub fn into_entity(self) -> T { + self.entity + } + + /// The children `rel` loaded (empty when it found none, or was not loaded). For a + /// `belongs_to` relation, at most one: see [`one`](Self::one). + pub fn get(&self, rel: &Relation) -> &[Loaded] { + self.try_get(rel).unwrap_or(&[]) + } + + /// The children `rel` loaded, or `None` when it was not loaded. + pub fn try_get(&self, rel: &Relation) -> Option<&[Loaded]> { + self + .slots + .iter() + .filter(|slot| slot.name == rel.name) + .find_map(|slot| slot.children.downcast_ref::>>()) + .map(Vec::as_slice) + } + + /// The first child `rel` loaded: the parent of a `belongs_to` relation. + pub fn one(&self, rel: &Relation) -> Option<&Loaded> { + self.get(rel).first() + } + + /// The names of the loaded relations. + pub fn relations(&self) -> Vec<&'static str> { + self.slots.iter().map(|slot| slot.name).collect() + } +} + +impl Deref for Loaded { + type Target = T; + + fn deref(&self) -> &T { + &self.entity + } +} + +impl Clone for Loaded { + fn clone(&self) -> Self { + Self { + entity: self.entity.clone(), + slots: self.slots.clone(), + } + } +} + +impl fmt::Debug for Loaded { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Loaded") + .field("entity", &self.entity) + .field("relations", &self.relations()) + .finish() + } +} + +// ---- eager repositories ---- + +/// A [`Repository`] that loads relations with its rows ([`Repository::with`]): the reads, each +/// returning [`Loaded`] entities. Writes stay on the [`Repository`]. +pub struct EagerRepository { + repo: Repository, + loaders: Vec>>, +} + +impl Clone for EagerRepository { + fn clone(&self) -> Self { + Self { + repo: self.repo.clone(), + loaders: self.loaders.clone(), + } + } +} + +impl fmt::Debug for EagerRepository { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EagerRepository") + .field("repository", &self.repo) + .field( + "relations", + &self.loaders.iter().map(|l| l.name()).collect::>(), + ) + .finish() + } +} + +/// An [`EagerRepository`] bound to one executor ([`EagerRepository::on`], +/// [`BoundRepository::with`]): the same reads as environment-free effects. +pub struct BoundEagerRepository { + eager: EagerRepository, + ex: Arc, +} + +impl Clone for BoundEagerRepository { + fn clone(&self) -> Self { + Self { + eager: self.eager.clone(), + ex: Arc::clone(&self.ex), + } + } +} + +impl fmt::Debug for BoundEagerRepository { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("BoundEagerRepository") + .field(&self.eager) + .finish() + } +} + +impl Repository { + /// This repository's reads, also loading `rel` for every row returned (one query per + /// relation per level; see [`Relation`]). Chain [`EagerRepository::with`] for more. + pub fn with(&self, rel: Relation) -> EagerRepository { + EagerRepository { + repo: self.clone(), + loaders: Vec::new(), + } + .with(rel) + } +} + +impl BoundRepository { + /// [`Repository::with`] on this executor. + pub fn with(&self, rel: Relation) -> BoundEagerRepository { + BoundEagerRepository { + eager: self.repo.with(rel), + ex: Arc::clone(&self.ex), + } + } +} + +impl EagerRepository { + /// Also load `rel` (a relation of the same name replaces the earlier one). + #[must_use] + pub fn with(mut self, rel: Relation) -> Self { + add_loader(&mut self.loaders, rel); + self + } + + /// The repository the rows are read with (its tenant and filters also scope the relations). + #[inline] + pub fn repository(&self) -> &Repository { + &self.repo + } + + /// The names of the relations loaded. + pub fn relations(&self) -> Vec<&'static str> { + self.loaders.iter().map(|l| l.name()).collect() + } + + /// The levels loaded (the deepest relation chain). + pub fn depth(&self) -> usize { + max_depth(&self.loaders) + } + + /// The same reads on `executor`, e.g. a transaction. + pub fn on(&self, executor: impl SqlExecutor) -> BoundEagerRepository { + BoundEagerRepository { + eager: self.clone(), + ex: Arc::new(executor), + } + } + + /// `base`, then the relations of its rows, then `finish`. + fn eager( + &self, + base: Result>, SqlError>, + finish: fn(Vec>) -> A, + ) -> Result, SqlError> { + let depth = self.depth(); + if depth > MAX_EAGER_DEPTH { + return Err(SqlError::Unsupported(format!( + "entity `{}`: eager loading nests {depth} relation levels, at most {MAX_EAGER_DEPTH} \ + are loaded", + T::NAME + ))); + } + self.loaders.iter().try_for_each(|l| l.check(&self.repo))?; + let base = base?; + let (repo, loaders) = (self.repo.clone(), self.loaders.clone()); + Ok(Box::new(move |ex| { + base(Arc::clone(&ex)).flat_map(move |rows| { + let parents = rows.into_iter().map(Loaded::new).collect(); + load_all(ex, repo, parents, loaders.into()).map(finish) + }) + })) + } + + fn find_op(&self, id: &T::Id) -> Result>>, SqlError> { + let base = self + .repo + .select(Repository::::id_is(id)) + .and_then(|mut stmt| self.repo.render(&*stmt.limit(1))) + .map(|stmt| rows(stmt, |rows| from_rows(&rows))); + self.eager(base, |found| found.into_iter().next()) + } + + fn find_by_op( + &self, + cond: Condition, + page: Option, + ) -> Result>>, SqlError> { + self.eager(self.repo.find_by_op(cond, page), |loaded| loaded) + } + + fn load_op(&self, entities: Vec) -> Result>>, SqlError> { + self.eager(Ok(Box::new(move |_| succeed(entities))), |loaded| loaded) + } +} + +impl BoundEagerRepository { + /// Also load `rel`. + #[must_use] + pub fn with(self, rel: Relation) -> Self { + Self { + eager: self.eager.with(rel), + ex: self.ex, + } + } + + /// The eager repository this is bound from. + #[inline] + pub fn eager_repository(&self) -> &EagerRepository { + &self.eager + } + + fn run(&self, op: Result, SqlError>) -> Effect { + match op { + Ok(run) => run(Arc::clone(&self.ex)), + Err(e) => id_effect::fail(e), + } + } +} + +/// The eager reads, once: [`EagerRepository`] runs them on the client in the environment, +/// [`BoundEagerRepository`] on its executor. +macro_rules! eager_operations { + ($( + $(#[$doc:meta])* + fn $name:ident($($arg:ident: $ty:ty),*) -> $ret:ty = $op:ident($($call:expr),*); + )*) => { + impl EagerRepository { + $( + $(#[$doc])* + pub fn $name(&self, $($arg: $ty),*) -> Effect<$ret, SqlError, R> + where + R: Selector + HList, + { + Repository::::in_env(self.$op($($call),*)) + } + )* + } + + impl BoundEagerRepository { + $( + $(#[$doc])* + pub fn $name(&self, $($arg: $ty),*) -> Effect<$ret, SqlError> { + self.run(self.eager.$op($($call),*)) + } + )* + } + }; +} + +eager_operations! { + /// The row with primary key `id` and its relations, or `None` (also when a filter hides it). + fn find(id: T::Id) -> Option> = find_op(&id); + /// Every row the filters let through, in primary-key order, with its relations. + fn find_all() -> Vec> = find_by_op(Condition::all(), None); + /// The rows matching `cond`, in primary-key order, with their relations. + fn find_by(cond: impl IntoCondition) -> Vec> = + find_by_op(cond.into_condition(), None); + /// One [`Page`] of the rows matching `cond`, in primary-key order, with their relations. + fn find_page(cond: impl IntoCondition, page: Page) -> Vec> = + find_by_op(cond.into_condition(), Some(page)); + /// The relations of `entities` already read (e.g. one keyset page), in their order; only the + /// relation queries run. + fn load(entities: Vec) -> Vec> = load_op(entities); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::query_builder::Dialect; + + #[derive(Clone, Debug, PartialEq)] + struct Order { + id: i64, + customer_id: Option, + } + + impl FromRow for Order { + fn from_row(row: &SqlRow) -> Result { + Ok(Self { + id: row.get("id")?, + customer_id: row.get("customer_id")?, + }) + } + } + + impl Entity for Order { + type Id = i64; + const NAME: &'static str = "Order"; + const TABLE: &'static str = "orders"; + const COLUMNS: &'static [&'static str] = &["id", "customer_id"]; + const ID: &'static str = "id"; + const TENANT: Option<&'static str> = Some("org_id"); + + fn id(&self) -> i64 { + self.id + } + + fn values(&self) -> Vec<(&'static str, SqlValue)> { + vec![ + ("id", self.id.to_sql_value()), + ("customer_id", self.customer_id.to_sql_value()), + ] + } + } + + #[derive(Clone, Debug, PartialEq)] + struct Tag { + id: i64, + } + + impl FromRow for Tag { + fn from_row(row: &SqlRow) -> Result { + Ok(Self { id: row.get("id")? }) + } + } + + impl Entity for Tag { + type Id = i64; + const NAME: &'static str = "Tag"; + const TABLE: &'static str = "tags"; + const COLUMNS: &'static [&'static str] = &["id"]; + const ID: &'static str = "id"; + const SOFT_DELETE: Option<&'static str> = Some("deleted_at"); + const TENANT: Option<&'static str> = Some("org_id"); + + fn id(&self) -> i64 { + self.id + } + + fn values(&self) -> Vec<(&'static str, SqlValue)> { + vec![("id", self.id.to_sql_value())] + } + } + + fn tags() -> Relation { + Relation::many_to_many("tags", "order_tags", "order_id", "tag_id") + } + + #[test] + fn defaults_and_metadata() { + assert_eq!(Order::LABEL, "Order"); + assert!(Order::FIELDS.is_empty() && Order::RELATIONS.is_empty()); + let meta = tags().meta(); + assert_eq!( + (meta.name, meta.target, meta.target_table), + ("tags", "Tag", "tags") + ); + let field = FieldMeta::new("customer_id", "customer_id", "i64") + .nullable(true) + .relation("customer"); + assert_eq!( + (field.label, field.nullable, field.editable, field.relation), + ("customer_id", true, false, Some("customer")) + ); + } + + #[test] + fn many_to_many_qualifies_the_child_filters_in_every_dialect() { + let repo = Repository::::new(Dialect::Postgres).with_tenant(7i64); + let (sql, params) = tags() + .statement(&repo, &[SqlValue::I64(1), SqlValue::I64(2)]) + .unwrap(); + assert_eq!( + sql, + r#"SELECT "tags"."id", "order_tags"."order_id" AS "__id_effect_key" FROM "tags" INNER JOIN "order_tags" ON "order_tags"."tag_id" = "tags"."id" WHERE "tags"."org_id" = $1 AND "tags"."deleted_at" IS NULL AND "order_tags"."order_id" IN ($2, $3) ORDER BY "tags"."id" ASC"# + ); + assert_eq!( + params, + [SqlValue::I64(7), SqlValue::I64(1), SqlValue::I64(2)] + ); + let mysql = Repository::::new(Dialect::MySql).with_tenant(7i64); + let (sql, _) = tags().statement(&mysql, &[SqlValue::I64(1)]).unwrap(); + assert!( + sql.contains("INNER JOIN `order_tags` ON `order_tags`.`tag_id` = `tags`.`id`"), + "{sql}" + ); + let sqlite = Repository::::new(Dialect::Sqlite).unscoped(); + let (sql, _) = tags().statement(&sqlite, &[SqlValue::I64(1)]).unwrap(); + assert!(sql.ends_with(r#"WHERE "order_tags"."order_id" IN (?) ORDER BY "tags"."id" ASC"#)); + } + + #[test] + fn belongs_to_needs_the_foreign_key_field() { + let rel = Relation::::belongs_to("tag", "tag_id"); + let err = rel + .parent_key(&Order { + id: 1, + customer_id: None, + }) + .unwrap_err(); + assert!( + matches!(&err, SqlError::Unsupported(m) if m.contains("has no column `tag_id`")), + "{err:?}" + ); + let customer = Relation::::belongs_to("customer", "customer_id"); + let order = |customer_id| Order { id: 1, customer_id }; + assert_eq!(customer.parent_key(&order(None)), Ok(None)); + assert_eq!( + customer.parent_key(&order(Some(4))), + Ok(Some(SqlValue::I64(4))) + ); + } + + #[test] + fn keys_are_chunked_and_deduplicated() { + let repo = Repository::::new(Dialect::Sqlite).with_tenant(1i64); + let parents: Vec<_> = (0..2_500) + .map(|i| { + Loaded::new(Order { + id: i % 2_100, + customer_id: None, + }) + }) + .collect(); + let (keys, stmts) = tags().prepare(&repo, &parents).unwrap(); + assert_eq!((keys.len(), stmts.len()), (2_500, 3)); + assert_eq!( + stmts[2].1.len(), + 1 + 100, + "the tenant, then the last 100 keys" + ); + let unscoped = Repository::::new(Dialect::Sqlite); + assert!(matches!( + tags().prepare(&unscoped, &[]), + Err(SqlError::Unsupported(m)) if m.contains("no tenant") + )); + } + + #[test] + fn attach_shares_and_moves_children() { + let rel = tags(); + let parents = vec![ + Loaded::new(Order { + id: 1, + customer_id: None, + }), + Loaded::new(Order { + id: 2, + customer_id: None, + }), + Loaded::new(Order { + id: 1, + customer_id: None, + }), + Loaded::new(Order { + id: 3, + customer_id: None, + }), + ]; + let key = |n: i64| Key::of(&SqlValue::I64(n)); + let keys = [Some(key(1)), Some(key(2)), Some(key(1)), None]; + let owners = vec![key(1), key(1), key(2)]; + let children = vec![ + Loaded::new(Tag { id: 10 }), + Loaded::new(Tag { id: 11 }), + Loaded::new(Tag { id: 12 }), + ]; + let loaded = attach("tags", parents, &keys, owners, children); + let ids = |o: &Loaded| o.get(&rel).iter().map(|t| t.id).collect::>(); + assert_eq!(ids(&loaded[0]), [10, 11]); + assert_eq!(ids(&loaded[1]), [12]); + assert_eq!(ids(&loaded[2]), [10, 11], "a shared key clones"); + assert_eq!(ids(&loaded[3]), [] as [i64; 0]); + assert_eq!(loaded[3].relations(), ["tags"]); + assert_eq!(loaded[1].one(&rel).map(|t| t.id), Some(12)); + let other = Relation::::has_many("tags", "x"); + assert!( + loaded[0].try_get(&other).is_none(), + "a name of another type" + ); + let copy = loaded[0].clone(); + assert_eq!(copy.entity(), loaded[0].entity()); + assert!(format!("{copy:?}").contains("relations: [\"tags\"]")); + assert_eq!(copy.into_entity().id, 1); + } + + #[test] + fn relations_nest_debug_and_dedupe() { + let deep = Relation::::has_many("orders", "tag_id").with(tags()); + assert_eq!((deep.depth(), deep.relations()), (2, vec!["tags"])); + let deep = deep.with(tags()); + assert_eq!(deep.relations(), ["tags"], "same name replaces"); + assert!(format!("{:?}", deep.clone()).contains("nested: [\"tags\"]")); + let eager = Repository::::new(Dialect::Postgres) + .with(deep.clone()) + .with(deep); + assert_eq!((eager.relations(), eager.depth()), (vec!["orders"], 2)); + assert!(format!("{eager:?}").contains("relations: [\"orders\"]")); + let client: Arc = Arc::new(crate::TestSqlClient::new()); + let bound = eager.on(client); + assert!(format!("{:?}", bound.clone()).starts_with("BoundEagerRepository")); + assert_eq!(bound.eager_repository().relations(), ["orders"]); + assert_eq!( + bound.eager_repository().repository().dialect(), + Dialect::Postgres + ); + } + + fn failure(eff: Effect) -> SqlError { + match eff.block_on() { + id_effect::Exit::Failure(id_effect::Cause::Fail(e)) => e, + other => panic!("expected a failure, got {other:?}"), + } + } + + #[test] + fn relation_errors_fail_the_effect() { + let test = crate::TestSqlClient::new(); + assert!(matches!( + crate::SqlClient::connect(&test).block_on(), + id_effect::Exit::Success(()) + )); + let client: Arc = Arc::new(test.clone()); + test.script_query( + r#"SELECT "id", "customer_id" FROM "orders" WHERE "org_id" = $1 ORDER BY "id" ASC"#, + vec![SqlRow::from_pairs([ + ("id", SqlValue::I64(1)), + ("customer_id", SqlValue::I64(2)), + ])], + ); + let orders = Repository::::new(Dialect::Postgres).with_tenant(1i64); + // A belongs_to foreign key that is not a column of the parent. + let bad = Relation::::belongs_to("tag", "tag_id"); + let err = failure(orders.on(Arc::clone(&client)).with(bad).find_all()); + assert!(matches!(err, SqlError::Unsupported(m) if m.contains("tag_id"))); + // A child row without its owner key. + test.script_query( + r#"SELECT "id", "id" AS "__id_effect_key" FROM "tags" WHERE "org_id" = $1 AND "deleted_at" IS NULL AND "id" IN ($2) ORDER BY "id" ASC"#, + vec![SqlRow::from_pairs([("id", SqlValue::I64(2))])], + ); + let tag = Relation::::belongs_to("tag", "customer_id"); + let err = failure( + orders + .on(Arc::clone(&client)) + .with(tags()) + .with(tag.clone()) + .find_all(), + ); + assert_eq!( + err, + SqlError::ColumnNotFound { + column: KEY.to_owned() + } + ); + // The query itself failing. + test.fail_queries("down"); + let err = failure(orders.on(client).with(tag).find_all()); + assert!(matches!(err, SqlError::QueryFailed { .. }), "{err:?}"); + } +} diff --git a/crates/id_effect_sql/tests/relations.rs b/crates/id_effect_sql/tests/relations.rs new file mode 100644 index 000000000..dca78b7af --- /dev/null +++ b/crates/id_effect_sql/tests/relations.rs @@ -0,0 +1,649 @@ +//! Relations and eager loading (feature `repository`, data.orm #3): `#[entity(belongs_to(..))]`, +//! `has_many(..)`, `many_to_many(.., through = "..")`, `Repository::with` (one `IN` query per +//! relation per level, nested at most three levels) and the field metadata of the derive. +//! +//! The suite runs on SQLite in memory (feature `sqlite`); `tests/repository_relations_pg.rs` +//! runs the same suite on PostgreSQL in a container. ADR 0013 R1: +//! `m19_eager_loaded_children_scoped_to_tenant`, `relation_join_cannot_cross_tenant`. + +#![cfg(feature = "repository")] + +use std::sync::{Arc, Mutex}; + +use id_effect::{Cause, Effect, Exit, hlist}; +use id_effect_sql::query_builder::sea_query::{Condition, Expr, ExprTrait}; +use id_effect_sql::repository::{ + FieldMeta, Loaded, MAX_EAGER_DEPTH, Relation, RelationKind, RelationMeta, +}; +use id_effect_sql::{ + Dialect, Entity, FromRow, Page, Repository, SqlClient, SqlClientEnv, SqlClientService, SqlError, + SqlExecutor, SqlRow, SqlValue, TestSqlClient, ToSqlValue, +}; + +// ---- the model: orders of customers, with lines of products of vendors, and tags ---- + +#[derive(Clone, Debug, PartialEq, FromRow, Entity)] +#[entity(table = "rel_customers", tenant = "org_id", label = "shop.customer")] +pub struct Customer { + #[entity(id)] + pub id: i64, + #[entity(editable, label = "shop.customer.name")] + pub name: String, +} + +#[derive(Clone, Debug, PartialEq, FromRow, Entity)] +#[entity(table = "rel_orders", tenant = "org_id", soft_delete = "deleted_at")] +#[entity(belongs_to(Customer), has_many(Line, foreign_key = "order_id"))] +#[entity(many_to_many(Tag, through = "rel_order_tags", local_key = "order_id"))] +pub struct Order { + #[entity(id)] + pub id: i64, + pub customer_id: Option, + #[entity(editable)] + pub title: String, +} + +#[derive(Clone, Debug, PartialEq, FromRow, Entity)] +#[entity(table = "rel_lines", tenant = "org_id", soft_delete = "deleted_at")] +#[entity(belongs_to(Product))] +pub struct Line { + #[entity(id)] + pub id: i64, + pub order_id: i64, + pub product_id: i64, + pub qty: i64, +} + +#[derive(Clone, Debug, PartialEq, FromRow, Entity)] +#[entity(table = "rel_products", tenant = "org_id", belongs_to(Vendor))] +pub struct Product { + #[entity(id)] + pub id: i64, + pub vendor_id: i64, + pub name: String, +} + +#[derive(Clone, Debug, PartialEq, FromRow, Entity)] +#[entity(table = "rel_vendors", tenant = "org_id", has_many(Product))] +pub struct Vendor { + #[entity(id)] + pub id: i64, + pub name: String, +} + +#[derive(Clone, Debug, PartialEq, FromRow, Entity)] +#[entity(table = "rel_tags", tenant = "org_id", soft_delete = "deleted_at")] +pub struct Tag { + #[entity(id)] + pub id: i64, + pub name: String, +} + +/// Every row of `rels` for one relation: ids, in order. +fn ids + 'static>(parent: &Loaded

, rel: &Relation) -> Vec { + parent.get(rel).iter().map(|c| c.id()).collect() +} + +// ---- metadata (no database) ---- + +#[test] +fn entity_fields_metadata_from_derive() { + assert_eq!(Order::LABEL, "order"); + assert_eq!( + Order::FIELDS, + [ + FieldMeta::new("id", "id", "i64").label("order.id"), + FieldMeta::new("customer_id", "customer_id", "i64") + .nullable(true) + .label("order.customer_id") + .relation("customer"), + FieldMeta::new("title", "title", "String") + .editable(true) + .label("order.title"), + ] + ); + assert_eq!(Customer::LABEL, "shop.customer"); + let name = Customer::FIELDS[1]; + assert_eq!( + (name.name, name.label, name.editable, name.nullable), + ("name", "shop.customer.name", true, false) + ); + assert_eq!( + Order::RELATIONS, + [ + Order::customer().meta(), + Order::lines().meta(), + Order::tags().meta() + ] + ); + assert_eq!( + Order::RELATIONS[0], + RelationMeta::new( + "customer", + RelationKind::BelongsTo { + foreign_key: "customer_id" + }, + "Customer", + "rel_customers" + ) + ); + assert_eq!( + Order::RELATIONS[2].kind, + RelationKind::ManyToMany { + through: "rel_order_tags", + local_key: "order_id", + foreign_key: "tag_id" + } + ); + assert_eq!( + (Vendor::RELATIONS[0].name, Vendor::RELATIONS[0].kind), + ( + "products", + RelationKind::HasMany { + foreign_key: "vendor_id" + } + ) + ); + assert_eq!(Line::FIELDS[2].relation, Some("product")); + assert_eq!(Tag::FIELDS.len(), 2); +} + +/// A hand-written entity (the 0.12 shape: no FIELDS, RELATIONS or LABEL). +#[derive(Clone, Debug, PartialEq)] +pub struct Legacy { + pub id: i64, +} + +impl FromRow for Legacy { + fn from_row(row: &SqlRow) -> Result { + Ok(Self { id: row.get("id")? }) + } +} + +impl Entity for Legacy { + type Id = i64; + const NAME: &'static str = "Legacy"; + const TABLE: &'static str = "legacy"; + const COLUMNS: &'static [&'static str] = &["id"]; + const ID: &'static str = "id"; + + fn id(&self) -> i64 { + self.id + } + + fn values(&self) -> Vec<(&'static str, SqlValue)> { + vec![("id", self.id.to_sql_value())] + } +} + +#[test] +fn manual_entity_impl_still_compiles() { + assert_eq!(Legacy::LABEL, Legacy::NAME); + assert!(Legacy::FIELDS.is_empty()); + assert!(Legacy::RELATIONS.is_empty()); + + // A relation built by hand works the same way. + let lines = Relation::::has_many("lines", "order_id"); + let test = TestSqlClient::new(); + assert!(matches!(test.connect().block_on(), Exit::Success(()))); + test.script_query( + r#"SELECT "id" FROM "legacy" ORDER BY "id" ASC"#, + vec![SqlRow::from_pairs([("id", 7i64)])], + ); + let line = |id: i64| { + SqlRow::from_pairs([ + ("id", id), + ("order_id", 7), + ("product_id", 1), + ("qty", 1), + ("__id_effect_key", 7), + ]) + }; + test.script_query( + r#"SELECT "id", "order_id", "product_id", "qty", "order_id" AS "__id_effect_key" FROM "rel_lines" WHERE "org_id" = $1 AND "deleted_at" IS NULL AND "order_id" IN ($2) ORDER BY "id" ASC"#, + vec![line(1), line(2)], + ); + let found = Repository::::new(Dialect::Postgres) + .with_tenant(1i64) + .with(lines.clone()) + .find_all::() + .provide_all(hlist![SqlClientService::new(test.clone())]) + .block_on(); + let Exit::Success(found) = found else { + panic!("{found:?}") + }; + assert_eq!(ids(&found[0], &lines), [1, 2]); + assert_eq!(test.recorded_queries().len(), 2); +} + +// ---- the live suite ---- + +/// One database under test. +pub struct Db { + pub client: Arc, + pub dialect: Dialect, +} + +/// An executor counting (and recording) the queries it runs: the N+1 probe. +#[derive(Clone)] +pub struct Probe { + inner: Arc, + queries: Arc>>, +} + +impl Probe { + pub fn new(db: &Db) -> Self { + Self { + inner: Arc::clone(&db.client), + queries: Arc::default(), + } + } + + pub fn queries(&self) -> Vec { + self.queries.lock().expect("probe").clone() + } +} + +impl SqlExecutor for Probe { + fn query(&self, sql: &str, params: &[SqlValue]) -> Effect, SqlError> { + self.queries.lock().expect("probe").push(sql.to_owned()); + SqlClient::query(&*self.inner, sql, params) + } + + fn execute(&self, sql: &str, params: &[SqlValue]) -> Effect { + SqlClient::execute(&*self.inner, sql, params) + } +} + +impl Db { + pub async fn run_plain( + &self, + eff: Effect, + ) -> Result { + match id_effect_tokio::TokioRuntime::current() + .expect("tokio") + .run(eff) + .await + { + Exit::Success(a) => Ok(a), + Exit::Failure(Cause::Fail(e)) => Err(e), + Exit::Failure(c) => panic!("unexpected cause: {c:?}"), + } + } + + pub async fn run( + &self, + eff: Effect, + ) -> Result { + let eff = eff.provide_all(hlist![SqlClientService(Arc::clone(&self.client))]); + self.run_plain(eff).await + } + + pub async fn ok(&self, eff: Effect) -> A { + self + .run_plain(eff) + .await + .unwrap_or_else(|e| panic!("statement failed: {e}")) + } + + async fn exec(&self, sql: &str) { + self + .run_plain(SqlClient::execute(&*self.client, sql, &[])) + .await + .unwrap_or_else(|e| panic!("{sql}: {e}")); + } + + /// The tables and rows every suite body reads: org 1's data plus rows crafted to point across + /// tenants (org 2) and soft-deleted rows. + pub async fn seed(&self) { + let ts = if self.dialect == Dialect::Postgres { + "TIMESTAMPTZ" + } else { + "TIMESTAMP" + }; + let tables = [ + ("rel_customers", "id BIGINT PRIMARY KEY, org_id BIGINT NOT NULL, name TEXT NOT NULL".to_owned()), + ("rel_vendors", "id BIGINT PRIMARY KEY, org_id BIGINT NOT NULL, name TEXT NOT NULL".to_owned()), + ( + "rel_products", + "id BIGINT PRIMARY KEY, org_id BIGINT NOT NULL, vendor_id BIGINT NOT NULL, name TEXT NOT NULL" + .to_owned(), + ), + ( + "rel_orders", + format!( + "id BIGINT PRIMARY KEY, org_id BIGINT NOT NULL, customer_id BIGINT, title TEXT NOT NULL, \ + deleted_at {ts}" + ), + ), + ( + "rel_lines", + format!( + "id BIGINT PRIMARY KEY, org_id BIGINT NOT NULL, order_id BIGINT NOT NULL, \ + product_id BIGINT NOT NULL, qty BIGINT NOT NULL, deleted_at {ts}" + ), + ), + ( + "rel_tags", + format!("id BIGINT PRIMARY KEY, org_id BIGINT NOT NULL, name TEXT NOT NULL, deleted_at {ts}"), + ), + ("rel_order_tags", "order_id BIGINT NOT NULL, tag_id BIGINT NOT NULL".to_owned()), + ]; + for (table, columns) in tables { + self.exec(&format!("DROP TABLE IF EXISTS {table}")).await; + self + .exec(&format!("CREATE TABLE {table} ({columns})")) + .await; + } + for sql in [ + "INSERT INTO rel_customers (id, org_id, name) VALUES (1, 1, 'ada'), (2, 1, 'bob'), \ + (3, 2, 'eve')", + "INSERT INTO rel_vendors (id, org_id, name) VALUES (1, 1, 'acme'), (2, 2, 'evil')", + "INSERT INTO rel_products (id, org_id, vendor_id, name) VALUES (1, 1, 1, 'bolt'), \ + (2, 1, 1, 'nut'), (3, 2, 2, 'spy'), (4, 1, 2, 'crafted')", + // Order 5 is org 1's but names org 2's customer. + "INSERT INTO rel_orders (id, org_id, customer_id, title) VALUES (1, 1, 1, 'first'), \ + (2, 1, 2, 'second'), (3, 1, NULL, 'third'), (4, 2, 3, 'theirs'), (5, 1, 3, 'crafted')", + // Line 4 is org 2's but points at org 1's order 1; line 5 is soft-deleted; line 7 names + // product 4, whose vendor is org 2's. + "INSERT INTO rel_lines (id, org_id, order_id, product_id, qty) VALUES (1, 1, 1, 1, 2), \ + (2, 1, 1, 2, 3), (3, 1, 2, 1, 1), (4, 2, 1, 3, 9), (5, 1, 2, 2, 7), (6, 2, 4, 3, 1), \ + (7, 1, 3, 4, 1)", + "UPDATE rel_lines SET deleted_at = CURRENT_TIMESTAMP WHERE id = 5", + "INSERT INTO rel_tags (id, org_id, name) VALUES (1, 1, 'red'), (2, 1, 'blue'), \ + (3, 2, 'secret'), (4, 1, 'gone')", + "UPDATE rel_tags SET deleted_at = CURRENT_TIMESTAMP WHERE id = 4", + // Join rows to org 2's tag 3 and the deleted tag 4. + "INSERT INTO rel_order_tags (order_id, tag_id) VALUES (1, 1), (1, 2), (2, 1), (1, 3), \ + (2, 4), (4, 3)", + ] { + self.exec(sql).await; + } + } +} + +fn orders(db: &Db, org: i64) -> Repository { + Repository::::new(db.dialect).with_tenant(org) +} + +pub async fn eager_load_issues_one_query_per_relation(db: &Db) { + db.seed().await; + let probe = Probe::new(db); + let eager = orders(db, 1) + .on(probe.clone()) + .with(Order::lines()) + .with(Order::customer()) + .with(Order::tags()); + let loaded = db.ok(eager.find_all()).await; + // One query for the orders, then one per relation, whatever the number of orders. + assert_eq!(probe.queries().len(), 1 + 3, "{:#?}", probe.queries()); + assert_eq!( + loaded.iter().map(|o| o.id).collect::>(), + [1, 2, 3, 5] + ); + let (lines, customer, tags) = (Order::lines(), Order::customer(), Order::tags()); + assert_eq!(ids(&loaded[0], &lines), [1, 2]); + assert_eq!(ids(&loaded[1], &lines), [3]); + assert_eq!(ids(&loaded[2], &lines), [7]); + assert_eq!(ids(&loaded[3], &lines), [] as [i64; 0]); + assert_eq!( + loaded[0].one(&customer).map(|c| c.name.as_str()), + Some("ada") + ); + assert_eq!( + loaded[1].one(&customer).map(|c| c.name.as_str()), + Some("bob") + ); + assert!(loaded[2].one(&customer).is_none(), "a NULL foreign key"); + assert_eq!(ids(&loaded[1], &tags), [1]); + assert_eq!(loaded[0].relations(), ["lines", "customer", "tags"]); + for sql in &probe.queries()[1..] { + assert!(sql.contains(" IN ("), "{sql}"); + } + + // The same through the environment, with a filter, a page and one row. + let in_env = orders(db, 1).with(Order::lines()); + let page = db + .run(in_env.find_page(Expr::col("id").gte(2), Page::new(1, 0))) + .await + .unwrap(); + assert_eq!( + (page.len(), page[0].id, ids(&page[0], &lines)), + (1, 2, vec![3]) + ); + let by = db + .run(in_env.find_by(Expr::col("title").eq("first"))) + .await + .unwrap(); + assert_eq!(ids(&by[0], &lines), [1, 2]); + let one = db.run(in_env.find(1)).await.unwrap().expect("order 1"); + assert_eq!(ids(&one, &lines), [1, 2]); + assert!(db.run(in_env.find(4)).await.unwrap().is_none(), "org 2's"); + + // Entities already read: only the relation query runs. + let probe = Probe::new(db); + let plain = db.run(orders(db, 1).find_all()).await.unwrap(); + let loaded = db + .ok( + orders(db, 1) + .with(Order::lines()) + .on(probe.clone()) + .load(plain), + ) + .await; + assert_eq!((loaded.len(), probe.queries().len()), (4, 1)); + + // Nothing to load: no relation query at all. + let probe = Probe::new(db); + let none = db + .ok( + orders(db, 1) + .on(probe.clone()) + .with(Order::lines()) + .find_by(Expr::col("id").eq(99)), + ) + .await; + assert!(none.is_empty()); + assert_eq!(probe.queries().len(), 1); +} + +pub async fn many_to_many_through_join_table(db: &Db) { + db.seed().await; + let tags = Order::tags(); + let loaded = db + .run(orders(db, 1).with(tags.clone()).find_all()) + .await + .unwrap(); + let names = |o: &Loaded| { + o.get(&tags) + .iter() + .map(|t| t.name.clone()) + .collect::>() + }; + assert_eq!(names(&loaded[0]), ["red", "blue"]); + assert_eq!(names(&loaded[1]), ["red"], "a tag shared by two orders"); + assert!(names(&loaded[2]).is_empty()); + // A tag reached twice is the same row. + assert_eq!( + loaded[0].get(&tags)[0].entity(), + loaded[1].get(&tags)[0].entity() + ); + // The soft-delete filter lifted: the deleted tag joins in, org 2's still does not. + let with_deleted = db + .run(orders(db, 1).with_deleted().with(tags.clone()).find(2)) + .await + .unwrap() + .expect("order 2"); + assert_eq!(ids(&with_deleted, &tags), [1, 4]); +} + +pub async fn nested_eager_load_three_levels(db: &Db) { + db.seed().await; + let lines = Order::lines().with(Line::product().with(Product::vendor())); + assert_eq!(lines.depth(), MAX_EAGER_DEPTH); + let probe = Probe::new(db); + let loaded = db + .ok( + orders(db, 1) + .on(probe.clone()) + .with(lines.clone()) + .find_all(), + ) + .await; + assert_eq!(probe.queries().len(), 1 + 3, "one query per level"); + let first = &loaded[0].get(&lines)[1]; + let product = first.one(&Line::product()).expect("product"); + assert_eq!(product.name, "nut"); + let vendor = product.one(&Product::vendor()).expect("vendor"); + assert_eq!(vendor.name, "acme"); + // Line 7's product is org 1's, but its vendor is org 2's: not loaded. + let crafted = &loaded[2].get(&lines)[0]; + let product = crafted.one(&Line::product()).expect("product 4"); + assert!(product.one(&Product::vendor()).is_none()); + + // A fourth level is refused before any query runs. + let probe = Probe::new(db); + let too_deep = + Order::lines().with(Line::product().with(Product::vendor().with(Vendor::products()))); + let err = db + .run_plain(orders(db, 1).on(probe.clone()).with(too_deep).find_all()) + .await + .unwrap_err(); + assert!( + matches!(&err, SqlError::Unsupported(m) if m.contains("at most 3")), + "{err:?}" + ); + assert!(probe.queries().is_empty()); +} + +pub async fn m19_eager_loaded_children_scoped_to_tenant(db: &Db) { + db.seed().await; + let (lines, customer) = (Order::lines(), Order::customer()); + let org1 = db + .run( + orders(db, 1) + .with(lines.clone()) + .with(customer.clone()) + .find_all(), + ) + .await + .unwrap(); + // Org 2's line 4 points at order 1; the deleted line 5 at order 2: neither loads. + assert_eq!(ids(&org1[0], &lines), [1, 2]); + assert_eq!(ids(&org1[1], &lines), [3]); + // Order 5 names org 2's customer: no customer. + assert_eq!(org1[3].id, 5); + assert!(org1[3].one(&customer).is_none()); + // Org 2 sees its own order with its own children only. + let org2 = db + .run( + orders(db, 2) + .with(lines.clone()) + .with(customer.clone()) + .find_all(), + ) + .await + .unwrap(); + assert_eq!(org2.len(), 1); + assert_eq!(ids(&org2[0], &lines), [6]); + assert_eq!(org2[0].one(&customer).map(|c| c.id), Some(3)); + // No tenant fails closed, and nothing is read. + let probe = Probe::new(db); + let err = db + .run_plain( + Repository::::new(db.dialect) + .on(probe.clone()) + .with(lines.clone()) + .find_all(), + ) + .await + .unwrap_err(); + assert!(matches!(err, SqlError::Unsupported(_)), "{err:?}"); + assert!(probe.queries().is_empty()); + // The explicit escape hatch lifts the children's filters too. + let all = db + .run( + Repository::::new(db.dialect) + .unscoped() + .with(lines.clone()) + .find(1), + ) + .await + .unwrap() + .expect("order 1"); + assert_eq!(ids(&all, &lines), [1, 2, 4]); +} + +pub async fn relation_join_cannot_cross_tenant(db: &Db) { + db.seed().await; + let tags = Order::tags(); + // Join rows pair org 1's order 1 with org 2's tag 3: the join re-applies the tag's tenant. + let order = db + .run(orders(db, 1).with(tags.clone()).find(1)) + .await + .unwrap() + .expect("order 1"); + assert_eq!(ids(&order, &tags), [1, 2]); + assert!( + order.get(&tags).iter().all(|t| t.name != "secret"), + "org 2's tag" + ); + // And its soft-delete filter: order 2's deleted tag 4 is gone. + let order = db + .run(orders(db, 1).with(tags.clone()).find(2)) + .await + .unwrap() + .expect("order 2"); + assert_eq!(ids(&order, &tags), [1]); + // From org 2's side, only its own tag. + let theirs = db + .run(orders(db, 2).with(tags.clone()).find_by(Condition::all())) + .await + .unwrap(); + assert_eq!(ids(&theirs[0], &tags), [3]); +} + +/// One `#[tokio::test]` per suite body for the backend `$make` builds (`async fn(&str) -> Option`; +/// `None` skips); `$suite` is the module the bodies live in. +#[allow(unused_macros)] +macro_rules! relations_suite { + ($make:path, $($suite:ident)::+) => { + relations_suite!(@one $make, [$($suite)::+], eager_load_issues_one_query_per_relation); + relations_suite!(@one $make, [$($suite)::+], many_to_many_through_join_table); + relations_suite!(@one $make, [$($suite)::+], nested_eager_load_three_levels); + relations_suite!(@one $make, [$($suite)::+], m19_eager_loaded_children_scoped_to_tenant); + relations_suite!(@one $make, [$($suite)::+], relation_join_cannot_cross_tenant); + }; + (@one $make:path, [$($suite:ident)::+], $name:ident) => { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn $name() { + let Some(db) = $make(stringify!($name)).await else { + return; + }; + $($suite::)+$name(&db).await; + } + }; +} + +#[cfg(feature = "sqlite")] +mod sqlite { + use std::sync::Arc; + + use id_effect_sql::Dialect; + use id_effect_sql::sqlite::{SqlitePoolConfig, SqliteSqlClient, sqlite_pool_from_config}; + + use super::Db; + + /// A fresh in-memory database. + async fn memory(_test: &str) -> Option { + let pool = sqlite_pool_from_config(SqlitePoolConfig::memory().with_max_size(4)) + .await + .expect("sqlite pool"); + Some(Db { + client: Arc::new(SqliteSqlClient::new(pool)), + dialect: Dialect::Sqlite, + }) + } + + relations_suite!(memory, super); +} diff --git a/crates/id_effect_sql/tests/repository_relations_pg.rs b/crates/id_effect_sql/tests/repository_relations_pg.rs new file mode 100644 index 000000000..caed5aaff --- /dev/null +++ b/crates/id_effect_sql/tests/repository_relations_pg.rs @@ -0,0 +1,86 @@ +//! The relations suite of `tests/relations.rs` on PostgreSQL (features `repository`, `pg`): one +//! container per test binary (`id_effect_test::Shared`, a random host port), a fresh +//! database per test, never the ambient `DATABASE_URL`. +//! +//! Without Docker the tests print `SKIP` and pass, unless `ID_EFFECT_REQUIRE_PG=1` or +//! `ID_EFFECT_REQUIRE_DOCKER=1` (CI), which turn a missing Docker into a failure. + +#![cfg(all(feature = "repository", feature = "pg"))] + +#[macro_use] +#[allow(dead_code)] +#[path = "relations.rs"] +mod relations; + +mod pg { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use id_effect::{Cause, Exit}; + use id_effect_sql::pg::{PgPoolConfig, PgSqlClient, pg_pool_from_config}; + use id_effect_sql::{Dialect, SqlClient}; + use id_effect_test::{ContainerError, Postgres, Shared, docker_available}; + + use super::relations::Db; + + static PG: Shared = Shared::new(Postgres::new); + + fn required() -> bool { + ["ID_EFFECT_REQUIRE_PG", "ID_EFFECT_REQUIRE_DOCKER"] + .iter() + .any(|var| std::env::var(var).is_ok_and(|v| v == "1")) + } + + /// A connected client on a fresh database of the shared container, or `None` (skip). + async fn live_pg(test: &str) -> Option { + if let Err(e) = docker_available() { + assert!( + !required(), + "{test}: ID_EFFECT_REQUIRE_PG=1 but PostgreSQL cannot start: {e}" + ); + eprintln!("SKIP {test}: {e} (ID_EFFECT_REQUIRE_PG=1 to require it)"); + return None; + } + let runtime = id_effect_tokio::TokioRuntime::current().expect("tokio"); + let pg = match runtime.run(PG.get::()).await { + Exit::Success(pg) => pg, + Exit::Failure(Cause::Fail(e)) => panic!("{test}: PostgreSQL container: {e}"), + Exit::Failure(c) => panic!("{test}: PostgreSQL container: {c:?}"), + }; + // One database per test: tests of one binary share the container under `cargo test`. + static NEXT: AtomicUsize = AtomicUsize::new(0); + let database = format!( + "rel_{}_{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + ); + let admin = connect(&pg.url()).await; + let create = admin.execute(&format!("CREATE DATABASE {database}"), &[]); + let created = runtime.run(create).await; + assert!( + matches!(created, Exit::Success(_)), + "{test}: CREATE DATABASE: {created:?}" + ); + let _ = runtime.run(admin.close()).await; + Some(Db { + client: Arc::new(connect(&pg.url_for(&database)).await), + dialect: Dialect::Postgres, + }) + } + + async fn connect(url: &str) -> PgSqlClient { + let config = PgPoolConfig::from_url(url.to_owned()).with_max_size(2); + let pool = pg_pool_from_config(config) + .await + .unwrap_or_else(|e| panic!("pool for {url}: {}", e.0)); + let client = PgSqlClient::new(pool); + let connected = id_effect_tokio::TokioRuntime::current() + .expect("tokio") + .run(client.connect()) + .await; + assert!(matches!(connected, Exit::Success(())), "{connected:?}"); + client + } + + relations_suite!(live_pg, super::relations); +} diff --git a/examples/book-snippets/src/part6.rs b/examples/book-snippets/src/part6.rs index 485315f94..6f7d43fe4 100644 --- a/examples/book-snippets/src/part6.rs +++ b/examples/book-snippets/src/part6.rs @@ -144,6 +144,10 @@ struct Ch2804Redis; #[doc = include_str!("../../../crates/id_effect/book/src/part6/ch28-05-storage.md")] struct Ch2805Storage; +#[cfg(doctest)] +#[doc = include_str!("../../../crates/id_effect/book/src/part6/ch28-06-relations.md")] +struct Ch2806Relations; + #[cfg(doctest)] #[doc = include_str!("../../../crates/id_effect/book/src/part6/ch29-00-api-boundaries.md")] struct Ch2900ApiBoundaries; From 6eefaa173952cfa48e96de74075ed644b1f2649b Mon Sep 17 00:00:00 2001 From: Tom Wieland Date: Wed, 21 Oct 2026 09:37:33 +0200 Subject: [PATCH 17/87] =?UTF-8?q?feat(axum):=20server-rendered=20UI=20?= =?UTF-8?q?=E2=80=94=20maud=20views,=20components,=20layouts,=20htmx,=20fo?= =?UTF-8?q?rms=20(r13-e9-5-views)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature `ui` in id_effect_axum (ADR 0015 D3; web.ui #1 #2 #6, margin #5): maud =0.27.0 Markup as a typed-route response; Component + Slots; Page + LayoutLayer / BasicLayout (full page to browsers, fragment to htmx by HX-Request, Vary: HX-Request); HxRequest, HxTarget, HxTrigger, HxRedirect (same-origin only), HxReswap, HxRetarget; htmx 2.0.11 vendored (0BSD, sha384 in assets/README.md), served by ui::htmx::asset_route() and loaded with integrity and htmx-config allowEval=false, selfRequestsOnly=true under script-src 'self'; the layout sends the masked CSRF token on every htmx request; HtmlForm / FormState bind Schema decode errors to fields with translatable message keys; raw HTML only through ui::trusted_html (grep test over the axum and admin sources). Example 095_htmx_todo (--self-test), book chapter 29.10. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Wieland --- Cargo.lock | 1 + crates/id_effect/book/src/SUMMARY.md | 1 + .../src/part6/ch29-10-server-rendered-ui.md | 308 ++++++++ crates/id_effect_axum/Cargo.toml | 12 + crates/id_effect_axum/assets/README.md | 57 ++ .../id_effect_axum/assets/htmx-2.0.11.min.js | 1 + .../id_effect_axum/examples/095_htmx_todo.rs | 347 +++++++++ crates/id_effect_axum/src/lib.rs | 2 + crates/id_effect_axum/src/ui/component.rs | 235 ++++++ crates/id_effect_axum/src/ui/form.rs | 675 ++++++++++++++++ crates/id_effect_axum/src/ui/htmx.rs | 720 ++++++++++++++++++ crates/id_effect_axum/src/ui/layout.rs | 499 ++++++++++++ crates/id_effect_axum/src/ui/mod.rs | 76 ++ crates/id_effect_axum/src/ui/trusted_html.rs | 47 ++ crates/id_effect_axum/tests/ui.rs | 478 ++++++++++++ examples/book-snippets/Cargo.toml | 1 + examples/book-snippets/src/part6.rs | 4 + treefmt.ci.toml | 2 + treefmt.toml | 2 + 19 files changed, 3468 insertions(+) create mode 100644 crates/id_effect/book/src/part6/ch29-10-server-rendered-ui.md create mode 100644 crates/id_effect_axum/assets/README.md create mode 100644 crates/id_effect_axum/assets/htmx-2.0.11.min.js create mode 100644 crates/id_effect_axum/examples/095_htmx_todo.rs create mode 100644 crates/id_effect_axum/src/ui/component.rs create mode 100644 crates/id_effect_axum/src/ui/form.rs create mode 100644 crates/id_effect_axum/src/ui/htmx.rs create mode 100644 crates/id_effect_axum/src/ui/layout.rs create mode 100644 crates/id_effect_axum/src/ui/mod.rs create mode 100644 crates/id_effect_axum/src/ui/trusted_html.rs diff --git a/Cargo.lock b/Cargo.lock index 0126ef0bd..6e64ffdbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2771,6 +2771,7 @@ dependencies = [ "id_effect_platform", "id_effect_resilience", "jsonschema", + "maud", "quinn", "rand 0.9.5", "rcgen", diff --git a/crates/id_effect/book/src/SUMMARY.md b/crates/id_effect/book/src/SUMMARY.md index 16586f362..814c66ee6 100644 --- a/crates/id_effect/book/src/SUMMARY.md +++ b/crates/id_effect/book/src/SUMMARY.md @@ -176,6 +176,7 @@ - [The HTTP/3 client](./part6/ch29-08-http3-client.md) - [Certificates that rotate themselves: reload and ACME](./part6/ch29-09-certificates-acme.md) - [Deploying HTTP/3: load balancers, direct-to-app, Kamal and Helm](./part6/ch29-07-http3-deployment.md) + - [Server-rendered UI: maud views and htmx](./part6/ch29-10-server-rendered-ui.md) - [Application host](./part6/ch30-00-application.md) - [Authorization](./part6/ch30-02-authorization.md) - [Passwords and sessions (`id_effect_auth`)](./part6/ch30-03-passwords-sessions.md) diff --git a/crates/id_effect/book/src/part6/ch29-10-server-rendered-ui.md b/crates/id_effect/book/src/part6/ch29-10-server-rendered-ui.md new file mode 100644 index 000000000..f33c9a33d --- /dev/null +++ b/crates/id_effect/book/src/part6/ch29-10-server-rendered-ui.md @@ -0,0 +1,308 @@ +# Server-rendered UI: maud views and htmx + +`id_effect_axum` renders HTML on the server with feature `ui`: [maud](https://maud.lambda.xyz/) +templates checked at compile time, components with slots, one layout for the full page and the +fragments htmx asks for, htmx helpers, and HTML forms that show `Schema` decode errors next to the +fields. There is no bundler and no npm: htmx is one vendored, hash-pinned file the app serves +itself. + +```toml +[dependencies] +id_effect_axum = { version = "0.13", features = ["ui"] } +``` + +| Need | API | +|---|---| +| HTML, escaped by default | `ui::html!` → `ui::Markup`, a response (`text/html; charset=utf-8`) | +| Reusable views with children | `ui::Component` + `ui::Slots` | +| Full page for browsers, fragment for htmx | `ui::Page` + `ui::LayoutLayer`, `ui::BasicLayout` | +| htmx headers | `HxRequest`, `HxTarget` (request); `HxTrigger`, `HxRedirect`, `HxReswap`, `HxRetarget` (response) | +| The htmx script | `ui::htmx::asset_route()`, `ui::htmx::htmx_head()` | +| Forms bound to `Schema` errors | `ui::HtmlForm`, `ui::FormState` | +| Raw HTML | `ui::trusted_html` only | + +The runnable version is +[`crates/id_effect_axum/examples/095_htmx_todo.rs`](https://github.com/Industrial/id_effect/blob/main/crates/id_effect_axum/examples/095_htmx_todo.rs): + +```bash +cargo run -p id_effect_axum --features ui --example 095_htmx_todo # http://localhost:3002 +cargo run -p id_effect_axum --features ui --example 095_htmx_todo -- --self-test +``` + +## Markup is a response + +`html!` builds a `Markup` at compile time. Every `(expression)` it interpolates is escaped, in text +and in attribute values. A `Markup` is an Axum response, so a typed route's handler returns it like +any other value. `html!` expands to `maud::…` paths, so import the re-exported crate next to it: + +```rust +use std::convert::Infallible; + +use axum::body::Body; +use axum::http::Request; +use id_effect::schema::{self, HasSchema, Schema}; +use id_effect::{Effect, succeed}; +use id_effect_axum::route::{Input, Route, Router}; +use id_effect_axum::ui::{Markup, html, maud}; +use tower::ServiceExt; + +struct Who; + +impl HasSchema for Who { + type A = String; + type I = String; + type E = (); + + fn schema() -> Schema { + schema::string() + } +} + +fn greet(_: &mut (), input: Input<(), String>) -> Effect { + succeed(html! { p { "Hello, " (input.query) } }) +} + +let app: axum::Router = Router::new() + .route(Route::get("/hello").public().query::().handler(greet)) + .with_state(()) + .into_axum(); + +# tokio::runtime::Runtime::new().unwrap().block_on(async { +let req = Request::get("/hello?who=%3Cscript%3E").body(Body::empty()).unwrap(); +let res = app.oneshot(req).await.unwrap(); +assert_eq!(res.headers()["content-type"], "text/html; charset=utf-8"); +let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap(); +assert_eq!(&body[..], b"

Hello, <script>

"); +# }); +``` + +## Components and slots + +A component is a props struct implementing `Component`. Its children arrive as `Slots`: the +default slot (`child`, in order) and named slots (`slot`). `with` pairs props and children; the +result renders inside any template, including another component's slot. + +```rust +use id_effect_axum::ui::{Component, Markup, Slots, html, maud}; + +struct Card { + title: String, +} + +impl Component for Card { + fn render(&self, slots: &Slots) -> Markup { + html! { + section.card { + h2 { (self.title) } + (slots.children()) + @if slots.has("footer") { footer { (slots.named("footer")) } } + } + } + } +} + +let card = Card { title: "Inbox".into() }.with( + Slots::new() + .child(html! { p { "one" } }) + .slot("footer", "2 unread") + .child(html! { p { "two" } }), +); +assert_eq!( + html! { (card) }.into_string(), + "

Inbox

one

two

2 unread
" +); +``` + +## Layouts and partials: page or fragment + +A handler returns a `Page`: a title and its content. Behind a `LayoutLayer`, a browser gets the +content inside the layout and htmx (an `HX-Request: true` request that is neither boosted nor a +history restore) gets the content alone. The handler does not branch, which matters for typed +routes: they never see the request headers. `Page` responses carry `Vary: HX-Request`. + +`BasicLayout` writes the document: `lang`, the title (plus an optional site name), stylesheets, +htmx and ``. The token comes from the `SecurityLayer`, so +that layer goes **outside** the `LayoutLayer` (a later `.layer` call): + +```rust +use std::convert::Infallible; + +use axum::body::Body; +use axum::http::Request; +use id_effect::{Effect, succeed}; +use id_effect_axum::route::{Input, Route, Router}; +use id_effect_axum::server::security::{CookieKey, SecurityLayer}; +use id_effect_axum::ui::{BasicLayout, LayoutLayer, Page, htmx, html, maud}; +use tower::ServiceExt; + +fn inbox(_: &mut (), _: Input) -> Effect { + succeed(Page::new("Inbox", html! { ul #messages { li { "hello" } } })) +} + +let app: axum::Router = Router::new() + .route(Route::get("/").public().handler(inbox)) + .merge(htmx::asset_route()) + .with_state(()) + .into_axum() + .layer(LayoutLayer::new(BasicLayout::new().site_name("Mail"))) + .layer(SecurityLayer::recommended(CookieKey::generate())); + +# tokio::runtime::Runtime::new().unwrap().block_on(async { +let page = app.clone().oneshot(Request::get("/").body(Body::empty()).unwrap()).await.unwrap(); +let csp = page.headers()["content-security-policy"].to_str().unwrap().to_owned(); +assert!(csp.contains("script-src 'self'")); +let page = axum::body::to_bytes(page.into_body(), usize::MAX).await.unwrap(); +let page = String::from_utf8(page.to_vec()).unwrap(); +assert!(page.starts_with("")); +assert!(page.contains("Inbox · Mail")); +assert!(page.contains("hx-headers=\"{"x-csrf-token":")); + +let htmx_request = Request::get("/").header("hx-request", "true").body(Body::empty()).unwrap(); +let fragment = app.oneshot(htmx_request).await.unwrap(); +let fragment = axum::body::to_bytes(fragment.into_body(), usize::MAX).await.unwrap(); +assert_eq!(&fragment[..], b"
  • hello
"); +# }); +``` + +A layout is any `Layout` (or a closure `Fn(&LayoutContext, &str, Markup) -> Markup`). The +`LayoutContext` holds the `HX-*` headers, the CSRF token (`hx_headers()`, `csrf_field()`) and the +CSP nonce. Plain Axum handlers can take `LayoutContext` as an extractor and call `ui::respond` +instead of installing the layer. Partials are plain `Markup` or components: return them directly +from fragment endpoints. + +## htmx headers + +`HxRequest` reads every `HX-*` request header and never rejects; `wants_fragment()` is the rule the +layout uses. The response helpers go in a tuple before the body: + +- `HxTrigger::event("todo-added")` fires client events (`and_detail` adds JSON details, + `after_swap` / `after_settle` pick the header); +- `HxRedirect::to(path)` sends the browser elsewhere **on this origin only**: absolute paths are + accepted, `https://other`, `//other`, `/\other` and `javascript:` are refused with + `ForeignRedirect`, so a `?next=` parameter cannot become an open redirect; +- `HxReswap` and `HxRetarget` change where and how htmx swaps the response. + +```rust +use axum::response::IntoResponse; +use id_effect_axum::ui::htmx::{HxRedirect, HxReswap, HxTrigger}; +use id_effect_axum::ui::{html, maud}; + +let res = ( + HxTrigger::event("saved").after_settle(), + HxReswap::OuterHtml, + html! { p { "saved" } }, +) + .into_response(); +assert_eq!(res.headers()["hx-trigger-after-settle"], "saved"); +assert_eq!(res.headers()["hx-reswap"], "outerHTML"); + +assert!(HxRedirect::to("/inbox").is_ok()); +assert!(HxRedirect::to("https://evil.example/").is_err()); +assert!(HxRedirect::to("//evil.example").is_err()); +``` + +## The htmx script + +htmx 2.0.11 is compiled into the crate (`ui::htmx::HTMX_JS`, 0BSD). `asset_route()` serves it at +`HTMX_PATH` (`/assets/htmx-2.0.11.min.js`) with a year of `immutable` caching and an `ETag`. +`htmx_head()` (used by `BasicLayout`) loads it with `integrity="sha384-…"` and sets +``: + +| Setting | Value | Why | +|---|---|---| +| `allowEval` | `false` | no `eval` (`hx-on`, `js:` values) | +| `allowScriptTags` | `false` | `"; // user input +let page = html! { h1 { (t(&l, "greeting", Args::new().with("name", name))) } }; +assert_eq!( + page.into_string(), + "

Hello, \u{2068}<script>alert(1)</script>\u{2069}!

" +); +``` + +A message renders as **text** (ADR 0013 R9): the formatted message, arguments and translation +alike, goes through maud's escaping, so neither a user-supplied argument nor a compromised +translation can inject markup; markup belongs in the template. The arguments keep the catalog's +bidi isolation (U+2068 … U+2069), which stays on unless the catalog opts out. + +## Translated validation errors (feature `schema`) + +`Schema` decoding reports each issue as a `ParseError` (a path and an English message). +`SchemaErrorCode::classify` recovers what went wrong from every message the built-in decoders, +validators and `#[derive(Schema)]` produce, maps it to a Fluent message id (`schema-min-length`, +`schema-missing-field`, `schema-email`, …) with its arguments (`$field`, `$path`, `$min`, …), and +`schema_messages()` adds the English and German catalogs shipped with the crate. +`translate_parse_errors` (or `localizer.parse_errors(&errors)`, or the effect +`localize_parse_errors` in the current locale) returns the same `ParseErrors`, translated, ready +for a form or an RFC 9457 problem body. + +```rust +use id_effect::schema::{ParseErrors, Unknown, email, min_length, refine, i64, string}; +use id_effect_i18n::{Catalog, Locale, SchemaMessages, translate_parse_errors}; + +let en = Locale::parse("en").unwrap(); +let de = Locale::parse("de").unwrap(); +let catalog = Catalog::builder(en.clone()) + .schema_messages() + // A custom refinement message can be a message id of the application's own catalog. + .embedded(en, "app/en.ftl", "age-positive = Age must be positive.") + .embedded(de.clone(), "app/de.ftl", "age-positive = Das Alter muss positiv sein.") + .use_isolating(false) + .require_complete() + .build() + .unwrap(); + +let mut issues = Vec::new(); +let name = min_length(string::<()>(), 3).decode_unknown_all(&Unknown::String("Al".into())); +issues.extend(name.unwrap_err().prefix("name").issues); +let mail = email(string::<()>()).decode_unknown_all(&Unknown::String("nope".into())); +issues.extend(mail.unwrap_err().prefix("email").issues); +let age = refine(i64::<()>(), |n| *n > 0, "age-positive").decode_unknown_all(&Unknown::I64(-1)); +issues.extend(age.unwrap_err().prefix("age").issues); + +let german = translate_parse_errors(&catalog, &de, &ParseErrors::new(issues)); +let messages: Vec<_> = german.issues.iter().map(|i| (i.path.as_str(), i.message.as_str())).collect(); +assert_eq!(messages, [ + ("name", "Muss mindestens 3 Zeichen lang sein."), + ("email", "Muss eine gültige E-Mail-Adresse sein."), + ("age", "Das Alter muss positiv sein."), +]); +``` + +A message without a translation in the chain is kept as it was, so adding a locale never turns an +error into an empty string. The shipped messages are complete in both languages (`require_complete` +passes); to reword one, embed your own copy of `locales/schema/*/schema.ftl` instead of calling +`schema_messages()`. + +The example `cargo run -p id_effect_i18n --all-features --example i18n_web` puts it together: a +negotiated, cookie-switchable sign-up page in English and German with translated validation +errors. + ## Summary | Piece | API | Notes | @@ -216,3 +401,7 @@ than echoing input: an unsupported locale is harmless for lookups (they fall bac | Startup check | `check()` → `CatalogReport` · `require_complete()` | keys a locale shows in the default language | | Locale of a request | `CurrentLocale::new(default)` · `with_locale` · `current_locale` · `translate` | fiber-local, inherited by forks | | Input | `Locale::parse` | ≤ 16 bytes, ASCII subtags; never a path | +| Negotiation | `negotiate::Negotiator` · `select(preference, cookie, header)` · `parse_accept_language` | allowlist only; header ≤ 256 bytes, ≤ 32 ranges or ignored | +| axum (`axum`) | `axum::LocaleLayer` · `Localizer` / `LocaleSource` extractors · `CurrentLocale` in `RequestCaps` | `Content-Language`, `Vary` | +| Views (`maud`) | `t(&localizer, "id", args)` → `Message: maud::Render` | escaped text, bidi isolation on | +| Validation (`schema`) | `SchemaErrorCode` · `schema_messages()` · `translate_parse_errors` / `localize_parse_errors` | en + de shipped | diff --git a/crates/id_effect_i18n/Cargo.toml b/crates/id_effect_i18n/Cargo.toml index 9270be1f9..f334736df 100644 --- a/crates/id_effect_i18n/Cargo.toml +++ b/crates/id_effect_i18n/Cargo.toml @@ -60,6 +60,18 @@ path = "examples/010_locale.rs" name = "020_catalog" path = "examples/020_catalog.rs" +[[example]] +name = "i18n_web" +path = "examples/i18n_web.rs" +required-features = ["axum", "maud", "schema"] + +# tests/web.rs and example i18n_web (r13-e9-4-i18n-web): drive an axum router in-process and +# serve it; the versions id_effect_axum already uses. +[dev-dependencies] +http-body-util = "0.1" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } +tower = { version = "0.5.3", features = ["util"] } + [package.metadata.docs.rs] all-features = true diff --git a/crates/id_effect_i18n/examples/i18n_web.rs b/crates/id_effect_i18n/examples/i18n_web.rs new file mode 100644 index 000000000..f1e10eaa9 --- /dev/null +++ b/crates/id_effect_i18n/examples/i18n_web.rs @@ -0,0 +1,237 @@ +//! i18n on the web: `Accept-Language` negotiation with a cookie override, a maud page that +//! calls `t(&localizer, "id", args)`, translated `Schema` validation errors, and the negotiated +//! `CurrentLocale` in an effect handler's environment. +//! +//! ```bash +//! cargo run -p id_effect_i18n --all-features --example i18n_web # serves :3000 +//! cargo run -p id_effect_i18n --all-features --example i18n_web -- --self-test +//! curl -H 'Accept-Language: de-AT, en;q=0.5' localhost:3000/ +//! ``` + +use std::collections::HashMap; +use std::convert::Infallible; + +use axum::extract::State; +use axum::http::{Request, StatusCode, header}; +use axum::response::Html; +use axum::routing::{get, post}; +use axum::{Form, Router}; +use http_body_util::BodyExt; +use id_effect::schema::{ParseErrors, Schema, Unknown, email, min_length, string}; +use id_effect::{DynEnv, Effect, succeed}; +use id_effect_axum::{RequestCaps, execute_with_request_caps}; +use id_effect_i18n::axum::LocaleLayer; +use id_effect_i18n::{Args, Catalog, CurrentLocale, Locale, Localizer, SchemaMessages, t}; +use maud::{DOCTYPE, Markup, html}; +use tower::ServiceExt; + +const EN: &str = "\ +title = Sign up +greeting = Welcome, { $name }! +name = Name +email = Email +submit = Create account +created = Account created for { $name }. +"; + +const DE: &str = "\ +title = Registrieren +greeting = Willkommen, { $name }! +name = Name +email = E-Mail +submit = Konto anlegen +created = Konto für { $name } angelegt. +"; + +fn catalog() -> Catalog { + let en = Locale::parse("en").expect("en"); + let de = Locale::parse("de").expect("de"); + Catalog::builder(en.clone()) + .embedded(en, "en/app.ftl", EN) + .embedded(de, "de/app.ftl", DE) + .schema_messages() + .require_complete() + .build() + .expect("the catalog is complete") +} + +/// The whole page around `body`, as an HTML response. +fn page(l: &Localizer, body: Markup) -> Html { + let markup = html! { + (DOCTYPE) + html lang=(l.locale().as_str()) { + head { meta charset="utf-8"; title { (t(l, "title", Args::new())) } } + body { (body) } + } + }; + Html(markup.into_string()) +} + +/// `GET /?name=…`: a greeting (the argument is escaped and bidi-isolated) and the form. +async fn index(l: Localizer, query: axum::extract::Query>) -> Html { + let name = query.get("name").cloned().unwrap_or_else(|| "Ada".into()); + page( + &l, + html! { + h1 { (t(&l, "greeting", Args::new().with("name", name))) } + form method="post" action="/signup" { + label { (t(&l, "name", Args::new())) input name="name"; } + label { (t(&l, "email", Args::new())) input name="email" type="email"; } + button { (t(&l, "submit", Args::new())) } + } + }, + ) +} + +fn field( + form: &HashMap, + name: &str, + schema: &Schema, + issues: &mut Vec, +) -> String { + let value = form.get(name).cloned().unwrap_or_default(); + if let Err(errors) = schema.decode_unknown_all(&Unknown::String(value.clone())) { + issues.extend(errors.prefix(name).issues); + } + value +} + +/// `POST /signup`: validates the form and answers in the request's language. +async fn signup( + l: Localizer, + Form(form): Form>, +) -> (StatusCode, Html) { + let mut issues = Vec::new(); + let name = field(&form, "name", &min_length(string(), 3), &mut issues); + field(&form, "email", &email(string()), &mut issues); + if issues.is_empty() { + let text = t(&l, "created", Args::new().with("name", name)); + return (StatusCode::CREATED, page(&l, html! { p { (text) } })); + } + let errors = l.parse_errors(&ParseErrors::new(issues)); + let body = html! { + ul.errors { + @for issue in &errors.issues { li data-field=(issue.path) { (issue.message) } } + } + }; + (StatusCode::UNPROCESSABLE_ENTITY, page(&l, body)) +} + +/// `GET /locale`: an effect handler; the layer put the negotiated `CurrentLocale` in its env. +fn whoami(env: &mut DynEnv) -> Effect { + match env.try_get::() { + Ok(current) => current.get().map(|locale: Locale| locale.to_string()), + Err(_) => succeed("none".to_owned()), + } +} + +fn app() -> Router { + let catalog = catalog(); + let base = DynEnv::new() + .with(catalog.clone()) + .with(CurrentLocale::new(catalog.default_locale().clone())); + Router::new() + .route("/", get(index)) + .route("/signup", post(signup)) + .route( + "/locale", + get(|st: State, caps: RequestCaps| execute_with_request_caps(st, caps, whoami)), + ) + .with_state(base) + .layer(LocaleLayer::new(catalog).cookie("lang")) +} + +async fn call(request: Request) -> (StatusCode, String, String) { + let response = app().oneshot(request).await.expect("infallible"); + let status = response.status(); + let language = response + .headers() + .get(header::CONTENT_LANGUAGE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_owned(); + let body = response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + ( + status, + language, + String::from_utf8_lossy(&body).into_owned(), + ) +} + +fn get_with(uri: &str, headers: &[(&str, &str)]) -> Request { + let mut request = Request::get(uri); + for (name, value) in headers { + request = request.header(*name, *value); + } + request.body(axum::body::Body::empty()).expect("request") +} + +async fn self_test() { + let (status, language, body) = call(get_with( + "/?name=%3Cscript%3E", + &[("accept-language", "fr-CH, de-AT;q=0.9, en;q=0.5")], + )) + .await; + assert_eq!((status, language.as_str()), (StatusCode::OK, "de")); + assert!( + body.contains("Willkommen, \u{2068}<script>\u{2069}!"), + "{body}" + ); + assert!(!body.contains(" & "; + let page = html! { p { (t(&l, "greeting", Args::new().with("name", hostile))) } }.into_string(); + assert!(!page.contains("', 'draft', 1, 0, 's', '2026-10-01')", + ) + .await; + let app = app(db); + for uri in ["/admin/document?q=script", "/admin/document/50"] { + let res = AdminRequest::get(uri) + .principal(editor()) + .tenant(1i64) + .send(&app) + .await; + assert!( + res.body.contains("<script>alert(1)</script>"), + "{uri}" + ); + assert!(!res.body.contains(""]); + let host = start(store, stub_site()).await; + + // The document, rendered by Leptos on the id_effect host. + let (status, headers, page) = get(&host.app, "/").await; + assert_eq!(status, StatusCode::OK, "{page}"); + assert!(header(&headers, header::CONTENT_TYPE).starts_with("text/html")); + assert!(page.starts_with(""), "{page}"); + assert!( + page.contains("Notes — id_effect + Leptos"), + "{page}" + ); + + // The data came through the RPC call made while rendering; untrusted text is escaped. + assert!(page.contains(">First note"), "{page}"); + assert!( + page.contains("<script>alert(1)</script>"), + "{page}" + ); + assert!(!page.contains(" memo").await; + let mut rita = Browser::new(&h, "rita", "acme").await; + + // The page: layout, the queue table and an empty document panel; titles escaped. + let page = rita.send(None, "/review", &[]).await; + page.assert_status(StatusCode::OK); + // Message arguments are bidi-isolated (U+2068 … U+2069). + let html = page.text().replace(['\u{2068}', '\u{2069}'], ""); + assert!(html.starts_with(""), "{html}"); + assert!(html.contains(r#"id="review-queue""#), "{html}"); + assert!(html.contains(r#"id="review-detail""#), "{html}"); + assert!(html.contains("2 documents wait for review."), "{html}"); + assert!(html.contains("<script>"), "{html}"); + assert!(!html.contains("