From 4e1bf54f0795d392e69809c997b4f7cf8b2381a9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 2 Sep 2026 11:48:01 -0400 Subject: [PATCH 01/22] Add anchoring rework progress ledger Co-Authored-By: Claude Fable 5.1 --- PROGRESS.md | 50 ++++++++------------------------------------------ 1 file changed, 8 insertions(+), 42 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 2477300..d18be55 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,51 +1,17 @@ -# Lane C5 progress +# OpenTimestamps anchoring rework ## State -- Branch: `be-2025-vintages` from `origin/main` at `5c15bfd`. -- Worktree inputs are staged under `.lane-raw/` and must remain uncommitted. -- Lane C5 is complete, validated, independently reviewed, and ready for handoff. -- The requested staged C2 report is absent, but root `LANE_C2_REPORT.md` is byte-identical - to the sibling lane's staged copy (SHA-256 `4590e0dc...50f06e7`) and is the pattern used. +In progress on branch `ots-anchor-main`, based on `origin/main`. ## Done -- Read the repository Chronicle boundary rules in `AGENTS.md`. -- Read `.lane-raw/SOURCES.md` and confirmed all five named publisher artifacts are present. -- Confirmed the worktree is otherwise clean apart from `.lane-raw/` and the shared `.venv` link. -- Verified all five staged artifact SHA-256 pins exactly. -- Mapped FPB workbook cells: 990 facts across T01/T06/T07/T11/T17/T24, with - 2022–2025 observations and 2026–2031 `source_projection` facts. -- Confirmed PDF boundary evidence: printed page 19 calls 2026 the first projection year; - annex table units appear on printed pages 45, 48, 49, 53, 58, and 65. -- Chosen Eurostat layout: two vintage-specific source-package aliases share new manifest - entries, preserving the prior package YAMLs, raw bytes, and fact outputs unchanged. -- Reproduced the Statbel curator logic: 18 NUTS1 × sex × age-band cells totaling 11,825,551. -- Added the hash-pinned FPB workbook and publication PDF plus the - `fpb-economic-outlook-2026-2031-june-2026` package alias. -- Built 990 line-specific publisher facts (99 per year): 396 observations for - 2022–2025 and 594 `source_projection` facts for 2026–2031. -- Passed FPB `validate-package` and `build-suite`: 990 facts, full cell lineage, - zero acceptance errors, and pinned 2025 cells 320578 / 77771 / 5602 million euro. -- Re-ran the Statbel 2026 curator logic on the 2025 ZIP and added the hash-pinned - raw capture plus its deterministic 18-row curated CSV. -- Passed Statbel 2025 `validate-package` and `build-suite`: 18 facts totaling - 11,825,551, 66 constraints, full lineage, and zero acceptance errors. -- Added the Eurostat `gov_10a_taxag` 2025 and `spr_exp_func` 2024 manifest - entries plus vintage-specific package aliases, without modifying either - prior artifact or prior package specification. -- Passed both new Eurostat package validations and suite builds: 12 tax facts - and 9 ESSPROS facts, full lineage, and zero acceptance errors. -- Extended Belgium and Eurostat regressions for FPB table counts/cells and - assertion boundary, vintage non-overlap, prior-output digests, Statbel pins, - and the declared 0.25% Statbel/FPB population comparison tolerance. -- Passed 43 focused tests and the full merged-bundle regression: 157,177 facts, - 148 packages, zero aggregate-key duplicates, and expected goldens throughout. -- Recorded pins, counts, boundary evidence, curator commands, validation tails, - and consumer fact families in `LANE_C5_REPORT.md`. -- Passed independent `ledger-source-fidelity` and `ledger-boundary` reviews with - no required corrections. +- Confirmed the worktree is clean and starts at `origin/main` commit `ff3efd3`. +- Read the repository agent rules and accepted the Chronicle/Microcosm boundary. ## Next -- None; ready for handoff. No push was performed. +- Inspect PR #182, then PR #183, their gate reviews, and journal/main governance. +- Reuse the proven anchoring tool and proofs while addressing all gate findings. +- Add the main-owned workflow, documentation, CI wiring, and tests. +- Run the full required verification, push the branch, and open the superseding PR. From 29fd885dae6401adfc30517f3a002c640c6cf633 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 2 Sep 2026 11:52:08 -0400 Subject: [PATCH 02/22] Record anchoring design evidence Co-Authored-By: Claude Fable 5.1 --- PROGRESS.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index d18be55..7814af9 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,16 +2,28 @@ ## State -In progress on branch `ots-anchor-main`, based on `origin/main`. +Design evidence reviewed; implementation is next. The lane currently has no DNS +access to GitHub, so the required live `main` rules API evidence remains pending. ## Done - Confirmed the worktree is clean and starts at `origin/main` commit `ff3efd3`. - Read the repository agent rules and accepted the Chronicle/Microcosm boundary. +- Read PR #182 at `545cfe56`: the anchoring tool, eight fake-client tests, + documentation, and all 15 Bitcoin-complete proof blobs for manifests 0000–0014. +- Read PR #183's workflow at `87f21f2` and the Fable+Sol gate verdict. +- Recorded the four gate defects to fix: impossible journal direct publication, + mutable journal code receiving write credentials, verification after push plus + skipped complete-proof binding checks, and stale rebased outputs. +- Read the journal branch's `scripts/receipt_pins.py`, immutable + `releases/README.md`, and `README.md`; confirmed `ots/**` is outside both + `gate_surface` and `data_surface`, while `releases/**` must not change. +- Attempted `gh api repos/PolicyEngine/chronicle/rules/branches/main`; GitHub DNS + is unavailable in this lane, so no publication-path assumption has been made. ## Next -- Inspect PR #182, then PR #183, their gate reviews, and journal/main governance. +- Obtain live `main` rules evidence and select direct-push versus bot-PR publication. - Reuse the proven anchoring tool and proofs while addressing all gate findings. - Add the main-owned workflow, documentation, CI wiring, and tests. - Run the full required verification, push the branch, and open the superseding PR. From f7bec16c6459f08465ffba5c145c99b1a28a6e46 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 2 Sep 2026 12:20:50 -0400 Subject: [PATCH 03/22] Add trusted OpenTimestamps anchoring core Co-Authored-By: Claude Fable 5.1 --- PROGRESS.md | 14 +- ots/0000-307cedbc91de43be.json.ots | Bin 0 -> 630 bytes ots/0001-916626696d034b80.json.ots | Bin 0 -> 595 bytes ots/0002-a69272175b73c83b.json.ots | Bin 0 -> 805 bytes ots/0003-cfae6e9b4524db6d.json.ots | Bin 0 -> 770 bytes ots/0004-36322993cf45b6d1.json.ots | Bin 0 -> 630 bytes ots/0005-9bcc4ff6b3fad5d2.json.ots | Bin 0 -> 700 bytes ots/0006-770683e59da14f45.json.ots | Bin 0 -> 630 bytes ots/0007-2b5ed02908832f0c.json.ots | Bin 0 -> 770 bytes ots/0008-070e797b855dce92.json.ots | Bin 0 -> 630 bytes ots/0009-995768a31dd8fa6d.json.ots | Bin 0 -> 665 bytes ots/0010-6ba8c08f34189164.json.ots | Bin 0 -> 700 bytes ots/0011-34319583df55ce83.json.ots | Bin 0 -> 735 bytes ots/0012-3a5ef7eeee484370.json.ots | Bin 0 -> 735 bytes ots/0013-d47323bbaacda2d1.json.ots | Bin 0 -> 1015 bytes ots/0014-bd12e9e3e79a5529.json.ots | Bin 0 -> 700 bytes scripts/ots_anchor.py | 545 +++++++++++++++++++++++++++++ tests/test_ots_anchor.py | 325 +++++++++++++++++ 18 files changed, 881 insertions(+), 3 deletions(-) create mode 100644 ots/0000-307cedbc91de43be.json.ots create mode 100644 ots/0001-916626696d034b80.json.ots create mode 100644 ots/0002-a69272175b73c83b.json.ots create mode 100644 ots/0003-cfae6e9b4524db6d.json.ots create mode 100644 ots/0004-36322993cf45b6d1.json.ots create mode 100644 ots/0005-9bcc4ff6b3fad5d2.json.ots create mode 100644 ots/0006-770683e59da14f45.json.ots create mode 100644 ots/0007-2b5ed02908832f0c.json.ots create mode 100644 ots/0008-070e797b855dce92.json.ots create mode 100644 ots/0009-995768a31dd8fa6d.json.ots create mode 100644 ots/0010-6ba8c08f34189164.json.ots create mode 100644 ots/0011-34319583df55ce83.json.ots create mode 100644 ots/0012-3a5ef7eeee484370.json.ots create mode 100644 ots/0013-d47323bbaacda2d1.json.ots create mode 100644 ots/0014-bd12e9e3e79a5529.json.ots create mode 100644 scripts/ots_anchor.py create mode 100644 tests/test_ots_anchor.py diff --git a/PROGRESS.md b/PROGRESS.md index 7814af9..16d1135 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,8 +2,8 @@ ## State -Design evidence reviewed; implementation is next. The lane currently has no DNS -access to GitHub, so the required live `main` rules API evidence remains pending. +Trusted anchoring core implemented and tested. Publication-path selection remains +pending because classic `main` protection details are not yet readable in this lane. ## Done @@ -20,10 +20,18 @@ access to GitHub, so the required live `main` rules API evidence remains pending `gate_surface` and `data_surface`, while `releases/**` must not change. - Attempted `gh api repos/PolicyEngine/chronicle/rules/branches/main`; GitHub DNS is unavailable in this lane, so no publication-path assumption has been made. +- Re-read Sol's completed #182 verdict and addressed its output-spoofing, + local-state, backup-loss, and symlink-escape findings in the trusted tool. +- Copied the 15 proof blobs for releases 0000–0014 byte-for-byte from `545cfe56`. +- Added `--manifests`, complete-proof binding re-verification, a testable + `ots/`-only change guard, and 14 hermetic fake-client tests (all passing). +- Queried GitHub through the authenticated connector: `main` is protected, + required-status-check enforcement is off, and repository plus parent ruleset + lists are empty. The integration cannot read classic protection, so this is + not yet enough evidence to choose direct push. ## Next - Obtain live `main` rules evidence and select direct-push versus bot-PR publication. -- Reuse the proven anchoring tool and proofs while addressing all gate findings. - Add the main-owned workflow, documentation, CI wiring, and tests. - Run the full required verification, push the branch, and open the superseding PR. diff --git a/ots/0000-307cedbc91de43be.json.ots b/ots/0000-307cedbc91de43be.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..aec5cfeb6b3a582901ea542e542f6b8cba031860 GIT binary patch literal 630 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(3~Ju)nRw56pSY0CfjXgN z>wjG3xBN~{OS^fzH+W~mxA+eNDp4~egfA=q?6~-R#mSG?o^t&EAYh?yC~MQU`Dt)1avy4-<(D!@YoNDRSC6Xcc>)yH^j9i!h74Fnz zn*yHPztNB3PSw+eIW;jSGdY!zV?PMIif?^h6}DgF2Ak*4M_*m80&Ty1;IhEa>0A@m z^oG7+68u=myWrxZuM_`sDLY@jeCN3T54i0I7ffH7@4vhZZo8%i%=Wa*yu4IkyuhL= SwIsDDFSDew92jAs*a84Hup@5( literal 0 HcmV?d00001 diff --git a/ots/0001-916626696d034b80.json.ots b/ots/0001-916626696d034b80.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..c38f62aa60c2d415a3894d02b719602ed54e8b32 GIT binary patch literal 595 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(CZ?%n<}!OX_yoj9@*Lk% z(Ri=SDLtD*p-4tykKfAL2_FPRZ}+_YRh~Ng(2+G7J1hb}aQy!uAi}0~ZsA|sPgZA( ze0P=?TXTG5$!haR`@k{NHCbz7+~UU!&G#Sk>iy*DQ&!3-DJdwn($`NaN!CkF%t_5l zNh|^~N)mH&5-Te8lJj%_f8bD0QfSz(RFTE;K_H}d_d#dn0>;$qyftn=w?2WJ{o`-+ z7hmS;wQ#d_wBcswC+VS>pAYnQ3EbOy`94(_dz*7y`_C1rhdn?ge^fdSras@cWb4#DS6kWZ1!8_wiOQt>!7*JgqG74eZwE1u0>7=9v!TN&bq)DIkyCTXS*&TdPE i`$1C!=7+S*yu4IkyuhL=wIsDDFSDew92jAs*a84%Yzlq= literal 0 HcmV?d00001 diff --git a/ots/0002-a69272175b73c83b.json.ots b/ots/0002-a69272175b73c83b.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..6e4747c7a5e6abce8155c48d14dc0a89d310519e GIT binary patch literal 805 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(mQ5-Wk1jr8Efo52*43|Z zmQtxdH#?rpQF$b}?fv%d(%26IvFn86S>21*@YnYxS6`pO&+-2Qhn7E|vdR;wa~vN8 z!tYO)J#d_P@3VQiAE%Uvb#Q!C@SNXZ`TVxjEFNna$qCAjxtqmq?&eN-DDmXEQBm)< z``VFKy|UJH|Bc7bvYuCYoi|HRNLhyCBTH7BNBRd2owUu0 zU5f4V8Jh1u=GFVj)2E}IQBqP+Y^ATCl%J%RRFbThoS2iEmy%edmk;!73EZ!G`9_rOOI@UED(BzQ{x&MDgV&2(Lc?Tcl-`&XF3RDvE zKIex|Usd=Y-fd~~>ufhQ8N564YNybOb@>s|p(d#naNm^hu*S^PJhuw&8$Df^ZxVAd zlT!)%N1#wHOhKq$ocX}X@^V|;4ku@`k|JKs8FlIx5MA-Kk55xA)JXj0#g!wG)cW!=s zpQ_8&pF3NaJx%oFY!}a8=bEY~NgEiKs6T^vBJBgmd6}yk2Yww2hkHU(1Llde%)GqR zN<@sLmZTQtWtLQyC+6hnCFkdU5csxF$h>Z9?=`ci3-Lxv4ujIdj2nJ8*=^Pd1XMnK z*WNv)XwU3}OYU;qd7Ai2?dAfh#C<^LhiI%bjPDg+_t5qG6va2(+^g?3vgw8gU$$sA q)LGIv?FKx6mqk9?d{*c# z%k7;5G{Qd5(R-p`|FF4YrP>F9NT<7B!&Bt9tl9nA{Qbf>PLBT{IFv$U5>DQJet_eH zg1Xt89UaVi%fD1pO+1{qXbr=h`a2+5d!eEEnMTs8HvTS$OWxnINSDtyR;F z_tdxP=r4_SwK!UTb#mJAr6PPB9|UT;gdWalNKB11@5wmqx!en=E7d-f=wx#XfZ_lA8XX7pnQMyn#FR%eR~Z z*0Py>;;|ebS+d$ZGCpuvWPUJS;q$$jq51w}UcH|@eR{eXB_##LR{HvhIho0+dPyb8 zddZ17sd*`hMSA(bFe`zFnO=TT`u`6Cwg2iopDx?4q>}5~>Obvc8PJ7GH%ry|w=#bB zF-bir*)-GS(R`*?yEjWjEjaa2apUEV%RmZ2kCuP+wwSLd)U2=SR6@R)05RKW%*pU_~Ak1Sbj9+@9FcGR!f zeAeeV7en*?$Gmz!dHVEpGfGMdimmkZ6LT_?Q}vQclJ$}kb5iqC5{vZmfxaz)`&KW% zDE}x{5`KV70(*?u&)!* zj=s0CA|Mm!%=qZp3OaSO=U#hhk+(5)+iw$9>*wLS<~6Q9(CtyFz$^xL=JVshioQ!G S+rpixqYZOrQhpMlKnDPR?;BD8 literal 0 HcmV?d00001 diff --git a/ots/0005-9bcc4ff6b3fad5d2.json.ots b/ots/0005-9bcc4ff6b3fad5d2.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..aba2a7ca3bee676396c064dd945e7c4e35905834 GIT binary patch literal 700 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(W}or@w)xl9OGhqVnqqjP z!q4iJ%|1u22+=1yQuXBj$8P^1P_|S0_aQ&lo_oJ|WsS~W_{Z`81BXpP607d}SW%7- z0-J;uzW#CS(%PQCtG%ABJUO4^gTmrXTE~J-0P4P~9?r zr`^{*K+Eq$Ji42*&BXWcgqE6BM_c4Xyz-(ur6YK+xBQxSXYD};pb~le0^v`5Ii$wUE4?wpa$>MXldPSxo-ojg1UOm0}aari1RgH3{74wfjp6EZf j3vTgEw)7=|+msL%D=Wb)Mv3Cf!=i4{PH`0y2$~wE)bv5%sB?>B8{?swSEch618eLjHxl!tQXJC=J=>kl-PRRk#GKM8RKt0zHixItoB;^Vb7GJvro@lQab3y ztibV+C9BON`vZsR79-1O*@8}n=KGI%^?vg7>1bz^loS+O>FX!uC+Q`XB$?>J!$oPW)%N+gNoY{nVCS#bE?ermn7HtRL{To)qBhN zqDhi-*(&nY@+KwtyX}7N^9*je>jjky-HB?>aLY9{V3wz4=H;bUBJ55rNiE9DEU7F{ z%*oM9&d&wfyeE#AH`J$2uO}hyVs~^@B+%wHZ~iqono9pum%HFpX^`#nU1t9L_9@j5 zs+qW2t@c^ph1<-})_8JV!+LYL&B{tJn^C-+oLB<1FtGyYlKk8c97l7E>SsxRUjq#E z^}ib93po?pbs09aXU*_CJVd( literal 0 HcmV?d00001 diff --git a/ots/0007-2b5ed02908832f0c.json.ots b/ots/0007-2b5ed02908832f0c.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..176a3b3147f441457dac020c85b42c6d2d4a4a79 GIT binary patch literal 770 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(wBs&lay0AnnBLyAZi0sJ zhc(Wnj8iX|g+DoXyKQc$^~4VXuD4XJf9uR%J$PnKxw1cg`1y|N2-(EQsTS0{e;M&l-GFk8XT+<+Wjd&(wysrQ3szwzZwR!pUI0 zMN|W*WJ#aYw0qMJ$kudSTA6k1$mbhf>sjg!yvsN_WB1;RLK{;#KC)!BdE|WHkQR8( zJ)!H@FNWs(k9qZe^7QFwXOxr_6kF-*C*>#UC6y%WB`4;j=A|SS>E#3cS_1d0UVc$J z(5Y?0OjdDz9Gien-Pl_6#`QwU8BGDv5RWyFZ*Y86ShIJ3PQ!`wN>&y3jc*M;_Iq#+=*WkX<(}npa zF()%Qm9T#VW*F^vy_u-elaZS^z{~Cp+W45W+rJS}Ngu8Id?!Gg3 zg%4-KU8t-Cb0JDtCnuI9=Hw(+RO%(?=Y9~_uqWFwvG|mFP-w$1wNo|mKr0W}F>8r9 zw_Gk}*%5i;R!h~MPiu4U?_^zh-EK{3efx$dz|fD|xpzU8?#yrbvrm<-KHb{9Qlaw7 z?g=dS_U>PwoZtGy4(_V|f4t6ZY|g#}ca^3F%vEWbd3mWoSHKcMYDsEQUS>&UIna3^ Gw*dhEqhXc+ literal 0 HcmV?d00001 diff --git a/ots/0008-070e797b855dce92.json.ots b/ots/0008-070e797b855dce92.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..1156f28f0bda689162a85d9ffe63d6bf2f4951bd GIT binary patch literal 630 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(*!e1}TVu~nVw>rAV^QX` zq?uhjds#1Z*Kpoy`dvM1M&$>Ax38bYB;23A<1W{_zIBGyp&b7|2)N`*cP+l0TD-;2 zTmEqPyhx6ZELm+HxgR*vdzO`Nt^F&((0ul4QN)#GKT; zl*A$+qa-mWC$XYZFF8LKXkhRHzJ(1*S9~UJ4Y~MAno)@3qeANO8ULNLg+&##-|tRt zUXpON_R=Gpdyke1%y{?ft@ZUIa67rDec7k3wTKmNr=|wX&a}+Dyi}kaFl$pwQj793 zODfBOmP2gj=)952;*=XK!|_3&S9cGuPFq^~9qohzx2FG`3v`4;;UC?PdU3qBbt<%X zT-tl(>9z;+v%VIYb$X-)a0Uw*a(qzWTxqqArTxYH<3Kq zJ_c<*kp_3?{(pYA)D)c!;qKJag}E~^Co?%!4<&^1fe}{%k2t;jqVx|O&o=y5YfE|h z66mmEAKO`OZG1n>BJa*)c&~Z=+oLEa$3t3I#Z{FTN+s2L0lgzS>*W7EOE#}HdS|-h zpYPw&Zxc{Ee3&>cyiBAO*9lKsxZrTddfi{UeJ5Pq?YRJ`M7pEylI1q#xE#d| zx2--3OkEv;plyt^aE&Ud16kEUUGi!2Z8(h yBzFF_`niTtsO-veg?X}YQ>$x)En}7)?uMJHtOPR^C6bd9OMr$YRse0u&jkPm;3oh8 literal 0 HcmV?d00001 diff --git a/ots/0010-6ba8c08f34189164.json.ots b/ots/0010-6ba8c08f34189164.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..00bc5b51fe0af7d3f7179c5a29e8632191b3ee16 GIT binary patch literal 700 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(vR54FH<6f_l0K)k?r~|% zzAXv|r5sfk6m+MpI3DBQS@c1`*>5hB?}A$lPb~IV>7sC>`k3-ZEK- zm9Jz!0-d^tC+SA(Lh*a5<`ee1^sw>(o%%D-H~Z55)&};*OG?oU8r9E_|7={n*XMs` zsm(EmBeA=IPCecwsPN{`ufTwPZ%abvc-Lxsn0Frs44~BF4}tN n{2p_-19P7w2pp`^PJla5SqbJqlyFQ=ECEJ!Vg)b_rkQ literal 0 HcmV?d00001 diff --git a/ots/0011-34319583df55ce83.json.ots b/ots/0011-34319583df55ce83.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..f765d6f5c813fab26a138ee0cdc150b97c13ea78 GIT binary patch literal 735 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(Obn+s-w!?4Ty%1kSoPmi z`J3*0d6+U}9=VnGhb?-g%cKti397A8OOkk>FfPCRl&`l;mgD~ij%*%HU7ZHi4ICc? zYHjKc-r?(+J_@&N^=`&d{ydy`}mr( z)oh@Wdu^wW-K;Fy|29p9Z~Bvy(s$-Ryc(GwTjYJ>@$;aQ+Ke0@S+d$Z3O;b`RY>ei z6?@IX(0u{3N}kl4QN)#GKT;l*A&ve4t-T;C|K1FG~Oa zL150S?3pJ@TUD3rxLGQ{xa1VaM}?~u+2J4OoDIoo;Ly9#ae3ujsmM&b>zDX@pGD}J z7TWp)-FN5J$t7}Un$CAvIP|zZyXEpMRhr56%gH^;liwe;QJ-c9ci)!_OBz)&v3bsDCHriNsoNx!2A>uk5wa?x>_5NTjv^}xiFmLLY za|dMe&ad*mGqK|;+$%4uWv{R2Jz@{{ik>daD~UOo$*F__`-8vR1;+=0slR(wq`a~h?$cX)T~a*E z5$K7-3nQjC>2LnQ8M^W2A)X(5I?shZ^uF?Txy63({1r8e&%#}J()>|LkIWZ#xC?c( z;V#Ti(nE1&J}?SO;8CELUz83E!Ntp>8Uq7`62Hye=3wh^Pyy)V^|`foS4~$4<=%d1 zg3Jd4+g}qdnBH9%U!}Q6+{Pnh0u#`E2~Q5qiai&2B9XCTe}98ZTv4xs?5kbb*{sWR zUEUP-?SZ>*m9c-n(YGgJaQA6yz}%OXnU|Le3^7Ii8y0|MIXU(3zPAcHFZJPCa!f+ZevsNM(ZbCf^T_e?I$F z|G;4G>iMleHz@Ei$xe+p;P~dLa2&h-(i?59{k+{A6Y3@_JH)fx-gsXJ?###+Uz>b( VRn3MwQ%@J>%*33``2{P2&In literal 0 HcmV?d00001 diff --git a/ots/0013-d47323bbaacda2d1.json.ots b/ots/0013-d47323bbaacda2d1.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..ef93c10261fe2400827ec49fc609b8d63ad0d787 GIT binary patch literal 1015 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(t`sZpUUhcS#mQawj@Y=b zUYlRoW^$$On8>?>J6S#F3%~pzFi}rDy!x4&K=SSVJ56Tk@p1hBAi((8V`IN+5vNwv zyY!>VK?WQj6-3qac1z1%(zuzrU4cH5SzW)Np&BtJh7g z*#4zAZvGIp77zm}`DwQ0wQH{a)hlU7EIF&q7pGtU*i~&M693=jh`3Ol|7MPlELm+H zMISg8WKKTCmsc#r(0uX!{4{d09aBn-*Vsnkx8!!E^oMskVP-RZHJ# zm0uaL(`eSVx~lK(*1kZ;-II0w;e7h{vE#m5{~r-N*O8X7JvMd4mURbz>iB!?kv|Sp z;wE?L$qA#`n@?wNQaJiQd*h;xkI~s(D_wbVcDbJZ{t0Btt`Y3#c-jwL>g zy;U&xe~2GvfDM%(Ip5ueS6Tgz? zkQs-JC(pLf&rESD2L^>uo1Ich)e_5-+3ANJ_9jhjei$gk#`fjSt0_{C(|#^o3pBW8 z>GN(Y*L_a&X03eJv-(Yn^|dMLobN+ZSu9tX{qDQ70H|cfVXwR4(HHOCsBGfrUXgOK zKvCU@VOD3r%7k2vN7H|~!u@$Db;Y~+0%eoo{?yZj`7<#mGdWcc#kcvuG+zQw^LqJ3 z=^r@edpIuaI~BPNXzN;rg#T>&thDBbwps0%e}Bw643R;nA4wp7$O^En^xA5}H_o~xQS0}BRk=t>MuV+_U_Ps7v3dro4mI2!Qb+hi@u@(90fPx4>JRme7C}q= literal 0 HcmV?d00001 diff --git a/ots/0014-bd12e9e3e79a5529.json.ots b/ots/0014-bd12e9e3e79a5529.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..3d6afc193337bc05d7b796db88070cd929726a87 GIT binary patch literal 700 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(_6ogx{CrlZW(J#!A-n0t zM8}N}w~DfmOR~wbEo?SIet^5c!xZ!14cs0MDfyoA7;EZEgGW z=P;i4n8NWvVX5>fgA<7<1sqPH#p1gjZ$GwoI+{}zsqELm+H#UD7{ zDLyu=UH8MCq51w}UcH|@eacE1B_##LR{Hu$CCPfpi8-lxDTzfuMoD5$PGUu+UUGi! z{|_A6DLFI#Dj!zm_^8lYn)s#V=Gq-OLd$CxeKIj={BJjjFH-GK(QgZevVGh3aeNRk z{uvZ`$dhs3`3cFtHr;D80J<<+U)nKI?CY1hb8?4OHyVGrFJ(}5wcPDp!qKe>qK0i- zfd+pIQ&^Jk)~fQ$#?~fQV@ifoC5zFl&u`A=?7Fb-?TvQ}fTrAjaHWX-us{s+{uPP^ z^W4v#^IauT%KmfVW|n$izd6eu!Tnjw>8qCVVc%D{KlOBB{!GltOitB9@ohdZo=V{H zq?ccm4)k)bfkY8MZ*4WuKlO)~oJ_CfTbh}5ZDCZ;wR=F9T)MgIVrrU>=&47%XZJ2< z>@QB3sptCcIp3$;OZF;1b6nvriFSYUuT18XB-|xB+AxNy#{7xXSJ z+0TFb$T9vnU_i0R*-AF=lABs!n$wx^rgxs2x6h#o$KOdx=e!Gf^P+MB-0td0zutLH l%Dn-%TT=sOcUop%UMetlVG*8Ml3J9PSyEXJjCN3*0|2m2IspIx literal 0 HcmV?d00001 diff --git a/scripts/ots_anchor.py b/scripts/ots_anchor.py new file mode 100644 index 0000000..237e473 --- /dev/null +++ b/scripts/ots_anchor.py @@ -0,0 +1,545 @@ +#!/usr/bin/env python3 +"""Anchor witnessed release manifests in Bitcoin via OpenTimestamps. + +Each supplied release manifest is already witnessed by two RFC 3161 +authorities and a pinned producer signature over its exact bytes. This tool +adds an operator-independent witness: an OpenTimestamps proof over those same +exact bytes, committed as ``ots/.json.ots`` in this repository. Because +manifest ``state.jsonlSha256`` covers the full journal bytes and +``previousManifestSha256`` chains every earlier manifest, a Bitcoin +attestation over one manifest bounds the existence time of the whole journal +state it commits to. + +Proofs live in this repository's top-level ``ots/`` directory, never beside +the manifests. The manifests may be supplied from a separate, credential-free +journal checkout with ``--manifests``. OpenTimestamps upgrades rewrite proof +files in place, while the journal's release history is immutable. + +Subcommands: + +- ``run``: stamp any manifest that lacks a proof, then try to upgrade pending + proofs to complete Bitcoin attestations. Idempotent; safe on a schedule. +- ``verify``: check every proof against its manifest's current bytes and + report the state stored in each local proof. Exits nonzero on a digest + mismatch or a manifest with no proof. +- ``status``: list proofs and whether each is unanchored, pending locally, or + Bitcoin-complete locally. +- ``guard``: fail if the repository has any change outside ``ots/``. + +Requires the ``ots`` CLI (PyPI ``opentimestamps-client``); stamping and +upgrading contact public calendar servers. Verification with ``--no-bitcoin`` +checks the file binding and prints manual Bitcoin block-check information. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import pathlib +import re +import shlex +import shutil +import subprocess +import sys +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DEFAULT_MANIFEST_DIR = pathlib.Path("releases/manifests") +OTS_DIR = pathlib.Path("ots") +MANIFEST_NAME_RE = re.compile(r"^(\d{4})-([0-9a-f]{16})\.json$") +SUBPROCESS_TIMEOUT = 300 + +# Full-line patterns observed from opentimestamps-client 0.7.2. Calendar URLs +# and errors are untrusted text, so substring matches are intentionally unsafe. +_MISMATCH_LINE_RE = re.compile(r"^\s*File does not match original!?\s*$", re.MULTILINE) +_VERIFY_PENDING_LINE_RE = re.compile( + r"^\s*Pending confirmation in Bitcoin blockchain\s*$", re.MULTILINE +) +_VERIFY_MANUAL_LINE_RE = re.compile( + r"^\s*To verify manually, check that Bitcoin block \d+ " + r"has merkleroot [0-9a-fA-F]+\s*$", + re.MULTILINE, +) +_NO_NODE_LINE_RE = re.compile( + r"^\s*Could not connect to Bitcoin node(?:[.:].*)?\s*$", re.MULTILINE +) +_UPGRADE_PENDING_LINE_RE = re.compile( + r"^\s*(?:Failed!\s*)?Timestamp not complete\.?\s*$", re.MULTILINE +) +_INFO_BITCOIN_LINE_RE = re.compile( + r"^\s*verify BitcoinBlockHeaderAttestation\(\d+\)\s*$", re.MULTILINE +) +_INFO_PENDING_LINE_RE = re.compile( + r"^\s*verify PendingAttestation\([^\r\n]*\)\s*$", re.MULTILINE +) + + +class AnchorError(RuntimeError): + """A condition that must stop the anchoring run.""" + + +def sha256_file(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(65536), b""): + digest.update(block) + return digest.hexdigest() + + +def discover_manifests(directory: pathlib.Path) -> list[pathlib.Path]: + if not directory.is_dir(): + raise AnchorError(f"manifest directory missing: {directory}") + manifests: list[pathlib.Path] = [] + for candidate in sorted(directory.iterdir()): + if not MANIFEST_NAME_RE.match(candidate.name): + continue + if candidate.is_symlink() or not candidate.is_file(): + raise AnchorError(f"manifest is not a regular file: {candidate}") + manifests.append(candidate) + if not manifests: + raise AnchorError(f"no release manifests found in {directory}") + return manifests + + +def check_manifest_name_digest(manifest: pathlib.Path) -> str: + """Refuse to anchor bytes that contradict the manifest's own filename.""" + + match = MANIFEST_NAME_RE.match(manifest.name) + if match is None: # discover_manifests already filtered on the pattern + raise AnchorError(f"unexpected manifest filename: {manifest.name}") + digest = sha256_file(manifest) + if digest[:16] != match.group(2): + raise AnchorError( + f"manifest {manifest.name} bytes hash to {digest[:16]}..., " + "which contradicts the filename; refusing to anchor" + ) + return digest + + +def proof_path(root: pathlib.Path, manifest: pathlib.Path) -> pathlib.Path: + return root / OTS_DIR / f"{manifest.name}.ots" + + +def ensure_ots_directory(root: pathlib.Path) -> pathlib.Path: + directory = root / OTS_DIR + if directory.is_symlink(): + raise AnchorError(f"proof directory must not be a symlink: {directory}") + directory.mkdir(parents=True, exist_ok=True) + if not directory.is_dir(): + raise AnchorError(f"proof path is not a directory: {directory}") + return directory + + +def check_proof_destination(proof: pathlib.Path) -> None: + """Reject proof paths that could redirect writes outside ``ots/``.""" + + if proof.is_symlink(): + raise AnchorError(f"proof path must not be a symlink: {proof}") + if proof.exists() and not proof.is_file(): + raise AnchorError(f"proof path is not a regular file: {proof}") + + +def _run_ots( + ots_bin: list[str], arguments: list[str], *, timeout: int = SUBPROCESS_TIMEOUT +) -> subprocess.CompletedProcess[str]: + command = [*ots_bin, *arguments] + try: + return subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except FileNotFoundError as exc: + raise AnchorError( + f"ots binary not found ({command[0]!r}); install " + "opentimestamps-client or pass --ots-bin" + ) from exc + except subprocess.TimeoutExpired as exc: + raise AnchorError(f"ots timed out: {' '.join(command)}") from exc + + +def stamp_manifest( + root: pathlib.Path, manifest: pathlib.Path, ots_bin: list[str] +) -> pathlib.Path: + """Stamp a temporary copy, installing output only in a real ``ots/``.""" + + directory = ensure_ots_directory(root) + destination = proof_path(root, manifest) + check_proof_destination(destination) + if destination.exists(): + raise AnchorError(f"refusing to replace existing proof: {destination}") + + # Keeping the temporary directory beneath ots/ makes os.replace atomic and + # guarantees that even the client's temporary output cannot reach releases/. + with tempfile.TemporaryDirectory(prefix=".ots-anchor-", dir=directory) as name: + working_copy = pathlib.Path(name) / manifest.name + shutil.copyfile(manifest, working_copy) + completed = _run_ots(ots_bin, ["stamp", str(working_copy)]) + produced = working_copy.with_name(working_copy.name + ".ots") + if completed.returncode != 0 or produced.is_symlink() or not produced.is_file(): + raise AnchorError( + f"ots stamp failed for {manifest.name}: " + f"{completed.stderr.strip() or completed.stdout.strip()}" + ) + check_proof_destination(destination) + os.replace(produced, destination) + return destination + + +def local_proof_state(proof: pathlib.Path, ots_bin: list[str]) -> str: + """Return the attestation state serialized in the local proof itself.""" + + check_proof_destination(proof) + if not proof.is_file(): + raise AnchorError(f"proof file missing: {proof}") + completed = _run_ots(ots_bin, ["info", str(proof)]) + output = completed.stdout + completed.stderr + if completed.returncode != 0: + raise AnchorError(f"ots info failed for {proof.name}: {output.strip()}") + if _INFO_BITCOIN_LINE_RE.search(output): + return "bitcoin" + if _INFO_PENDING_LINE_RE.search(output): + return "pending" + raise AnchorError( + f"proof {proof.name} lists neither a Bitcoin nor a pending " + "attestation; refusing to guess" + ) + + +def proof_is_complete(proof: pathlib.Path, ots_bin: list[str]) -> bool: + """True only when the committed proof file carries a Bitcoin attestation.""" + + return local_proof_state(proof, ots_bin) == "bitcoin" + + +def _restore_upgrade_backup(proof: pathlib.Path, backup: pathlib.Path) -> None: + """Restore the client's backup after an interrupted or invalid upgrade.""" + + if not backup.exists() and not backup.is_symlink(): + return + if backup.is_symlink() or not backup.is_file(): + raise AnchorError(f"unsafe OpenTimestamps backup path: {backup}") + if proof.is_symlink(): + proof.unlink() + elif proof.exists(): + if not proof.is_file(): + raise AnchorError(f"cannot restore proof over non-file: {proof}") + proof.unlink() + os.replace(backup, proof) + + +def upgrade_proof( + manifest: pathlib.Path, proof: pathlib.Path, ots_bin: list[str] +) -> bool: + """Upgrade a pending proof and validate its replacement before cleanup. + + Returns whether the validated local replacement contains a Bitcoin block + attestation. A normal still-pending response restores the original proof. + """ + + check_proof_destination(proof) + if not proof.is_file(): + raise AnchorError(f"proof file missing: {proof}") + backup = proof.with_name(proof.name + ".bak") + if backup.exists() or backup.is_symlink(): + raise AnchorError(f"refusing to overwrite existing backup: {backup}") + + completed = _run_ots(ots_bin, ["upgrade", str(proof)]) + output = completed.stdout + completed.stderr + if completed.returncode != 0: + _restore_upgrade_backup(proof, backup) + if _UPGRADE_PENDING_LINE_RE.search(output): + return False + raise AnchorError(f"ots upgrade failed for {proof.name}: {output.strip()}") + + try: + check_proof_destination(proof) + if not proof.is_file(): + raise AnchorError(f"ots upgrade removed proof: {proof.name}") + state = classify_proof(manifest, proof, ots_bin) + if state == "mismatch": + raise AnchorError( + f"upgraded proof {proof.name} does not match manifest bytes" + ) + except (AnchorError, OSError): + _restore_upgrade_backup(proof, backup) + raise + + if backup.exists() or backup.is_symlink(): + if backup.is_symlink() or not backup.is_file(): + _restore_upgrade_backup(proof, backup) + raise AnchorError(f"unsafe OpenTimestamps backup path: {backup}") + backup.unlink() + return state == "bitcoin" + + +def verify_proof_binding( + manifest: pathlib.Path, proof: pathlib.Path, ots_bin: list[str] +) -> bool: + """Return false only when ``ots`` proves the file digest is mismatched.""" + + completed = _run_ots( + ots_bin, ["--no-bitcoin", "verify", "-f", str(manifest), str(proof)] + ) + output = completed.stdout + completed.stderr + if _MISMATCH_LINE_RE.search(output): + return False + if completed.returncode == 0: + return True + if ( + _VERIFY_PENDING_LINE_RE.search(output) + or _VERIFY_MANUAL_LINE_RE.search(output) + or _NO_NODE_LINE_RE.search(output) + ): + return True + raise AnchorError( + f"unrecognized ots verify outcome for {proof.name}: {output.strip()}" + ) + + +def classify_proof( + manifest: pathlib.Path, proof: pathlib.Path, ots_bin: list[str] +) -> str: + """Classify a locally stored proof after checking its exact-file binding.""" + + if not verify_proof_binding(manifest, proof, ots_bin): + return "mismatch" + return local_proof_state(proof, ots_bin) + + +def command_run( + root: pathlib.Path, manifest_dir: pathlib.Path, ots_bin: list[str] +) -> int: + manifests = discover_manifests(manifest_dir) + ensure_ots_directory(root) + stamped: list[str] = [] + upgraded: list[str] = [] + pending: list[str] = [] + for manifest in manifests: + check_manifest_name_digest(manifest) + proof = proof_path(root, manifest) + check_proof_destination(proof) + if not proof.exists(): + stamp_manifest(root, manifest, ots_bin) + state = classify_proof(manifest, proof, ots_bin) + if state == "mismatch": + raise AnchorError( + f"new proof {proof.name} does not match manifest bytes" + ) + stamped.append(manifest.name) + if state == "pending": + pending.append(manifest.name) + continue + + # Binding is checked before completeness, including for a proof whose + # local structure already contains a Bitcoin attestation. + state = classify_proof(manifest, proof, ots_bin) + if state == "mismatch": + raise AnchorError( + f"proof {proof.name} does not match manifest bytes; refusing to skip" + ) + if proof_is_complete(proof, ots_bin): + continue + if upgrade_proof(manifest, proof, ots_bin): + upgraded.append(manifest.name) + else: + pending.append(manifest.name) + print( + f"ots anchor run: {len(manifests)} manifests, " + f"stamped {len(stamped)}, upgraded {len(upgraded)}, " + f"still pending {len(pending)}" + ) + for name in stamped: + print(f" stamped {name}") + for name in upgraded: + print(f" upgraded {name}") + return 0 + + +def command_verify( + root: pathlib.Path, + manifest_dir: pathlib.Path, + ots_bin: list[str], + *, + require_bitcoin: bool, +) -> int: + manifests = discover_manifests(manifest_dir) + failures: list[str] = [] + pending_count = 0 + bitcoin_count = 0 + for manifest in manifests: + check_manifest_name_digest(manifest) + proof = proof_path(root, manifest) + check_proof_destination(proof) + if not proof.exists(): + failures.append(f"{manifest.name}: no OpenTimestamps proof") + continue + state = classify_proof(manifest, proof, ots_bin) + if state == "mismatch": + failures.append(f"{manifest.name}: proof does not match manifest bytes") + elif state == "pending": + pending_count += 1 + if require_bitcoin: + failures.append(f"{manifest.name}: attestation not yet in local proof") + else: + bitcoin_count += 1 + print( + f"ots anchor verify: {len(manifests)} manifests, " + f"{bitcoin_count} locally Bitcoin-complete, {pending_count} pending locally" + ) + for failure in failures: + print(f" FAIL {failure}", file=sys.stderr) + if failures: + return 1 + print("every release manifest has an OpenTimestamps proof bound to its exact bytes") + return 0 + + +def command_status( + root: pathlib.Path, manifest_dir: pathlib.Path, ots_bin: list[str] +) -> int: + manifests = discover_manifests(manifest_dir) + for manifest in manifests: + check_manifest_name_digest(manifest) + proof = proof_path(root, manifest) + check_proof_destination(proof) + if not proof.exists(): + print(f"{manifest.name}: unanchored") + continue + state = classify_proof(manifest, proof, ots_bin) + label = { + "bitcoin": "bitcoin attestation stored locally", + "pending": "pending local proof", + "mismatch": "MISMATCH", + }[state] + print(f"{manifest.name}: {label}") + return 0 + + +def git_changed_paths(root: pathlib.Path) -> list[str]: + """Return both sides of every porcelain-v1 change record.""" + + completed = subprocess.run( + ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + raise AnchorError(f"git status failed: {completed.stderr.strip()}") + fields = completed.stdout.split("\0") + paths: list[str] = [] + index = 0 + while index < len(fields): + record = fields[index] + if not record: + index += 1 + continue + if len(record) < 4 or record[2] != " ": + raise AnchorError(f"unrecognized git status record: {record!r}") + status = record[:2] + paths.append(record[3:]) + if "R" in status or "C" in status: + index += 1 + if index >= len(fields) or not fields[index]: + raise AnchorError("git status omitted a rename/copy source path") + paths.append(fields[index]) + index += 1 + return paths + + +def assert_only_ots_changes(root: pathlib.Path) -> int: + paths = git_changed_paths(root) + outside = sorted( + changed for changed in paths if not changed.startswith(f"{OTS_DIR.as_posix()}/") + ) + if outside: + details = ", ".join(outside) + raise AnchorError(f"refusing changes outside ots/: {details}") + return len(paths) + + +def command_guard(root: pathlib.Path) -> int: + changed_count = assert_only_ots_changes(root) + print(f"ots publication guard: {changed_count} changed path(s), all under ots/") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="anchor witnessed release manifests via OpenTimestamps" + ) + common = argparse.ArgumentParser(add_help=False) + common.add_argument( + "--root", + type=pathlib.Path, + default=ROOT, + help=argparse.SUPPRESS, + ) + common.add_argument( + "--manifests", + type=pathlib.Path, + default=DEFAULT_MANIFEST_DIR, + help=( + "manifest directory, absolute or relative to --root (default: %(default)s)" + ), + ) + common.add_argument( + "--ots-bin", + default="ots", + help="ots invocation, shell-split (default: %(default)s)", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser( + "run", parents=[common], help="stamp missing proofs, upgrade pending" + ) + verify_parser = subparsers.add_parser( + "verify", + parents=[common], + help="check every proof against current manifest bytes", + ) + verify_parser.add_argument( + "--require-bitcoin", + action="store_true", + help="fail while any local proof still lacks a Bitcoin attestation", + ) + subparsers.add_parser( + "status", parents=[common], help="list proofs and their local state" + ) + subparsers.add_parser( + "guard", parents=[common], help="fail on repository changes outside ots/" + ) + args = parser.parse_args(argv) + + root = args.root.resolve() + manifest_dir = args.manifests + if not manifest_dir.is_absolute(): + manifest_dir = root / manifest_dir + manifest_dir = manifest_dir.resolve() + ots_bin = shlex.split(args.ots_bin) + try: + if not ots_bin: + raise AnchorError("--ots-bin cannot be empty") + if args.command == "run": + return command_run(root, manifest_dir, ots_bin) + if args.command == "verify": + return command_verify( + root, + manifest_dir, + ots_bin, + require_bitcoin=args.require_bitcoin, + ) + if args.command == "status": + return command_status(root, manifest_dir, ots_bin) + return command_guard(root) + except (AnchorError, OSError) as exc: + print(f"ots anchor failed: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ots_anchor.py b/tests/test_ots_anchor.py new file mode 100644 index 0000000..b07628e --- /dev/null +++ b/tests/test_ots_anchor.py @@ -0,0 +1,325 @@ +"""Tests for scripts/ots_anchor.py. + +The suite never contacts calendar servers or Bitcoin: a fake ``ots`` +executable reproduces the observed opentimestamps-client 0.7.2 output +contract (stamp/upgrade/info/verify), and every invocation is logged so the +tests can assert which operations ran. +""" + +from __future__ import annotations + +import hashlib +import json +import shlex +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + +sys.path.insert(0, str(ROOT / "scripts")) + +import ots_anchor # noqa: E402 + +FAKE_OTS = r""" +import hashlib +import json +import os +import pathlib +import sys + +LOG = pathlib.Path(os.environ["FAKE_OTS_LOG"]) + + +def log(entry): + with LOG.open("a", encoding="utf-8") as handle: + handle.write(entry + "\n") + + +def read_proof(path): + return json.loads(pathlib.Path(path).read_text(encoding="utf-8")) + + +def write_proof(path, payload): + pathlib.Path(path).write_text(json.dumps(payload), encoding="utf-8") + + +def main(): + arguments = [a for a in sys.argv[1:] if a != "--no-bitcoin"] + command = arguments[0] + log(command) + if command == "stamp": + target = pathlib.Path(arguments[1]) + digest = hashlib.sha256(target.read_bytes()).hexdigest() + write_proof( + str(target) + ".ots", {"digest": digest, "state": "pending"} + ) + print("Submitting to remote calendar https://fake.calendar") + return 0 + if command == "info": + proof = read_proof(arguments[1]) + if proof["state"] == "bitcoin": + print("verify BitcoinBlockHeaderAttestation(963213)") + elif os.environ.get("FAKE_OTS_INFO_SPOOF") == "yes": + print( + "verify PendingAttestation(" + "'https://fake/BitcoinBlockHeaderAttestation(1)')" + ) + else: + print("verify PendingAttestation('https://fake.calendar')") + return 0 + if command == "upgrade": + path = pathlib.Path(arguments[1]) + proof = read_proof(path) + upgrade = os.environ.get("FAKE_OTS_UPGRADE") + if upgrade == "success": + proof["state"] = "bitcoin" + write_proof(path, proof) + pathlib.Path(str(path) + ".bak").write_text( + "backup", encoding="utf-8" + ) + print("Success! Timestamp complete") + return 0 + if upgrade == "broken": + path.replace(pathlib.Path(str(path) + ".bak")) + print("calendar response could not be serialized") + return 2 + print("Failed! Timestamp not complete") + return 1 + if command == "verify": + target = pathlib.Path(arguments[arguments.index("-f") + 1]) + proof = read_proof(arguments[-1]) + digest = hashlib.sha256(target.read_bytes()).hexdigest() + if digest != proof["digest"]: + print("File does not match original!") + return 1 + if ( + proof["state"] == "bitcoin" + or os.environ.get("FAKE_OTS_VERIFY_RESOLVED") == "yes" + ): + print( + "To verify manually, check that Bitcoin block 963213 " + "has merkleroot aa" + ) + return 1 + print("Pending confirmation in Bitcoin blockchain") + return 1 + raise SystemExit(f"unexpected fake ots command: {command}") + + +if __name__ == "__main__": + raise SystemExit(main()) +""" + + +def make_manifest(directory: Path, index: int, payload: bytes) -> Path: + digest = hashlib.sha256(payload).hexdigest() + path = directory / f"{index:04d}-{digest[:16]}.json" + path.write_bytes(payload) + return path + + +@pytest.fixture +def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict: + manifest_dir = tmp_path / "releases" / "manifests" + manifest_dir.mkdir(parents=True) + manifests = [ + make_manifest(manifest_dir, 0, b'{"releaseIndex": 0}\n'), + make_manifest(manifest_dir, 1, b'{"releaseIndex": 1}\n'), + ] + fake = tmp_path / "fake_ots.py" + fake.write_text(FAKE_OTS, encoding="utf-8") + log = tmp_path / "ots-invocations.log" + log.touch() + monkeypatch.setenv("FAKE_OTS_LOG", str(log)) + monkeypatch.setenv("FAKE_OTS_UPGRADE", "pending") + ots_bin = f"{shlex.quote(sys.executable)} {shlex.quote(str(fake))}" + return { + "root": tmp_path, + "manifest_dir": manifest_dir, + "manifests": manifests, + "ots_bin": ots_bin, + "log": log, + } + + +def run_cli(repo: dict, *arguments: str) -> int: + return ots_anchor.main( + [*arguments, "--root", str(repo["root"]), "--ots-bin", repo["ots_bin"]] + ) + + +def logged_commands(repo: dict) -> list[str]: + return repo["log"].read_text(encoding="utf-8").split() + + +def test_run_stamps_every_manifest_into_ots_dir(repo: dict) -> None: + assert run_cli(repo, "run") == 0 + proofs = sorted((repo["root"] / "ots").iterdir()) + assert [p.name for p in proofs] == [m.name + ".ots" for m in repo["manifests"]] + for manifest, proof in zip(repo["manifests"], proofs): + payload = json.loads(proof.read_text(encoding="utf-8")) + assert payload["digest"] == hashlib.sha256(manifest.read_bytes()).hexdigest() + + +def test_run_never_writes_into_releases(repo: dict) -> None: + before = sorted(p.name for p in repo["manifest_dir"].iterdir()) + assert run_cli(repo, "run") == 0 + after = sorted(p.name for p in repo["manifest_dir"].iterdir()) + assert before == after + + +def test_run_is_idempotent_and_upgrades_pending_proofs( + repo: dict, monkeypatch: pytest.MonkeyPatch +) -> None: + assert run_cli(repo, "run") == 0 + assert logged_commands(repo).count("stamp") == 2 + + # Second run: calendars still pending — no new stamps, upgrade attempted. + assert run_cli(repo, "run") == 0 + assert logged_commands(repo).count("stamp") == 2 + assert logged_commands(repo).count("upgrade") == 2 + + # Third run: attestations land — proofs upgraded in place, .bak removed. + monkeypatch.setenv("FAKE_OTS_UPGRADE", "success") + assert run_cli(repo, "run") == 0 + for manifest in repo["manifests"]: + proof = repo["root"] / "ots" / f"{manifest.name}.ots" + assert json.loads(proof.read_text(encoding="utf-8"))["state"] == "bitcoin" + assert not proof.with_name(proof.name + ".bak").exists() + + # Fourth run: complete proofs are left untouched (no further upgrades). + upgrades_before = logged_commands(repo).count("upgrade") + assert run_cli(repo, "run") == 0 + assert logged_commands(repo).count("upgrade") == upgrades_before + + +def test_run_refuses_manifest_contradicting_its_filename(repo: dict) -> None: + rogue = repo["manifest_dir"] / f"0002-{'0' * 16}.json" + rogue.write_bytes(b'{"releaseIndex": 2}\n') + assert run_cli(repo, "run") == 1 + + +def test_verify_passes_with_pending_proofs_by_default(repo: dict) -> None: + assert run_cli(repo, "run") == 0 + assert run_cli(repo, "verify") == 0 + assert run_cli(repo, "verify", "--require-bitcoin") == 1 + + +def test_verify_fails_on_missing_proof(repo: dict) -> None: + assert run_cli(repo, "run") == 0 + (repo["root"] / "ots" / f"{repo['manifests'][0].name}.ots").unlink() + assert run_cli(repo, "verify") == 1 + + +def test_verify_fails_when_proof_binds_different_bytes(repo: dict) -> None: + assert run_cli(repo, "run") == 0 + proof = repo["root"] / "ots" / f"{repo['manifests'][1].name}.ots" + payload = json.loads(proof.read_text(encoding="utf-8")) + payload["digest"] = "ab" * 32 + proof.write_text(json.dumps(payload), encoding="utf-8") + assert run_cli(repo, "verify") == 1 + + +def test_status_reports_each_state(repo: dict, capsys) -> None: + assert run_cli(repo, "status") == 0 + output = capsys.readouterr().out + assert output.count("unanchored") == 2 + + assert run_cli(repo, "run") == 0 + assert run_cli(repo, "status") == 0 + output = capsys.readouterr().out + assert output.count("pending local proof") == 2 + + +def test_manifests_option_reads_external_checkout(repo: dict) -> None: + external = repo["root"].parent / "journal" / "releases" / "manifests" + external.mkdir(parents=True) + manifest_names = [manifest.name for manifest in repo["manifests"]] + for manifest in repo["manifests"]: + manifest.replace(external / manifest.name) + + assert run_cli(repo, "run", "--manifests", str(external)) == 0 + assert sorted(proof.name for proof in (repo["root"] / "ots").iterdir()) == [ + f"{name}.ots" for name in manifest_names + ] + assert not list(external.glob("*.ots")) + + +def test_guard_accepts_only_ots_and_rejects_outside_changes(tmp_path: Path) -> None: + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + (tmp_path / "README.md").write_text("baseline\n", encoding="utf-8") + subprocess.run(["git", "-C", str(tmp_path), "add", "README.md"], check=True) + subprocess.run( + [ + "git", + "-C", + str(tmp_path), + "-c", + "user.name=OTS test", + "-c", + "user.email=ots@example.invalid", + "commit", + "-qm", + "baseline", + ], + check=True, + ) + (tmp_path / "ots").mkdir() + (tmp_path / "ots" / "proof.ots").write_text("proof", encoding="utf-8") + + assert ots_anchor.main(["guard", "--root", str(tmp_path)]) == 0 + (tmp_path / "README.md").write_text("changed\n", encoding="utf-8") + assert ots_anchor.main(["guard", "--root", str(tmp_path)]) == 1 + + +def test_run_reverifies_bitcoin_complete_proof_binding(repo: dict) -> None: + assert run_cli(repo, "run") == 0 + proof = repo["root"] / "ots" / f"{repo['manifests'][0].name}.ots" + payload = json.loads(proof.read_text(encoding="utf-8")) + payload.update(state="bitcoin", digest="ab" * 32) + proof.write_text(json.dumps(payload), encoding="utf-8") + verifies_before = logged_commands(repo).count("verify") + upgrades_before = logged_commands(repo).count("upgrade") + + assert run_cli(repo, "run") == 1 + assert logged_commands(repo).count("verify") == verifies_before + 1 + assert logged_commands(repo).count("upgrade") == upgrades_before + + +def test_local_state_resists_calendar_output_spoofing( + repo: dict, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + assert run_cli(repo, "run") == 0 + monkeypatch.setenv("FAKE_OTS_INFO_SPOOF", "yes") + monkeypatch.setenv("FAKE_OTS_VERIFY_RESOLVED", "yes") + + assert run_cli(repo, "status") == 0 + assert capsys.readouterr().out.count("pending local proof") == 2 + assert run_cli(repo, "verify", "--require-bitcoin") == 1 + upgrades_before = logged_commands(repo).count("upgrade") + assert run_cli(repo, "run") == 0 + assert logged_commands(repo).count("upgrade") == upgrades_before + 2 + + +def test_failed_upgrade_restores_original_backup( + repo: dict, monkeypatch: pytest.MonkeyPatch +) -> None: + assert run_cli(repo, "run") == 0 + proof = repo["root"] / "ots" / f"{repo['manifests'][0].name}.ots" + original = proof.read_bytes() + monkeypatch.setenv("FAKE_OTS_UPGRADE", "broken") + + assert run_cli(repo, "run") == 1 + assert proof.read_bytes() == original + assert not proof.with_name(proof.name + ".bak").exists() + + +def test_run_rejects_symlinked_proof_directory(repo: dict) -> None: + proof_directory = repo["root"] / "ots" + proof_directory.symlink_to(repo["manifest_dir"], target_is_directory=True) + + assert run_cli(repo, "run") == 1 + assert not list(repo["manifest_dir"].glob("*.ots")) From b97376ed774af1c139afe9ca0bc8df5c9e0e3198 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 2 Sep 2026 12:22:42 -0400 Subject: [PATCH 04/22] Restore proofs after upgrade timeouts Co-Authored-By: Claude Fable 5.1 --- PROGRESS.md | 4 +++- scripts/ots_anchor.py | 7 ++++++- tests/test_ots_anchor.py | 23 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 16d1135..85602dc 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -24,7 +24,9 @@ pending because classic `main` protection details are not yet readable in this l local-state, backup-loss, and symlink-escape findings in the trusted tool. - Copied the 15 proof blobs for releases 0000–0014 byte-for-byte from `545cfe56`. - Added `--manifests`, complete-proof binding re-verification, a testable - `ots/`-only change guard, and 14 hermetic fake-client tests (all passing). + `ots/`-only change guard, and 15 hermetic fake-client tests (all passing). +- Restored the original proof on upgrade timeout as well as explicit client + failure, closing the independent core review's only finding. - Queried GitHub through the authenticated connector: `main` is protected, required-status-check enforcement is off, and repository plus parent ruleset lists are empty. The integration cannot read classic protection, so this is diff --git a/scripts/ots_anchor.py b/scripts/ots_anchor.py index 237e473..9c91719 100644 --- a/scripts/ots_anchor.py +++ b/scripts/ots_anchor.py @@ -247,7 +247,12 @@ def upgrade_proof( if backup.exists() or backup.is_symlink(): raise AnchorError(f"refusing to overwrite existing backup: {backup}") - completed = _run_ots(ots_bin, ["upgrade", str(proof)]) + try: + completed = _run_ots(ots_bin, ["upgrade", str(proof)]) + except (AnchorError, OSError): + # A timeout can arrive after the client renamed the original proof. + _restore_upgrade_backup(proof, backup) + raise output = completed.stdout + completed.stderr if completed.returncode != 0: _restore_upgrade_backup(proof, backup) diff --git a/tests/test_ots_anchor.py b/tests/test_ots_anchor.py index b07628e..fa67d9b 100644 --- a/tests/test_ots_anchor.py +++ b/tests/test_ots_anchor.py @@ -317,6 +317,29 @@ def test_failed_upgrade_restores_original_backup( assert not proof.with_name(proof.name + ".bak").exists() +def test_timed_out_upgrade_restores_original_backup( + repo: dict, monkeypatch: pytest.MonkeyPatch +) -> None: + assert run_cli(repo, "run") == 0 + proof = repo["root"] / "ots" / f"{repo['manifests'][0].name}.ots" + original = proof.read_bytes() + real_run_ots = ots_anchor._run_ots + + def time_out_after_backup(ots_bin, arguments, *, timeout=300): + if arguments[0] == "upgrade": + upgrade_target = Path(arguments[1]) + upgrade_target.replace( + upgrade_target.with_name(upgrade_target.name + ".bak") + ) + raise ots_anchor.AnchorError("ots timed out") + return real_run_ots(ots_bin, arguments, timeout=timeout) + + monkeypatch.setattr(ots_anchor, "_run_ots", time_out_after_backup) + assert run_cli(repo, "run") == 1 + assert proof.read_bytes() == original + assert not proof.with_name(proof.name + ".bak").exists() + + def test_run_rejects_symlinked_proof_directory(repo: dict) -> None: proof_directory = repo["root"] / "ots" proof_directory.symlink_to(repo["manifest_dir"], target_is_directory=True) From b39af18d22b99f64931e3e059c0d649caefb4ddc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 2 Sep 2026 12:23:04 -0400 Subject: [PATCH 05/22] Document main-owned Bitcoin checkpoints Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 8 +++++ PROGRESS.md | 4 ++- README.md | 10 ++++++ ots/README.md | 70 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 ots/README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fd6d61..552fbaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,14 @@ jobs: run: > uv run pytest -q + # Keep this security-sensitive operational surface explicit even though + # the broad Chronicle lint and test commands above also cover it. + - name: Lint OpenTimestamps anchoring + run: uv run ruff check scripts/ots_anchor.py tests/test_ots_anchor.py + + - name: Test OpenTimestamps anchoring + run: uv run pytest -q tests/test_ots_anchor.py + - name: Build source input database run: | uv run chronicle --db /tmp/chronicle-targets-ci.db init diff --git a/PROGRESS.md b/PROGRESS.md index 85602dc..3358da1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -31,9 +31,11 @@ pending because classic `main` protection details are not yet readable in this l required-status-check enforcement is off, and repository plus parent ruleset lists are empty. The integration cannot read classic protection, so this is not yet enough evidence to choose direct push. +- Added main-owned proof documentation, the root README overview, and explicit + focused OTS lint/test steps without narrowing main's existing full CI suite. ## Next - Obtain live `main` rules evidence and select direct-push versus bot-PR publication. -- Add the main-owned workflow, documentation, CI wiring, and tests. +- Add and validate the main-owned scheduled workflow. - Run the full required verification, push the branch, and open the superseding PR. diff --git a/README.md b/README.md index 576919c..d0c4015 100644 --- a/README.md +++ b/README.md @@ -569,3 +569,13 @@ normalized_fact = convert_units(fact, 1000, "count") target selection, and calibration execution. - [thesis](https://github.com/PolicyEngine/thesis) - Public-facing official observations and analysis surfaces backed by Chronicle facts. + +## Bitcoin checkpoints for the witnessed journal + +The `codex/thesis-ledger-facts` branch's witnessed release manifests are +additionally anchored through OpenTimestamps. Trusted automation and the +mutable `ots/.json.ots` proofs live on `main`, while the immutable +manifests and journal remain on their protected branch. Each proof binds a +manifest's exact bytes into Bitcoin, giving the journal state it commits to an +external anteriority bound. See [`ots/README.md`](ots/README.md) for the limits, +cross-branch verification command, and publication design. diff --git a/ots/README.md b/ots/README.md new file mode 100644 index 0000000..0291f23 --- /dev/null +++ b/ots/README.md @@ -0,0 +1,70 @@ +# Bitcoin checkpoints for the witnessed journal + +This directory contains [OpenTimestamps](https://opentimestamps.org) proofs for +release manifests on the `codex/thesis-ledger-facts` journal branch. For a stem +``, `ots/.json.ots` commits to the exact bytes of +`releases/manifests/.json` in a journal checkout. Those are the same bytes +witnessed by the release's two RFC 3161 authorities and signed by its pinned +producer key. + +## What a proof establishes + +A proof with a Bitcoin block attestation establishes that the manifest bytes +existed no later than that block. Each manifest commits to the full journal +bytes (`state.jsonlSha256` and `state.lineCount`), the immutable prefix, and the +previous manifest. An attestation therefore bounds the existence time of that +journal state and the manifest chain it incorporates without trusting this +repository's Git history as the only checkpoint. + +The proof does not establish that the manifest's claims are true, that GitHub +accepted a proposal at a particular time, or that no parallel fork exists. A +rewritten history can acquire new anchors, but Bitcoin exposes the later time at +which those replacement bytes first existed; it cannot be backdated. + +The 15 proofs initially carried here bind releases 0000–0014 and were first +stamped on 2026-08-19. The daily workflow stamps later manifests after they +appear and upgrades locally pending proof files when calendar attestations can +be folded into the serialized proof. + +## Verify + +Check a proof against a real checkout of the journal branch: + +```console +ots --no-bitcoin verify \ + -f /releases/manifests/.json \ + ots/.json.ots +``` + +The workflow pins the client as +`uvx --from opentimestamps-client==0.7.2 ots`; use that invocation in place of +`ots` for the same reproducible client version. `--no-bitcoin` verifies that the +proof commits to the manifest's exact bytes and, when available, prints a block +height and merkle root for manual checking against a Bitcoin source you trust. + +To sweep every manifest in a journal checkout against this proof tree: + +```console +python3 scripts/ots_anchor.py verify \ + --manifests /releases/manifests \ + --ots-bin "uvx --from opentimestamps-client==0.7.2 ots" +``` + +This is strict: a mismatched or missing proof fails. Add `--require-bitcoin` to +also fail while any committed proof file still contains only pending calendar +attestations. `status` distinguishes that local serialized state from an +attestation a calendar may resolve in memory during verification. + +## Why proofs and automation live on `main` + +The journal's `releases/` history is immutable, and its append gate admits only +complete release bundles. OpenTimestamps proofs are operational artifacts: +stamping creates them after a release exists, and upgrading rewrites them as +calendar transactions confirm. They therefore cannot live under `releases/`. + +Keeping the proof tree, anchoring script, tests, and scheduled workflow together +on protected `main` also establishes the privilege boundary. The workflow runs +`main`'s trusted script against a separate, shallow journal checkout that has no +persisted credential. It verifies the entire manifest/proof tree and rejects +any worktree change outside `ots/` before publication. It never pushes to the +journal branch. From ea8fced122d0d0c01734f323b7a3b1b1417bd0b0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 2 Sep 2026 12:31:41 -0400 Subject: [PATCH 06/22] Publish Bitcoin anchors from trusted main Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ots-anchor.yml | 154 +++++++++++++++++++++++++++++++ PROGRESS.md | 7 +- 2 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ots-anchor.yml diff --git a/.github/workflows/ots-anchor.yml b/.github/workflows/ots-anchor.yml new file mode 100644 index 0000000..58df4a9 --- /dev/null +++ b/.github/workflows/ots-anchor.yml @@ -0,0 +1,154 @@ +name: OTS anchor + +# The trusted tool and mutable proofs live on main. The journal checkout is +# credential-free input only; no code from it is executed and it is never pushed. + +on: + schedule: + - cron: "23 6 * * *" + workflow_dispatch: + +permissions: {} + +concurrency: + group: ots-anchor-main + cancel-in-progress: false + +jobs: + anchor: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + # API evidence on 2026-09-02 showed no repository/parent rulesets and + # required-status-check enforcement off on main, so proof-only commits + # use the direct-push publication path. + contents: write + env: + MAIN_BRANCH: main + JOURNAL_BRANCH: codex/thesis-ledger-facts + OTS_BIN: uvx --from opentimestamps-client==0.7.2 ots + steps: + - name: Check out trusted main + uses: actions/checkout@v4 + with: + ref: main + path: main + fetch-depth: 0 + persist-credentials: true + + - name: Check out journal manifests without credentials + uses: actions/checkout@v4 + with: + ref: codex/thesis-ledger-facts + path: journal + fetch-depth: 1 + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Anchor, verify, and publish proof-only changes + working-directory: main + shell: bash + run: | + set -euo pipefail + + manifest_dir="$GITHUB_WORKSPACE/journal/releases/manifests" + + anchor_and_verify() { + python3 scripts/ots_anchor.py run \ + --manifests "$manifest_dir" \ + --ots-bin "$OTS_BIN" + python3 scripts/ots_anchor.py verify \ + --manifests "$manifest_dir" \ + --ots-bin "$OTS_BIN" + python3 scripts/ots_anchor.py guard + } + + refresh_journal() { + git -C "$GITHUB_WORKSPACE/journal" fetch \ + --no-tags --depth=1 origin "$JOURNAL_BRANCH" + git -C "$GITHUB_WORKSPACE/journal" checkout \ + --detach FETCH_HEAD + } + + commit_proofs() { + git add -- ots/ + if git diff --cached --quiet; then + return + fi + if git diff --quiet "origin/$MAIN_BRANCH"...HEAD; then + git commit \ + -m "Update OpenTimestamps anchors for release manifests" \ + -m "Co-Authored-By: Claude Fable 5.1 " + else + git commit --amend --no-edit + fi + } + + assert_commit_scope() { + outside=0 + while IFS= read -r changed_path; do + if [[ -z "$changed_path" ]]; then + continue + fi + case "$changed_path" in + ots/*) ;; + *) + printf 'refusing committed path outside ots/: %s\n' \ + "$changed_path" >&2 + outside=1 + ;; + esac + done < <(git diff --name-only "origin/$MAIN_BRANCH"...HEAD) + if [[ "$outside" -ne 0 ]]; then + return 1 + fi + if [[ -n "$(git status --porcelain)" ]]; then + git status --short >&2 + printf 'refusing to push a dirty worktree\n' >&2 + return 1 + fi + } + + git config user.name "github-actions[bot]" + git config user.email \ + "41898282+github-actions[bot]@users.noreply.github.com" + + anchor_and_verify + commit_proofs + if git diff --quiet "origin/$MAIN_BRANCH"...HEAD; then + printf 'no proof changes to publish\n' + exit 0 + fi + assert_commit_scope + + for attempt in 1 2 3; do + if push_output="$( + git push origin "HEAD:refs/heads/$MAIN_BRANCH" 2>&1 + )"; then + printf '%s\n' "$push_output" + exit 0 + fi + printf '%s\n' "$push_output" >&2 + if [[ "$push_output" != *"non-fast-forward"* && \ + "$push_output" != *"(fetch first)"* ]]; then + printf 'push failed for a reason other than non-fast-forward\n' >&2 + exit 1 + fi + if [[ "$attempt" -eq 3 ]]; then + printf 'push remained non-fast-forward after 3 attempts\n' >&2 + exit 1 + fi + + git fetch --no-tags origin "$MAIN_BRANCH" + git rebase "origin/$MAIN_BRANCH" + refresh_journal + anchor_and_verify + commit_proofs + if git diff --quiet "origin/$MAIN_BRANCH"...HEAD; then + printf 'proof changes already landed during retry\n' + exit 0 + fi + assert_commit_scope + done diff --git a/PROGRESS.md b/PROGRESS.md index 3358da1..6fbf638 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,8 +2,7 @@ ## State -Trusted anchoring core implemented and tested. Publication-path selection remains -pending because classic `main` protection details are not yet readable in this lane. +Implementation is complete. Verification, delivery, and final reporting remain. ## Done @@ -33,9 +32,9 @@ pending because classic `main` protection details are not yet readable in this l not yet enough evidence to choose direct push. - Added main-owned proof documentation, the root README overview, and explicit focused OTS lint/test steps without narrowing main's existing full CI suite. +- Added the daily/manual main-owned workflow with sibling credential boundaries, + pre-push full verification, proof-only guards, and bounded rebase/rerun retries. ## Next -- Obtain live `main` rules evidence and select direct-push versus bot-PR publication. -- Add and validate the main-owned scheduled workflow. - Run the full required verification, push the branch, and open the superseding PR. From e255c09a5f7261cc057e9ca86e1f66b869d36246 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 2 Sep 2026 12:53:50 -0400 Subject: [PATCH 07/22] Publish anchors through a protected-main PR Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ots-anchor.yml | 191 ++++++++++++++++++++++++++----- PROGRESS.md | 26 ++++- 2 files changed, 182 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ots-anchor.yml b/.github/workflows/ots-anchor.yml index 58df4a9..0f042c7 100644 --- a/.github/workflows/ots-anchor.yml +++ b/.github/workflows/ots-anchor.yml @@ -19,11 +19,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 permissions: - # API evidence on 2026-09-02 showed no repository/parent rulesets and - # required-status-check enforcement off on main, so proof-only commits - # use the direct-push publication path. + # main is protected and a direct GITHUB_TOKEN push is not assumed. The + # proof commit is published through a bot-branch pull request instead. contents: write + pull-requests: write env: + BOT_BRANCH: automation/ots-anchor + GH_TOKEN: ${{ github.token }} MAIN_BRANCH: main JOURNAL_BRANCH: codex/thesis-ledger-facts OTS_BIN: uvx --from opentimestamps-client==0.7.2 ots @@ -34,6 +36,7 @@ jobs: ref: main path: main fetch-depth: 0 + # A credential is needed only to push the proof commit to BOT_BRANCH. persist-credentials: true - name: Check out journal manifests without credentials @@ -106,49 +109,179 @@ jobs: fi if [[ -n "$(git status --porcelain)" ]]; then git status --short >&2 - printf 'refusing to push a dirty worktree\n' >&2 + printf 'refusing to publish a dirty worktree\n' >&2 return 1 fi } + # Carry an existing bot PR's proof bytes across a retry, but trust no + # other path from that mutable branch. Every imported proof must map + # to a current immutable journal manifest and is re-verified below by + # main's script before it can be committed again. + import_bot_proofs() { + bot_remote_sha="" + if ! git fetch --no-tags origin "refs/heads/$BOT_BRANCH"; then + return + fi + bot_remote_sha="$(git rev-parse FETCH_HEAD)" + + deleted_paths="$( + git diff --diff-filter=D --name-only \ + "origin/$MAIN_BRANCH...$bot_remote_sha" + )" + if [[ -n "$deleted_paths" ]]; then + printf 'refusing bot branch proof deletions:\n%s\n' \ + "$deleted_paths" >&2 + return 1 + fi + + while IFS= read -r -d '' changed_path; do + if [[ ! "$changed_path" =~ ^ots/[0-9]{4}-[0-9a-f]{16}\.json\.ots$ ]]; then + printf 'refusing unexpected bot branch path: %s\n' \ + "$changed_path" >&2 + return 1 + fi + proof_name="${changed_path#ots/}" + manifest_path="$manifest_dir/${proof_name%.ots}" + if [[ ! -f "$manifest_path" || -L "$manifest_path" ]]; then + printf 'bot proof has no regular journal manifest: %s\n' \ + "$changed_path" >&2 + return 1 + fi + git checkout "$bot_remote_sha" -- "$changed_path" + done < <( + git diff --name-only -z \ + "origin/$MAIN_BRANCH...$bot_remote_sha" + ) + } + + # Record the effective rules in the run log. Authoring-time evidence + # established that main is protected but could not establish a direct + # GITHUB_TOKEN bypass, so publication deliberately stays on a PR path. + gh api \ + "repos/$GITHUB_REPOSITORY/rules/branches/$MAIN_BRANCH?per_page=100" \ + > "$RUNNER_TEMP/main-rules.json" + jq '{effective_rule_types: map(.type)}' \ + "$RUNNER_TEMP/main-rules.json" + gh api "repos/$GITHUB_REPOSITORY/branches/$MAIN_BRANCH" \ + --jq '{protected, required_status_checks: .protection.required_status_checks}' + git config user.name "github-actions[bot]" git config user.email \ "41898282+github-actions[bot]@users.noreply.github.com" - anchor_and_verify - commit_proofs - if git diff --quiet "origin/$MAIN_BRANCH"...HEAD; then - printf 'no proof changes to publish\n' - exit 0 - fi - assert_commit_scope - + pushed=0 for attempt in 1 2 3; do - if push_output="$( - git push origin "HEAD:refs/heads/$MAIN_BRANCH" 2>&1 - )"; then - printf '%s\n' "$push_output" + # A main race is resolved before every publication attempt. The + # trusted tool then stamps and verifies again against a freshly + # fetched journal tip, so neither a rebase nor a retry can publish + # a stale proof tree. + git fetch --no-tags origin "$MAIN_BRANCH" + if ! git merge-base --is-ancestor "origin/$MAIN_BRANCH" HEAD; then + git rebase "origin/$MAIN_BRANCH" + fi + import_bot_proofs + refresh_journal + anchor_and_verify + commit_proofs + + if git diff --quiet "origin/$MAIN_BRANCH"...HEAD; then + printf 'no proof changes to publish\n' exit 0 fi + assert_commit_scope + + if [[ -n "$bot_remote_sha" ]]; then + push_command=( + git push + "--force-with-lease=refs/heads/$BOT_BRANCH:$bot_remote_sha" + origin "HEAD:refs/heads/$BOT_BRANCH" + ) + else + push_command=( + git push origin "HEAD:refs/heads/$BOT_BRANCH" + ) + fi + + if push_output="$("${push_command[@]}" 2>&1)"; then + printf '%s\n' "$push_output" + pushed=1 + break + fi printf '%s\n' "$push_output" >&2 if [[ "$push_output" != *"non-fast-forward"* && \ - "$push_output" != *"(fetch first)"* ]]; then - printf 'push failed for a reason other than non-fast-forward\n' >&2 + "$push_output" != *"(fetch first)"* && \ + "$push_output" != *"stale info"* ]]; then + printf 'bot branch push failed for an unexpected reason\n' >&2 exit 1 fi if [[ "$attempt" -eq 3 ]]; then - printf 'push remained non-fast-forward after 3 attempts\n' >&2 + printf 'bot branch remained stale after 3 attempts\n' >&2 exit 1 fi + printf 'bot branch moved; fetching, rebasing, and re-verifying\n' >&2 + done - git fetch --no-tags origin "$MAIN_BRANCH" - git rebase "origin/$MAIN_BRANCH" - refresh_journal - anchor_and_verify - commit_proofs - if git diff --quiet "origin/$MAIN_BRANCH"...HEAD; then - printf 'proof changes already landed during retry\n' - exit 0 + if [[ "$pushed" -ne 1 ]]; then + printf 'proof commit was not published\n' >&2 + exit 1 + fi + + pr_body="$RUNNER_TEMP/ots-anchor-pr.md" + { + printf '%s\n' \ + '## Automated OpenTimestamps publication' \ + '' \ + "- Publication path: bot-branch pull request into protected \`main\`." \ + "- Direct \`GITHUB_TOKEN\` pushes to \`main\` are never attempted." \ + "- The trusted script came from \`main\`; the journal checkout had no credential." \ + '- The complete manifest/proof tree was verified before this branch was pushed.' \ + "- Only \`ots/.json.ots\` paths were carried from an earlier bot retry." \ + '' \ + "Source run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + '' \ + '🤖 Generated with [Claude Code](https://claude.com/claude-code)' + } > "$pr_body" + + pr_url="$( + gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --base "$MAIN_BRANCH" \ + --head "$BOT_BRANCH" \ + --state open \ + --json url \ + --jq '.[0].url // empty' + )" + if [[ -z "$pr_url" ]]; then + pr_url="$( + gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --base "$MAIN_BRANCH" \ + --head "$BOT_BRANCH" \ + --title "Update Bitcoin anchors for the witnessed journal" \ + --body-file "$pr_body" + )" + fi + printf 'proof publication PR: %s\n' "$pr_url" + + head_sha="$(git rev-parse HEAD)" + if ! merge_output="$( + gh pr merge "$pr_url" \ + --auto \ + --merge \ + --delete-branch \ + --match-head-commit "$head_sha" 2>&1 + )"; then + printf '%s\n' "$merge_output" >&2 + auto_merge_set="$( + gh pr view "$pr_url" \ + --json autoMergeRequest \ + --jq '.autoMergeRequest != null' + )" + if [[ "$auto_merge_set" != "true" ]]; then + printf 'failed to merge now or enable auto-merge\n' >&2 + exit 1 fi - assert_commit_scope - done + else + printf '%s\n' "$merge_output" + fi diff --git a/PROGRESS.md b/PROGRESS.md index 6fbf638..85cbb61 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,8 @@ ## State -Implementation is complete. Verification, delivery, and final reporting remain. +Implementation and local proof verification are complete. Final review, +delivery, and reporting remain. ## Done @@ -28,13 +29,26 @@ Implementation is complete. Verification, delivery, and final reporting remain. failure, closing the independent core review's only finding. - Queried GitHub through the authenticated connector: `main` is protected, required-status-check enforcement is off, and repository plus parent ruleset - lists are empty. The integration cannot read classic protection, so this is - not yet enough evidence to choose direct push. + lists are empty. The integration cannot read classic protection, so direct + `GITHUB_TOKEN` publication is not established and is never attempted. - Added main-owned proof documentation, the root README overview, and explicit focused OTS lint/test steps without narrowing main's existing full CI suite. -- Added the daily/manual main-owned workflow with sibling credential boundaries, - pre-push full verification, proof-only guards, and bounded rebase/rerun retries. +- Replaced the provisional direct-push workflow with a fixed bot-branch PR + publication path. It queries effective rules in the run log, imports only + manifest-matched proof files from an earlier retry, re-verifies them with + `main`'s script, uses a bounded fetch/rebase/rerun loop, and invokes + `gh pr merge --auto`; it never pushes the journal or `main` directly. +- Ran the required focused suite: 15 tests passed; `ruff check .` passed. The + repository-wide format check reports the same 14 pre-existing files on + `origin/main`; both added Python files pass the focused format check. +- Added `/tmp/journal` at `origin/codex/thesis-ledger-facts` and verified all 15 + carried proof bindings against its real manifest bytes. Full `status` reports + exactly manifests 0015--0019 as unanchored. Calendar DNS is unavailable, so + their missing proofs could not be stamped in this lane. +- Confirmed all 15 committed proof blob IDs match `545cfe56` byte-for-byte and + that the revised workflow passes `actionlint` (including ShellCheck). ## Next -- Run the full required verification, push the branch, and open the superseding PR. +- Complete the independent final workflow review, write `out.md`, push the + branch, and open the superseding PR. From e8fd9859195a1170122826505efceb9f0b31b92e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 2 Sep 2026 13:00:08 -0400 Subject: [PATCH 08/22] Harden bot publication against branch races Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ots-anchor.yml | 198 +++++++++++++++++++++++-------- PROGRESS.md | 4 + 2 files changed, 154 insertions(+), 48 deletions(-) diff --git a/.github/workflows/ots-anchor.yml b/.github/workflows/ots-anchor.yml index 0f042c7..70b54de 100644 --- a/.github/workflows/ots-anchor.yml +++ b/.github/workflows/ots-anchor.yml @@ -124,6 +124,9 @@ jobs: return fi bot_remote_sha="$(git rev-parse FETCH_HEAD)" + bot_base="$( + git merge-base "origin/$MAIN_BRANCH" "$bot_remote_sha" + )" deleted_paths="$( git diff --diff-filter=D --name-only \ @@ -148,6 +151,24 @@ jobs: "$changed_path" >&2 return 1 fi + + base_blob="$( + git rev-parse "$bot_base:$changed_path" 2>/dev/null || true + )" + main_blob="$( + git rev-parse \ + "origin/$MAIN_BRANCH:$changed_path" 2>/dev/null || true + )" + bot_blob="$(git rev-parse "$bot_remote_sha:$changed_path")" + if [[ "$main_blob" != "$base_blob" && \ + "$main_blob" != "$bot_blob" ]]; then + printf 'refusing divergent main/bot proof: %s\n' \ + "$changed_path" >&2 + return 1 + fi + if [[ "$main_blob" == "$bot_blob" ]]; then + continue + fi git checkout "$bot_remote_sha" -- "$changed_path" done < <( git diff --name-only -z \ @@ -164,12 +185,16 @@ jobs: jq '{effective_rule_types: map(.type)}' \ "$RUNNER_TEMP/main-rules.json" gh api "repos/$GITHUB_REPOSITORY/branches/$MAIN_BRANCH" \ - --jq '{protected, required_status_checks: .protection.required_status_checks}' + > "$RUNNER_TEMP/main-branch.json" + jq '{protected, required_status_checks: .protection.required_status_checks}' \ + "$RUNNER_TEMP/main-branch.json" + main_protected="$(jq -r '.protected' "$RUNNER_TEMP/main-branch.json")" git config user.name "github-actions[bot]" git config user.email \ "41898282+github-actions[bot]@users.noreply.github.com" + published_head_sha="" pushed=0 for attempt in 1 2 3; do # A main race is resolved before every publication attempt. The @@ -177,11 +202,10 @@ jobs: # fetched journal tip, so neither a rebase nor a retry can publish # a stale proof tree. git fetch --no-tags origin "$MAIN_BRANCH" - if ! git merge-base --is-ancestor "origin/$MAIN_BRANCH" HEAD; then - git rebase "origin/$MAIN_BRANCH" - fi - import_bot_proofs + verified_main_sha="$(git rev-parse "origin/$MAIN_BRANCH")" + git rebase "origin/$MAIN_BRANCH" refresh_journal + import_bot_proofs anchor_and_verify commit_proofs @@ -191,35 +215,77 @@ jobs: fi assert_commit_scope - if [[ -n "$bot_remote_sha" ]]; then - push_command=( - git push - "--force-with-lease=refs/heads/$BOT_BRANCH:$bot_remote_sha" - origin "HEAD:refs/heads/$BOT_BRANCH" - ) - else - push_command=( - git push origin "HEAD:refs/heads/$BOT_BRANCH" - ) + # A bot-branch push cannot itself be rejected merely because main + # advanced. Check main explicitly so that such a race also goes + # through the bounded rebase + fresh journal + re-verification path. + git fetch --no-tags origin "$MAIN_BRANCH" + if [[ "$(git rev-parse "origin/$MAIN_BRANCH")" != \ + "$verified_main_sha" ]]; then + if [[ "$attempt" -eq 3 ]]; then + printf 'main kept moving after 3 verified attempts\n' >&2 + exit 1 + fi + printf 'main moved; rebasing and re-verifying\n' >&2 + continue fi - if push_output="$("${push_command[@]}" 2>&1)"; then - printf '%s\n' "$push_output" - pushed=1 - break - fi - printf '%s\n' "$push_output" >&2 - if [[ "$push_output" != *"non-fast-forward"* && \ - "$push_output" != *"(fetch first)"* && \ - "$push_output" != *"stale info"* ]]; then - printf 'bot branch push failed for an unexpected reason\n' >&2 - exit 1 + # If an open bot PR is already based on this main tip and has the + # exact proof tree just re-verified, retain its head instead of + # force-pushing an equivalent commit and churning the PR. + if [[ -n "$bot_remote_sha" ]] && \ + git merge-base --is-ancestor \ + "origin/$MAIN_BRANCH" "$bot_remote_sha" && \ + git diff --quiet "$bot_remote_sha" HEAD -- ots/; then + published_head_sha="$bot_remote_sha" + printf 'existing bot head already has the verified proof tree\n' + else + if [[ -n "$bot_remote_sha" ]]; then + push_command=( + git push + "--force-with-lease=refs/heads/$BOT_BRANCH:$bot_remote_sha" + origin "HEAD:refs/heads/$BOT_BRANCH" + ) + else + push_command=( + git push origin "HEAD:refs/heads/$BOT_BRANCH" + ) + fi + + if push_output="$("${push_command[@]}" 2>&1)"; then + printf '%s\n' "$push_output" + published_head_sha="$(git rev-parse HEAD)" + else + printf '%s\n' "$push_output" >&2 + if [[ "$push_output" != *"non-fast-forward"* && \ + "$push_output" != *"(fetch first)"* && \ + "$push_output" != *"stale info"* ]]; then + printf 'bot branch push failed for an unexpected reason\n' >&2 + exit 1 + fi + if [[ "$attempt" -eq 3 ]]; then + printf 'bot branch remained stale after 3 attempts\n' >&2 + exit 1 + fi + printf '%s\n' \ + 'bot branch moved; fetching, rebasing, and re-verifying' >&2 + continue + fi fi - if [[ "$attempt" -eq 3 ]]; then - printf 'bot branch remained stale after 3 attempts\n' >&2 - exit 1 + + # Close the last main race between the pre-push check and the bot + # update. A changed tip goes around the loop and re-verifies again. + git fetch --no-tags origin "$MAIN_BRANCH" + if [[ "$(git rev-parse "origin/$MAIN_BRANCH")" != \ + "$verified_main_sha" ]]; then + if [[ "$attempt" -eq 3 ]]; then + printf 'main moved after all 3 publication attempts\n' >&2 + exit 1 + fi + printf 'main moved during publication; re-verifying\n' >&2 + continue fi - printf 'bot branch moved; fetching, rebasing, and re-verifying\n' >&2 + pushed=1 + break done if [[ "$pushed" -ne 1 ]]; then @@ -234,6 +300,8 @@ jobs: '' \ "- Publication path: bot-branch pull request into protected \`main\`." \ "- Direct \`GITHUB_TOKEN\` pushes to \`main\` are never attempted." \ + "- API evidence: \`main\` protected=$main_protected; no direct token bypass was established." \ + '- The proof-only bot branch was pushed or reused before this PR was created or updated.' \ "- The trusted script came from \`main\`; the journal checkout had no credential." \ '- The complete manifest/proof tree was verified before this branch was pushed.' \ "- Only \`ots/.json.ots\` paths were carried from an earlier bot retry." \ @@ -253,35 +321,69 @@ jobs: --jq '.[0].url // empty' )" if [[ -z "$pr_url" ]]; then - pr_url="$( + if ! create_output="$( gh pr create \ --repo "$GITHUB_REPOSITORY" \ --base "$MAIN_BRANCH" \ --head "$BOT_BRANCH" \ --title "Update Bitcoin anchors for the witnessed journal" \ - --body-file "$pr_body" - )" + --body-file "$pr_body" 2>&1 + )"; then + printf '%s\n' "$create_output" >&2 + pr_url="$( + gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --base "$MAIN_BRANCH" \ + --head "$BOT_BRANCH" \ + --state open \ + --json url \ + --jq '.[0].url // empty' + )" + if [[ -z "$pr_url" ]]; then + printf 'failed to create or find proof publication PR\n' >&2 + exit 1 + fi + else + pr_url="$create_output" + fi fi + gh pr edit "$pr_url" --body-file "$pr_body" printf 'proof publication PR: %s\n' "$pr_url" - head_sha="$(git rev-parse HEAD)" - if ! merge_output="$( + merge_status=0 + merge_output="$( gh pr merge "$pr_url" \ --auto \ --merge \ --delete-branch \ - --match-head-commit "$head_sha" 2>&1 - )"; then - printf '%s\n' "$merge_output" >&2 - auto_merge_set="$( - gh pr view "$pr_url" \ - --json autoMergeRequest \ - --jq '.autoMergeRequest != null' - )" - if [[ "$auto_merge_set" != "true" ]]; then - printf 'failed to merge now or enable auto-merge\n' >&2 - exit 1 - fi - else + --match-head-commit "$published_head_sha" 2>&1 + )" || merge_status=$? + if [[ -n "$merge_output" ]]; then printf '%s\n' "$merge_output" fi + + pr_state="$( + gh pr view "$pr_url" \ + --json state,headRefOid,autoMergeRequest,mergeStateStatus + )" + observed_head="$(jq -r '.headRefOid' <<< "$pr_state")" + observed_state="$(jq -r '.state' <<< "$pr_state")" + auto_merge_set="$(jq -r '.autoMergeRequest != null' <<< "$pr_state")" + if [[ "$observed_head" != "$published_head_sha" ]]; then + if [[ "$auto_merge_set" == "true" ]]; then + gh pr merge "$pr_url" --disable-auto || true + fi + printf 'PR head moved after verification: expected %s, found %s\n' \ + "$published_head_sha" "$observed_head" >&2 + exit 1 + fi + if [[ "$observed_state" == "MERGED" ]]; then + exit 0 + fi + if [[ "$observed_state" == "OPEN" && "$auto_merge_set" == "true" ]]; then + exit 0 + fi + printf '%s\n' "$pr_state" >&2 + printf 'merge/auto-merge did not stick (gh exit %s)\n' \ + "$merge_status" >&2 + exit 1 diff --git a/PROGRESS.md b/PROGRESS.md index 85cbb61..c6d7488 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -47,6 +47,10 @@ delivery, and reporting remain. their missing proofs could not be stamped in this lane. - Confirmed all 15 committed proof blob IDs match `545cfe56` byte-for-byte and that the revised workflow passes `actionlint` (including ShellCheck). +- Closed the first PR-path review's race findings: refresh the journal before + importing retry proofs, reject divergent main/bot proof edits, detect main + movement before and after the bot push, reuse an unchanged verified bot head, + and accept merge/auto-merge only when the PR still names the verified SHA. ## Next From ec64111c2f22805929f1e60a2922380170023a14 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 2 Sep 2026 13:02:04 -0400 Subject: [PATCH 09/22] Normalize automated proof pull requests Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ots-anchor.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ots-anchor.yml b/.github/workflows/ots-anchor.yml index 70b54de..6e5bad4 100644 --- a/.github/workflows/ots-anchor.yml +++ b/.github/workflows/ots-anchor.yml @@ -179,6 +179,8 @@ jobs: # Record the effective rules in the run log. Authoring-time evidence # established that main is protected but could not establish a direct # GITHUB_TOKEN bypass, so publication deliberately stays on a PR path. + # Failure to read current metadata is deliberately fatal and occurs + # before any proof or branch mutation. gh api \ "repos/$GITHUB_REPOSITORY/rules/branches/$MAIN_BRANCH?per_page=100" \ > "$RUNNER_TEMP/main-rules.json" @@ -344,7 +346,20 @@ jobs: exit 1 fi else - pr_url="$create_output" + printf '%s\n' "$create_output" + pr_url="$( + gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --base "$MAIN_BRANCH" \ + --head "$BOT_BRANCH" \ + --state open \ + --json url \ + --jq '.[0].url // empty' + )" + if [[ -z "$pr_url" ]]; then + printf 'created proof PR could not be resolved by head\n' >&2 + exit 1 + fi fi fi gh pr edit "$pr_url" --body-file "$pr_body" From ad4b8408ce0264a95768ada46025f826633c8a07 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 2 Sep 2026 13:04:37 -0400 Subject: [PATCH 10/22] Record publication permission blocker Co-Authored-By: Claude Fable 5.1 --- PROGRESS.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index c6d7488..5d9783e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,8 +2,8 @@ ## State -Implementation and local proof verification are complete. Final review, -delivery, and reporting remain. +Implementation and local proof verification are complete. Delivery is blocked +on establishing or authorizing a GitHub publication path for protected `main`. ## Done @@ -51,8 +51,16 @@ delivery, and reporting remain. importing retry proofs, reject divergent main/bot proof edits, detect main movement before and after the bot push, reuse an unchanged verified bot head, and accept merge/auto-merge only when the PR still names the verified SHA. +- Final publication review confirmed two external prerequisites cannot be + established in this lane: repository metadata currently has + `allow_auto_merge: false`, and the Actions setting that permits + `GITHUB_TOKEN` to create pull requests is unreadable. Direct token updates to + protected `main` also remain unproven because classic protection is unreadable. ## Next -- Complete the independent final workflow review, write `out.md`, push the - branch, and open the superseding PR. +- Obtain the exact `main` protection/effective-rules output proving direct + `GITHUB_TOKEN` updates are allowed, or authorization to enable repository + auto-merge and Actions-created pull requests (or to use an approved App/PAT). +- Then finalize the selected workflow, write `out.md`, push the branch, and + open the superseding PR. From 03b27674028a0d8cf5bdc71437f5944661f3c6cb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 2 Sep 2026 13:08:20 -0400 Subject: [PATCH 11/22] Carry Bitcoin-complete proofs for releases 0000-0014 and stamp 0015-0019 The fifteen proofs from #182 were upgraded in place from a networked session (each now carries BitcoinBlockHeaderAttestation entries; ots info confirms) and the five manifests appended to the journal since 2026-08-19 are stamped (pending calendar attestations). All twenty bind to their manifest bytes. Co-Authored-By: Claude Fable 5.1 --- ots/0000-307cedbc91de43be.json.ots | Bin 630 -> 4915 bytes ots/0001-916626696d034b80.json.ots | Bin 595 -> 4880 bytes ots/0002-a69272175b73c83b.json.ots | Bin 805 -> 5090 bytes ots/0003-cfae6e9b4524db6d.json.ots | Bin 770 -> 5055 bytes ots/0004-36322993cf45b6d1.json.ots | Bin 630 -> 4915 bytes ots/0005-9bcc4ff6b3fad5d2.json.ots | Bin 700 -> 4985 bytes ots/0006-770683e59da14f45.json.ots | Bin 630 -> 4915 bytes ots/0007-2b5ed02908832f0c.json.ots | Bin 770 -> 5055 bytes ots/0008-070e797b855dce92.json.ots | Bin 630 -> 4915 bytes ots/0009-995768a31dd8fa6d.json.ots | Bin 665 -> 4950 bytes ots/0010-6ba8c08f34189164.json.ots | Bin 700 -> 4985 bytes ots/0011-34319583df55ce83.json.ots | Bin 735 -> 5020 bytes ots/0012-3a5ef7eeee484370.json.ots | Bin 735 -> 5020 bytes ots/0013-d47323bbaacda2d1.json.ots | Bin 1015 -> 5300 bytes ots/0014-bd12e9e3e79a5529.json.ots | Bin 700 -> 4985 bytes ots/0015-fdcfd0e570214f6b.json.ots | Bin 0 -> 700 bytes ots/0016-5226191699ae168d.json.ots | Bin 0 -> 700 bytes ots/0017-efa7d60fece304f7.json.ots | Bin 0 -> 770 bytes ots/0018-20974a5bdeeace01.json.ots | Bin 0 -> 805 bytes ots/0019-01d2f0bfb2ebff75.json.ots | Bin 0 -> 700 bytes 20 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 ots/0015-fdcfd0e570214f6b.json.ots create mode 100644 ots/0016-5226191699ae168d.json.ots create mode 100644 ots/0017-efa7d60fece304f7.json.ots create mode 100644 ots/0018-20974a5bdeeace01.json.ots create mode 100644 ots/0019-01d2f0bfb2ebff75.json.ots diff --git a/ots/0000-307cedbc91de43be.json.ots b/ots/0000-307cedbc91de43be.json.ots index aec5cfeb6b3a582901ea542e542f6b8cba031860..7a392f4e0beb161529f470ec8b2916fd0ae17477 100644 GIT binary patch delta 4357 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 delta 33 pcmbQBc9~^D#l%Y8$yZqPC$q4cO?KrBnmmKkV)AX?p2-<}#sJo`3>yFd diff --git a/ots/0002-a69272175b73c83b.json.ots b/ots/0002-a69272175b73c83b.json.ots index 6e4747c7a5e6abce8155c48d14dc0a89d310519e..6ec331c327f79f50804819ec7de1661227d85747 100644 GIT binary patch delta 4359 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 diff --git a/ots/0004-36322993cf45b6d1.json.ots b/ots/0004-36322993cf45b6d1.json.ots index 2462b15dbf9eb0f76407f96a95ab04d5bc67a182..fd906796ca07defa6fec11c00718068e216ade7c 100644 GIT binary patch delta 4360 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 diff --git a/ots/0005-9bcc4ff6b3fad5d2.json.ots b/ots/0005-9bcc4ff6b3fad5d2.json.ots index aba2a7ca3bee676396c064dd945e7c4e35905834..f1025be9c4b5ec2a8a3f4afc971fc998cf0a15f7 100644 GIT binary patch delta 4359 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 CRuHiO delta 33 rcmV++0N(%kCcFiZrje&Olj;O@lVAlllc5QXlP?N0lh+PLlO7K({7MZU diff --git a/ots/0006-770683e59da14f45.json.ots b/ots/0006-770683e59da14f45.json.ots index 08a979bcf69c33fc2151e09b3313fdf5dd524a8c..65cae664a89066a7459e4e2ac59dc0b5082a671d 100644 GIT binary patch delta 4359 zcmY+HbyQSq7l&ua0YnfGK^iF$MM64<8XA$Bp+^A;X(eRtJ5+Yr+4%=c6#!6^!P)7Z@M@MgSMZiRK@Ai__B*`J~2Ii61}X z@OVHYLo4INLS(d86!yBUEOF$47!GSY^UZuU{AQW*c~m?ffGjPrLfQ(ylL$;m;7&c& zV!2-XaUAKs`~q>?TSiQtF93z&t6-(^v6!rnE%a%jjohUZkWV&AwOf|^flJY0(68)~ zfToBY)5`nXUy`x1Y7twI`yJ7Eg!`~9-bbGF1?O8;8nLABvwE ztmNWzw{0^d$9_;-{S+PpkRu*T>sInP>(qtRc8|RqX=SG@QmMJC@N9S(3S+W7ZWOD-wz+J9&Q|eJ|&B!Lwx9_jsz7%=%AXYP10e0y#TBKPOOr1l|FG&_n39 zT|1tTOlCL*aLIi!ezr6F>?lp^Vh#A=bYR~i$}77JfdOqGdM`)VoRJPd_6rS6O&lH3 z(dEgH^-o4hb&~2;;JQ;VU^h_Z0e$N^DCR3s=$NEho8Ggc=eHQN@AuzNMjIN=CUew- zP|j%xt!SkJg;83G02e_4g{WPx+{=D5&$Ly_^rbRxWw02_#{2Y6k}~}aHihuuz8dWD z3_JoyC>V5%?1+8DZ}%JY*jJWF(hY4#WQ*1%35NW6orTB9H9D^#vs*D&1*c@SZ9q|V zVp7dk!8FU7e0##*oiGDeH_b;~zf)O}CpneE#hVCv%0AA! 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 Aa{vGU delta 33 rcmV++0N($zCiVo7rje&MljsCTlQ0D{lh_GIlOPH$lY0(ylkg5W@$?Nh diff --git a/ots/0007-2b5ed02908832f0c.json.ots b/ots/0007-2b5ed02908832f0c.json.ots index 176a3b3147f441457dac020c85b42c6d2d4a4a79..6d8f4f0b176bf131025171c69da0e16b6b474840 100644 GIT binary patch delta 4357 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 diff --git a/ots/0008-070e797b855dce92.json.ots b/ots/0008-070e797b855dce92.json.ots index 1156f28f0bda689162a85d9ffe63d6bf2f4951bd..525e5e2e676be59cf37b66590cfcf53f5ec09304 100644 GIT binary patch delta 4358 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 delta 33 pcmdn2_Kjsi#l%Y8$yZq1C(E!JPd>s~I@yZLVsb5C$>dLbW&q~Y4C?>@ diff --git a/ots/0009-995768a31dd8fa6d.json.ots b/ots/0009-995768a31dd8fa6d.json.ots index dd30506b0bd8ec867e8a9c55d8611377f30f426b..912f5887110b6703196e12a45aee35a2779113cb 100644 GIT binary patch delta 4359 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 diff --git a/ots/0010-6ba8c08f34189164.json.ots b/ots/0010-6ba8c08f34189164.json.ots index 00bc5b51fe0af7d3f7179c5a29e8632191b3ee16..3b565b0420ee1fc47c37b513fe7f5fd96eba6016 100644 GIT binary patch delta 4359 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 diff --git a/ots/0012-3a5ef7eeee484370.json.ots b/ots/0012-3a5ef7eeee484370.json.ots index 849d7094dccbf9bcd5d5d495082a2ec21e8f7c4f..cbbec166551c8666bfc07045fc38b3f805ed55e1 100644 GIT binary patch delta 4358 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 delta 33 rcmV++0N($cC*K8-zLCEzlNtqelaK{AlhFxklROGDljII`lUWZq?J^Aq diff --git a/ots/0013-d47323bbaacda2d1.json.ots b/ots/0013-d47323bbaacda2d1.json.ots index ef93c10261fe2400827ec49fc609b8d63ad0d787..2ed1bb34db00b1ee222f70de943fe0e4c750591b 100644 GIT binary patch delta 4362 zcmY+HcTkf{xQ9bc5D|ok6zMG@iXt6Eh;)!%Lyrnlq$^b-i1Z@Ty8;KK_g<7H(pwM+ z9R(@UAxLQV`}Ewo=l+w)H~Y-{rhB?~ zK6}8=RJ4{~tLdk|VvwmOUW%eWSl`+Q5Xx2V%<^xKPt>sz6qXWR*=uT!k)AA324({U zCzm*?9sr?;)U~;H_u_cESYSKs+(-l&M}0yAoCX39Joo;X5qFeH}xo3QyO;ai>|S zURi+Q=!BS$X~SHx+4Wc8~&&l zV1|5d*W)z>r=lMSzh!lnXg?PbI2%9<(S8r=VCw#4oDCoz6MQP>l9pKuGZr3Mv=R1v z?Y7P#X)Pl^V$rEaE`&;8+IJYFD-KwX1%`;fMjU>liM`%wB%9@0kI|+6MVhXCZ4f{f zclHL{3w!4E{3GHG$muZ?j#iiPvMqBAy0o`d`LJ+Xpwg7{`!(B-ZWL6`mK>T8@(Y=a zUsEnTzjCWI6Zi$I2s#nEOgjQw^z@dAI?yZg@TA|}K zJOqG&K+ew3&xuL&i2r=hL*VV)o(0pk(g@C8q-H!Drl_7)K<4itKycczZ`r~ zo-)jg@l{5XaqVJrJXvUWL1wffrt|kb*7234gMimsoe+eo#~x4q=q^V@whe~%38^Vq z>}JRJ-O$V5HJg7cD3{)0?_ccGGg6R=rWMdA@6Si|$ATh0=G4r+hH;4fkbutQ7vGIl zwm@y&yPwpor=OSzbF7YoObb*nqDo)~9~#7ji9u#a zykcwlOi{M{xD-=Oc5m0N)ha_joJnu;h2otH){dSUU_&#xp@VKZYECXnwZ>~>?(hK< zd@lQ==DtVHJ6da@+tggQP1<0%e8#Hos?QnqzF&o%k(Ycs2@5(N-d zZ;D>4wPbcpr>T}6kny$(GW%97L`-dGkTEwcH*rCy+3WPB&wbcF$>Jhbc^_nCT_oHB znso@DPp$c3hGok#4irTNEVau!;XMti6IyjJ7=$b&fy!;1k%**vN%DLFTIgDCnR@4YKvLPoNdxX*`7Lp)20ZPCU zT;?YKLwm2Wq3Hvz@-{t}^XvmHnK$kU{xw*~8K$TW!177ZVVscyGq4)ek^foVr{EtC z-^sP3)sIFn-i>#uuW70QX0{Bi*zFU6Z?47Zw4V!aBuh5#ME4|5lHdEv?wE%808|R^ zI&1y$m=XR_&x`M?fRx2HR|oWgnE$?dSRX2M-*^nLD9&Ys(X4PID-X#1B@e^bMn0O% zs$Elqme#DNW3E`-ody`2PmXUVFtOeKVdTi6r#Q=G9$hVO$@g}gUicz!)-SFA5`wZY zoi`4dbvAame0i=GmX;eO)3MM7^TN97wQDb60mM~dDD6tsC+rB-YlSEzucgNNz)$UG zvBAevex-SC#qT3SM_a52ugt3g>I15_Aw0NIf8ch~B@2f(tXJ3=<1~h+T21);h zCctmt&VUOi^+w>9mp|HTD&|}3@C3R79T}cy{3lsX!I)7i`STwF#Efa7i{+lv&e^O?LonaFi^u|$~6+;M(!CW(%dZjkb>tv4d3V{#eF7-rf(&p)p@;{Nvt1$r`i)EW}lE;hNB`}?>8oI zQ?-fYx9xA%3;q<Iul`aVpqDdDj>H>p56&`wktT zI0{)z)K3RDAB}m|=wqcphUYHv)2$O9m#WIIjXjJP5AQ1d;}oBmEXV8~pcA}1b_Z z>=i?8!&BEO`N4tE#fX-ZuEN7^tE4sJn}9_^%I0!WciXKP!s7-i3#J$*$vlk92}v}S z7p~@!cNU%*&}iV>&bzc_)4lfZmdlE0j=kJi?8f}6hHLkx<)&urw1F!-Hh5#%Z)((V zZ_)MSks;J?=TdmDbgjD?tRE$9p>FvRYUd3p9*)X7Agxrgg~| z_NxHMs4|_|R=Kh|3OBBQ(|boC-n#zfuHfT7%J*Vim~HdE44~4p))Jz}4-B0fk}XLZ z5&8oJySOm5@GHIqB(^Cj%PIjtXgAhTyLSfJj@O%#`=E#NM>0afX>D4TXBK_z(Y@*0 z0AjUaG_Byg$E-Zc-(-GzFL))U;0+VCQ1R_=R1p-U??Bvg(|9c7HCHwOMq=T3-T{_Ota*NZHTTszeZ?-Jalm7pu zL;k;akjr}cwgDbP=e{j~xqTt#+~5}oA${S)=;V^GX+_U6JI6Gq81=ojoIi2Y*4`(Uk2StH z3o=*V1Zlq%HDpYRS67J3kX8*UP!IksfTEK2(3~PmVu%Kr%JmqMgZV&%8Xm!mKXEVU ze%(EC-VsczNU9N2*o{7+04MgF^2A|q?&^WjN ziptmR$1fV)Yfn2Gm!8zTZ&p~>zQ^SY zepB>VljP3f{}KqRa5vimi@6ir*}~t8D6cRLHz-Mu)aONO-XMJ#_gu)YGnVe{*1drL zrb7Xc4p5Q?ARSssM8SuS_?wa@Ub3cRqu5xU>eT*6hy{bB8EVce=Fy9L9h^iD0fZ%8 zgVvF9AT(!pvT8HLbF?O3|I*X?mj)i3-)2QoMZo}aD=Z!tuhLQdqKc~>kE^xTNi2EB zZdp%)m*kpIpFRdp5*ZDO^7vQjw6uAP)~2!26SlZD%w{YzblTR*j7}&Y(+x0_#j|6v zi5c@L4iP_w70c9q)0>~3mfcTmj!gLQ4;GIHDoN8xJFt)%?3aYRYp+Nc)fL4CSA73D7|Hxsb$cf|VX zGbA2nzx|VD*e$mwd z#+)y85C1P6YR+0Ya})*AT_7JZA~OUsADxpgy|v;9+yyR*LqR%@&?00 z?E&p1w7&jq5=SEleLM}M6Rwt}G)OBG;3h7j6t?M;c{5<@p0-Anv0T9;5AJ=Ijm(U% zZ$rT`nFY)9kov zoY#iAA~<#L;h(!hZ9-z*cF{D;y8XO>v8bZCQtI;0Fe?n>pp~v)p=9}eP}F*Io-gq0 zM76+J@IGbgu24!)is@E$ zRe{7*GB;lW=&ATH?-UvFiWG^Vuy+_9DQcU#8Ry3@>VDOh=oMoub3X$pYLv+=nT!q< zn=ouSlEh7%x!E<$vWbJR9&-IpFUv`dtrZRH4 zeV1nSiEvsG=$S)Nzua7(3!aBDOFa?gtPcIvfNoYs1Qf`jhv>rTP>TOehr%Vv{{f5r B4}Jgu delta 35 tcmV+;0NnqyDfb7k1Obx;0W*`W1+bG|1}&4h3cHha3pkUw54w|c5H{+E4Ke@# diff --git a/ots/0014-bd12e9e3e79a5529.json.ots b/ots/0014-bd12e9e3e79a5529.json.ots index 3d6afc193337bc05d7b796db88070cd929726a87..756e605f838502890cd0614c3edfbdba7283d1d8 100644 GIT binary patch delta 4357 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 delta 33 rcmV++0N(%kCcFiZn~|I?llKI>ld}alllBQxlSv9TlZp;TlhF<{311FK diff --git a/ots/0015-fdcfd0e570214f6b.json.ots b/ots/0015-fdcfd0e570214f6b.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..bd5d740eba7dd3c9367d230a83f409bdff54b4e9 GIT binary patch literal 700 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd({+_?^v_R26d-nS!J^l|v zo43z?lW<|8CSO|Kj#Mu0fBYW=lG1E!!=o~#9=-EDeJplG9moF<0!l9I&pFDspIQ01 zok3gwc^JnB1@Cu%&m1likNIDvaP<6+%drkz?VHjSJ&UF@+s$4k#oo{HktJ(}o8AWw z8>36_=G?C2V`#qrm{;#7PoJ_bJ}MYqb9_Eon`@R=a!`KvIo6Z=W0Y%m9MR1$Y;|== z?&dNFI;*6R`ElMpKKJFp%DG@o4Vp&Hr|2~i(|qNXX$?6m@QVAd8@sm z2ktCQ4VbgiGV}6MfzE)rEVU%HC@-_5vK;6>h~qf=FF1WQI~dBq@j;+4VcCVCzPOGb zp=>Lj&wkVibVQBN84dlbl2%V&q;3xU_G?YasvS#(=T@$Jv*yZ*5RLYyKqZTux=#z= zyLhbW!Ond%{w_ECw@Gu$$7W}dT;(fg)TbWU3lBb%BbP#cT(vz3cc-2%%$1U?s-mvt>WxA3_ZU+1PF7V!+@(-xwmG$Pz&aOGz9KUdWSh1y!r=7oKue}$?V3 zzB(bT{+q?`$jxZC{mtG%9ToF8)X%PbR?4@9<0DJf3^)A`9QP7>cCDKt@4(P}|1q!L zPo6$ajf|3#f?_Lu{j|)yywpm)n8oIhTFV1 zdbQ=2>so4Xo0XMdHYb%NBdkqMECE`WSOIhi$WD&Re+u7pr!;8;?OHs0QkuZdAKRS! zm@IE`T;Nu!uGDl+n(;M!M=6ODOTkcck2BO=N13q^Lstz8w=cp=hf05O@3Wt1$Uv2Hr$2zNqQ)*%m+q( z2|V)k@{7_xaP)AN<*Zp1JsIeX85Z39s}8Sy_erj*d7jLNt3YS832+=!i@BJ~T41Cy zQP-BwzHi@BmSq-CK0mxOJ*1{x0T_#u991{G-Eyuie3Qrx^?XU?S|f+g*ByB+ODA&v iHon5R7VgrY`Qqy)U3%68cd4E(%%zDrnaQby+zJ4t#|K>5v_bLY~*

3 z74zHAZ!Gy&`l2A_1GD9ou&@sTw(q5rbFKxyd~PMl^7KppUXK4C1f*TXltot?Pr8xu z=GzY&E>(^X3bOK+){HMUCA)CUDfnTZD&-#`7W^|kAad9Hr542k?MFF2DsTqh5{T4P z_^czB)|J$Klkf5qJ-u*Fc30td3Qfro%r!t$o*FwZzWZuTUqylQt{!j2ImH`V58s}Z ze*T#Kr-nnSpZqyKvSiJ0Gx)%vYPebCTBrDZhUWW^dG&ts^l55jl#~<{Tj}ekW#;9j zR_Y}u=A`DOBo^tVmZTQtWtLQyC+6hnCFke<|G=RwqI1@Ay1F>W2Z2{Tk*Q~!B2MOU zU6!A2Do_q|#G+rtsXT>KCP~biaq#T@ck}ivxPBw4W?f96?x83{_CO|}SD2I7udwF( z$SA1zg`ce6Jy&`2Y4*bg%lSeQBx2Wwdf$V))BjL+^5g~a#c+4(Xv5r@l%J%RRFaHv zXg)BmO5ky&mtT|)3~F~b5iwncUiZrp_ER2SO;ejqERVQ3=2kwvp7#On zKGBKF|M!QUuzku&b`MDoBZi^^=kdCY40tR4xoV4(j@b$76 z-d7~A&X3Lj+HUf5=PQdvFQ4$vIV+G6FK%zCWb!9ARbQN&Q~`QuE1YEja#v5O7@ieEH0@fVaxr zu_q^(1-Ec~RFGB)?Twt-yq-Jd&5`pT|84!ePV#~AlZdU>cXk=;X>Yj7@j)SH%2j@L zzv>A(TV?NalqP8;&*R?uvNUb&oM*2#TwZ#4A;(9StQl^GA2_}(N=of_H;ZLxzWOFe{=tS90ehxO{|J`3b?{+urLUcF z<1(O<31xGNmy%@xWMjtpLAL~sQ=HH?RKQ%RA{!Giv z%S#1@JS;L&OHzySGD|AUfl&gAj}IIt7P;5%+^wn)jE-}k>ii>L@A-M*k45Lj?_c?W zKAExUn85NXm=NnI@&O2Cgmp)iVOhgk#NEQ literal 0 HcmV?d00001 diff --git a/ots/0019-01d2f0bfb2ebff75.json.ots b/ots/0019-01d2f0bfb2ebff75.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..88dd57eb1972012d4bab59269eaf521ab7fc1690 GIT binary patch literal 700 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(7%zR;zv=b=(ro9zi+^Y9 z@#?(ZI=^53_o=d1v-utN7`*!+@LTR}2-lj=-|RdKRFx{&q&fb75HQrgRa0^7w^F>T z#D1rkydxYR6gGZpd$(qW>aJ5PAv1cSw>3@cusZx(NaN5ckII}M`O)iuN|LKul|S#^ z{Kc;Lg;2?qRnJ2w8rZeGFY$ie5-GIwbOjs7N0zJ^ZpI%tDu0|g^ny`xIz#jQ$Gmz! zdHOUpGD=DcimmkZ(=zk&QY-b66LV7YQWA^wQcF^c@-j;*%M){Q^pf*)|9{}HneyT9 zx_LWaaeNTieeBC6*ZHlh@&oFcZm(0F#_>^M@}`I0eUXb#UALWa=46wMjUWH*r;%4u zUz(e2%M_hpvJ>de+i#@SFwXn`SB8C;d%2sgN6fOOd};euvAchMnZJ)aBL{cqv-Lu| z&$R8&hPzWw7v|2yoXq4@y`++4gj4f@QBeYq3cdWIbf9A+?&h}LDc%_lbnJujDbuzu z{8bZtNXPl*2DQ&Xm;5fU3|$dbM>RRFd`~u;_Rh@0_?dr^AdQBUFy$)o0E%Vz?)LC~YBQ nlHGN<^IA(6Z4_zwkp*|2vJ%{R$U&5xSOSd4#0p>p=jQ?diLpTv literal 0 HcmV?d00001 From a519cfbf16049bff57ea791edf6eba29b7768eca Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 2 Sep 2026 13:10:29 -0400 Subject: [PATCH 12/22] Record PR 229 completion plan Co-Authored-By: Claude Fable 5.1 --- PROGRESS.md | 80 +++++++++++++++-------------------------------------- 1 file changed, 23 insertions(+), 57 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 5d9783e..2052605 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,66 +1,32 @@ -# OpenTimestamps anchoring rework +# PR #229 completion ## State -Implementation and local proof verification are complete. Delivery is blocked -on establishing or authorizing a GitHub publication path for protected `main`. +Work is in progress on the direct-push publication workflow and the pending-proof +classification fix. The worktree is on `ots-anchor-main` at `03b27674`, matching +the locally cached `origin/ots-anchor-main`; GitHub DNS is currently unavailable. ## Done -- Confirmed the worktree is clean and starts at `origin/main` commit `ff3efd3`. -- Read the repository agent rules and accepted the Chronicle/Microcosm boundary. -- Read PR #182 at `545cfe56`: the anchoring tool, eight fake-client tests, - documentation, and all 15 Bitcoin-complete proof blobs for manifests 0000–0014. -- Read PR #183's workflow at `87f21f2` and the Fable+Sol gate verdict. -- Recorded the four gate defects to fix: impossible journal direct publication, - mutable journal code receiving write credentials, verification after push plus - skipped complete-proof binding checks, and stale rebased outputs. -- Read the journal branch's `scripts/receipt_pins.py`, immutable - `releases/README.md`, and `README.md`; confirmed `ots/**` is outside both - `gate_surface` and `data_surface`, while `releases/**` must not change. -- Attempted `gh api repos/PolicyEngine/chronicle/rules/branches/main`; GitHub DNS - is unavailable in this lane, so no publication-path assumption has been made. -- Re-read Sol's completed #182 verdict and addressed its output-spoofing, - local-state, backup-loss, and symlink-escape findings in the trusted tool. -- Copied the 15 proof blobs for releases 0000–0014 byte-for-byte from `545cfe56`. -- Added `--manifests`, complete-proof binding re-verification, a testable - `ots/`-only change guard, and 15 hermetic fake-client tests (all passing). -- Restored the original proof on upgrade timeout as well as explicit client - failure, closing the independent core review's only finding. -- Queried GitHub through the authenticated connector: `main` is protected, - required-status-check enforcement is off, and repository plus parent ruleset - lists are empty. The integration cannot read classic protection, so direct - `GITHUB_TOKEN` publication is not established and is never attempted. -- Added main-owned proof documentation, the root README overview, and explicit - focused OTS lint/test steps without narrowing main's existing full CI suite. -- Replaced the provisional direct-push workflow with a fixed bot-branch PR - publication path. It queries effective rules in the run log, imports only - manifest-matched proof files from an earlier retry, re-verifies them with - `main`'s script, uses a bounded fetch/rebase/rerun loop, and invokes - `gh pr merge --auto`; it never pushes the journal or `main` directly. -- Ran the required focused suite: 15 tests passed; `ruff check .` passed. The - repository-wide format check reports the same 14 pre-existing files on - `origin/main`; both added Python files pass the focused format check. -- Added `/tmp/journal` at `origin/codex/thesis-ledger-facts` and verified all 15 - carried proof bindings against its real manifest bytes. Full `status` reports - exactly manifests 0015--0019 as unanchored. Calendar DNS is unavailable, so - their missing proofs could not be stamped in this lane. -- Confirmed all 15 committed proof blob IDs match `545cfe56` byte-for-byte and - that the revised workflow passes `actionlint` (including ShellCheck). -- Closed the first PR-path review's race findings: refresh the journal before - importing retry proofs, reject divergent main/bot proof edits, detect main - movement before and after the bot push, reuse an unchanged verified bot head, - and accept merge/auto-merge only when the PR still names the verified SHA. -- Final publication review confirmed two external prerequisites cannot be - established in this lane: repository metadata currently has - `allow_auto_merge: false`, and the Actions setting that permits - `GITHUB_TOKEN` to create pull requests is unreadable. Direct token updates to - protected `main` also remain unproven because classic protection is unreadable. +- Checked for another `ots-anchor-main` process; this sandbox denies process-list + access to both `pgrep` and `ps`, so no process result was available. +- Attempted the required `git fetch origin`; DNS resolution for GitHub failed. +- Confirmed local `HEAD` and `origin/ots-anchor-main` both resolve to + `03b27674028a0d8cf5bdc71437f5944661f3c6cb`, then reset the worktree to that + remote-tracking ref. +- Read the inherited progress log. Attempted to read PR #229 with `gh`; the same + GitHub DNS failure prevented access, so the supplied dispatch facts and PR-body + requirements are the current source of truth. +- Accepted the Chronicle boundary and the prohibitions on changing `releases/`, + `ledger/`, or proof bytes. ## Next -- Obtain the exact `main` protection/effective-rules output proving direct - `GITHUB_TOKEN` updates are allowed, or authorization to enable repository - auto-merge and Actions-created pull requests (or to use an approved App/PAT). -- Then finalize the selected workflow, write `out.md`, push the branch, and - open the superseding PR. +- Reproduce and fix pending/complete/mismatch classification with hermetic + fixtures for the captured OpenTimestamps 0.7.2 output. +- Replace bot-branch/PR/auto-merge publication with a bounded, non-force direct + push to `main`, and update documentation. +- Run focused tests, lint, formatting, actionlint, guard checks, and real-proof + status against the journal checkout. +- Update this log and `out.md`, edit PR #229 when GitHub is reachable, and push + `ots-anchor-main` without merging it. From cfb8af29c849445b83d0d99efa5bd7f5268241ce Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 2 Sep 2026 13:19:32 -0400 Subject: [PATCH 13/22] Classify real pending OpenTimestamps proofs Co-Authored-By: Claude Fable 5.1 --- PROGRESS.md | 22 +++-- scripts/ots_anchor.py | 43 +++++----- tests/test_ots_anchor.py | 180 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 213 insertions(+), 32 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 2052605..4a43555 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,9 +2,10 @@ ## State -Work is in progress on the direct-push publication workflow and the pending-proof -classification fix. The worktree is on `ots-anchor-main` at `03b27674`, matching -the locally cached `origin/ots-anchor-main`; GitHub DNS is currently unavailable. +The pending-proof classification fix is complete and locally verified. Work is +in progress on the direct-push publication workflow. The worktree started from +`ots-anchor-main` at `03b27674`, matching the locally cached remote-tracking ref; +GitHub DNS is currently unavailable. ## Done @@ -19,11 +20,22 @@ the locally cached `origin/ots-anchor-main`; GitHub DNS is currently unavailable requirements are the current source of truth. - Accepted the Chronicle boundary and the prohibitions on changing `releases/`, `ledger/`, or proof bytes. +- Fixed proof classification to inspect local `ots info` structure first, give + any Bitcoin block-header attestation precedence over leftover pending + attestations, and then validate binding from the exact 0.7.2 output contract. +- Replaced permissive verify-output recognition with exact/full-line parsing: + the exact mismatch line is the only mismatch, captured pending and paired + Bitcoin-disabled/manual lines bind successfully, and unknown output fails + closed. +- Updated the hermetic fake client with the captured four-calendar pending, + mixed complete, mismatch, upgrade, and unrecognized outputs. All 17 focused + tests pass; focused Ruff lint and formatting checks pass. +- Ran real status with the cached 0.7.2 executable. All 15 complete proofs were + recognized, then calendar DNS errors prevented binding classification for the + five pending proofs; the fixture suite covers their supplied captured output. ## Next -- Reproduce and fix pending/complete/mismatch classification with hermetic - fixtures for the captured OpenTimestamps 0.7.2 output. - Replace bot-branch/PR/auto-merge publication with a bounded, non-force direct push to `main`, and update documentation. - Run focused tests, lint, formatting, actionlint, guard checks, and real-proof diff --git a/scripts/ots_anchor.py b/scripts/ots_anchor.py index 9c91719..5ac4186 100644 --- a/scripts/ots_anchor.py +++ b/scripts/ots_anchor.py @@ -50,20 +50,17 @@ MANIFEST_NAME_RE = re.compile(r"^(\d{4})-([0-9a-f]{16})\.json$") SUBPROCESS_TIMEOUT = 300 -# Full-line patterns observed from opentimestamps-client 0.7.2. Calendar URLs -# and errors are untrusted text, so substring matches are intentionally unsafe. -_MISMATCH_LINE_RE = re.compile(r"^\s*File does not match original!?\s*$", re.MULTILINE) +# Full-line output observed from opentimestamps-client 0.7.2. Calendar URLs and +# errors are untrusted text, so substring matches are intentionally unsafe. +_MISMATCH_LINE = "File does not match original" _VERIFY_PENDING_LINE_RE = re.compile( - r"^\s*Pending confirmation in Bitcoin blockchain\s*$", re.MULTILINE + r"Calendar \S+: Pending confirmation in Bitcoin blockchain" ) _VERIFY_MANUAL_LINE_RE = re.compile( - r"^\s*To verify manually, check that Bitcoin block \d+ " - r"has merkleroot [0-9a-fA-F]+\s*$", - re.MULTILINE, -) -_NO_NODE_LINE_RE = re.compile( - r"^\s*Could not connect to Bitcoin node(?:[.:].*)?\s*$", re.MULTILINE + r"To verify manually, check that Bitcoin block \d+ " + r"has merkleroot [0-9a-fA-F]{64}" ) +_VERIFY_BITCOIN_DISABLED_LINE = "Not checking Bitcoin attestation; Bitcoin disabled" _UPGRADE_PENDING_LINE_RE = re.compile( r"^\s*(?:Failed!\s*)?Timestamp not complete\.?\s*$", re.MULTILINE ) @@ -284,22 +281,29 @@ def upgrade_proof( def verify_proof_binding( manifest: pathlib.Path, proof: pathlib.Path, ots_bin: list[str] ) -> bool: - """Return false only when ``ots`` proves the file digest is mismatched.""" + """Return false only for the client's exact digest-mismatch output.""" completed = _run_ots( ots_bin, ["--no-bitcoin", "verify", "-f", str(manifest), str(proof)] ) output = completed.stdout + completed.stderr - if _MISMATCH_LINE_RE.search(output): + lines = completed.stdout.splitlines() + completed.stderr.splitlines() + if _MISMATCH_LINE in lines: return False - if completed.returncode == 0: - return True if ( - _VERIFY_PENDING_LINE_RE.search(output) - or _VERIFY_MANUAL_LINE_RE.search(output) - or _NO_NODE_LINE_RE.search(output) + completed.returncode == 1 + and lines + and all(_VERIFY_PENDING_LINE_RE.fullmatch(line) for line in lines) ): return True + if completed.returncode == 1 and len(lines) >= 2 and len(lines) % 2 == 0: + pairs = zip(lines[::2], lines[1::2]) + if all( + disabled == _VERIFY_BITCOIN_DISABLED_LINE + and _VERIFY_MANUAL_LINE_RE.fullmatch(manual) + for disabled, manual in pairs + ): + return True raise AnchorError( f"unrecognized ots verify outcome for {proof.name}: {output.strip()}" ) @@ -310,9 +314,10 @@ def classify_proof( ) -> str: """Classify a locally stored proof after checking its exact-file binding.""" + state = local_proof_state(proof, ots_bin) if not verify_proof_binding(manifest, proof, ots_bin): return "mismatch" - return local_proof_state(proof, ots_bin) + return state def command_run( @@ -346,7 +351,7 @@ def command_run( raise AnchorError( f"proof {proof.name} does not match manifest bytes; refusing to skip" ) - if proof_is_complete(proof, ots_bin): + if state == "bitcoin": continue if upgrade_proof(manifest, proof, ots_bin): upgraded.append(manifest.name) diff --git a/tests/test_ots_anchor.py b/tests/test_ots_anchor.py index fa67d9b..a4fa64f 100644 --- a/tests/test_ots_anchor.py +++ b/tests/test_ots_anchor.py @@ -23,6 +23,40 @@ import ots_anchor # noqa: E402 +CALENDARS = ( + "https://btc.calendar.catallaxy.com", + "https://finney.calendar.eternitywall.com", + "https://alice.btc.calendar.opentimestamps.org", + "https://bob.btc.calendar.opentimestamps.org", +) +PENDING_VERIFY_OUTPUT = "".join( + f"Calendar {calendar}: Pending confirmation in Bitcoin blockchain\n" + for calendar in CALENDARS +) +PENDING_INFO_OUTPUT = "".join( + f" verify PendingAttestation('{calendar}')\n" for calendar in CALENDARS +) +COMPLETE_VERIFY_OUTPUT = """\ +Not checking Bitcoin attestation; Bitcoin disabled +To verify manually, check that Bitcoin block 963242 has merkleroot 34ff137ec701d2ee72ac4f88a08ddee948932f6be2840a066198acfae077a24d +Not checking Bitcoin attestation; Bitcoin disabled +To verify manually, check that Bitcoin block 963243 has merkleroot 583d3abcc52c06fdffa5ba3d24177b6fb6f048f63733e2f79734897648827278 +Not checking Bitcoin attestation; Bitcoin disabled +To verify manually, check that Bitcoin block 963253 has merkleroot 20f7fb6f9e04f098f4cdecb138618d62d4e302a22f1e144c1e3f16c7d97fb00e +Not checking Bitcoin attestation; Bitcoin disabled +To verify manually, check that Bitcoin block 963257 has merkleroot 376fd236cf6f231bb0453fcb5b109d3dfc2bebfac2455f3e723f0a79b6919f75 +""" +COMPLETE_INFO_OUTPUT = """\ + verify PendingAttestation('https://btc.calendar.catallaxy.com') + verify BitcoinBlockHeaderAttestation(963257) + verify PendingAttestation('https://bob.btc.calendar.opentimestamps.org') + verify BitcoinBlockHeaderAttestation(963243) + verify PendingAttestation('https://alice.btc.calendar.opentimestamps.org') + verify BitcoinBlockHeaderAttestation(963242) + verify PendingAttestation('https://finney.calendar.eternitywall.com') + verify BitcoinBlockHeaderAttestation(963253) +""" + FAKE_OTS = r""" import hashlib import json @@ -31,6 +65,44 @@ import sys LOG = pathlib.Path(os.environ["FAKE_OTS_LOG"]) +CALENDARS = ( + "https://btc.calendar.catallaxy.com", + "https://finney.calendar.eternitywall.com", + "https://alice.btc.calendar.opentimestamps.org", + "https://bob.btc.calendar.opentimestamps.org", +) +BITCOIN_ATTESTATIONS = ( + ( + 963242, + "34ff137ec701d2ee72ac4f88a08ddee948932f6be2840a066198acfae077a24d", + ), + ( + 963243, + "583d3abcc52c06fdffa5ba3d24177b6fb6f048f63733e2f79734897648827278", + ), + ( + 963253, + "20f7fb6f9e04f098f4cdecb138618d62d4e302a22f1e144c1e3f16c7d97fb00e", + ), + ( + 963257, + "376fd236cf6f231bb0453fcb5b109d3dfc2bebfac2455f3e723f0a79b6919f75", + ), +) + + +def print_pending_calendars(): + for calendar in CALENDARS: + print(f"Calendar {calendar}: Pending confirmation in Bitcoin blockchain") + + +def print_complete_verification(): + for block, merkle_root in BITCOIN_ATTESTATIONS: + print("Not checking Bitcoin attestation; Bitcoin disabled") + print( + f"To verify manually, check that Bitcoin block {block} " + f"has merkleroot {merkle_root}" + ) def log(entry): @@ -61,14 +133,31 @@ def main(): if command == "info": proof = read_proof(arguments[1]) if proof["state"] == "bitcoin": - print("verify BitcoinBlockHeaderAttestation(963213)") + print(" verify PendingAttestation('https://btc.calendar.catallaxy.com')") + print(" verify BitcoinBlockHeaderAttestation(963257)") + print( + " verify PendingAttestation(" + "'https://bob.btc.calendar.opentimestamps.org')" + ) + print(" verify BitcoinBlockHeaderAttestation(963243)") + print( + " verify PendingAttestation(" + "'https://alice.btc.calendar.opentimestamps.org')" + ) + print(" verify BitcoinBlockHeaderAttestation(963242)") + print( + " verify PendingAttestation(" + "'https://finney.calendar.eternitywall.com')" + ) + print(" verify BitcoinBlockHeaderAttestation(963253)") elif os.environ.get("FAKE_OTS_INFO_SPOOF") == "yes": print( "verify PendingAttestation(" "'https://fake/BitcoinBlockHeaderAttestation(1)')" ) else: - print("verify PendingAttestation('https://fake.calendar')") + for calendar in CALENDARS: + print(f" verify PendingAttestation('{calendar}')") return 0 if command == "upgrade": path = pathlib.Path(arguments[1]) @@ -86,6 +175,7 @@ def main(): path.replace(pathlib.Path(str(path) + ".bak")) print("calendar response could not be serialized") return 2 + print_pending_calendars() print("Failed! Timestamp not complete") return 1 if command == "verify": @@ -93,18 +183,22 @@ def main(): proof = read_proof(arguments[-1]) digest = hashlib.sha256(target.read_bytes()).hexdigest() if digest != proof["digest"]: + print("File does not match original") + return 1 + if os.environ.get("FAKE_OTS_VERIFY_OUTCOME") == "unrecognized": print("File does not match original!") + print( + "Calendar https://fake.calendar: Pending confirmation in Bitcoin " + "blockchain (unexpected trailing text)" + ) return 1 if ( proof["state"] == "bitcoin" or os.environ.get("FAKE_OTS_VERIFY_RESOLVED") == "yes" ): - print( - "To verify manually, check that Bitcoin block 963213 " - "has merkleroot aa" - ) + print_complete_verification() return 1 - print("Pending confirmation in Bitcoin blockchain") + print_pending_calendars() return 1 raise SystemExit(f"unexpected fake ots command: {command}") @@ -155,6 +249,15 @@ def logged_commands(repo: dict) -> list[str]: return repo["log"].read_text(encoding="utf-8").split() +def invoke_fake_ots(repo: dict, *arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [*shlex.split(repo["ots_bin"]), *arguments], + capture_output=True, + text=True, + check=False, + ) + + def test_run_stamps_every_manifest_into_ots_dir(repo: dict) -> None: assert run_cli(repo, "run") == 0 proofs = sorted((repo["root"] / "ots").iterdir()) @@ -223,15 +326,76 @@ def test_verify_fails_when_proof_binds_different_bytes(repo: dict) -> None: assert run_cli(repo, "verify") == 1 -def test_status_reports_each_state(repo: dict, capsys) -> None: +def test_status_reports_multi_calendar_pending_proofs(repo: dict, capsys) -> None: assert run_cli(repo, "status") == 0 output = capsys.readouterr().out assert output.count("unanchored") == 2 assert run_cli(repo, "run") == 0 + proof = repo["root"] / "ots" / f"{repo['manifests'][0].name}.ots" + verify = invoke_fake_ots( + repo, + "--no-bitcoin", + "verify", + "-f", + str(repo["manifests"][0]), + str(proof), + ) + assert verify.returncode == 1 + assert verify.stdout == PENDING_VERIFY_OUTPUT + info = invoke_fake_ots(repo, "info", str(proof)) + assert info.returncode == 0 + assert info.stdout == PENDING_INFO_OUTPUT + upgrade = invoke_fake_ots(repo, "upgrade", str(proof)) + assert upgrade.returncode == 1 + assert upgrade.stdout == PENDING_VERIFY_OUTPUT + "Failed! Timestamp not complete\n" + + commands_before = len(logged_commands(repo)) assert run_cli(repo, "status") == 0 output = capsys.readouterr().out assert output.count("pending local proof") == 2 + assert logged_commands(repo)[commands_before:] == ["info", "verify"] * 2 + + +def test_status_prefers_bitcoin_info_with_leftover_pending_attestations( + repo: dict, capsys +) -> None: + assert run_cli(repo, "run") == 0 + proof = repo["root"] / "ots" / f"{repo['manifests'][0].name}.ots" + payload = json.loads(proof.read_text(encoding="utf-8")) + payload["state"] = "bitcoin" + proof.write_text(json.dumps(payload), encoding="utf-8") + + info = invoke_fake_ots(repo, "info", str(proof)) + assert info.returncode == 0 + assert info.stdout == COMPLETE_INFO_OUTPUT + verify = invoke_fake_ots( + repo, + "--no-bitcoin", + "verify", + "-f", + str(repo["manifests"][0]), + str(proof), + ) + assert verify.returncode == 1 + assert verify.stdout == COMPLETE_VERIFY_OUTPUT + + assert run_cli(repo, "status") == 0 + output = capsys.readouterr().out + assert output.count("bitcoin attestation stored locally") == 1 + assert output.count("pending local proof") == 1 + + +def test_status_fails_closed_on_unrecognized_verify_output( + repo: dict, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + assert run_cli(repo, "run") == 0 + monkeypatch.setenv("FAKE_OTS_VERIFY_OUTCOME", "unrecognized") + + assert run_cli(repo, "status") == 1 + captured = capsys.readouterr() + assert "unrecognized ots verify outcome" in captured.err + assert "MISMATCH" not in captured.out def test_manifests_option_reads_external_checkout(repo: dict) -> None: From 7e647056b8f346488efe3864d61e40c9a4c6af1a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 2 Sep 2026 13:24:27 -0400 Subject: [PATCH 14/22] Assert exact OpenTimestamps mismatch classification Co-Authored-By: Claude Fable 5.1 --- tests/test_ots_anchor.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_ots_anchor.py b/tests/test_ots_anchor.py index a4fa64f..5c4a565 100644 --- a/tests/test_ots_anchor.py +++ b/tests/test_ots_anchor.py @@ -323,6 +323,12 @@ def test_verify_fails_when_proof_binds_different_bytes(repo: dict) -> None: payload = json.loads(proof.read_text(encoding="utf-8")) payload["digest"] = "ab" * 32 proof.write_text(json.dumps(payload), encoding="utf-8") + assert ( + ots_anchor.classify_proof( + repo["manifests"][1], proof, shlex.split(repo["ots_bin"]) + ) + == "mismatch" + ) assert run_cli(repo, "verify") == 1 From 2f4b3ae5b9140bb26d6fd8584e5c4f40b92dce4c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 2 Sep 2026 13:25:23 -0400 Subject: [PATCH 15/22] Publish OpenTimestamps proofs directly to main Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ots-anchor.yml | 337 +++++-------------------------- PROGRESS.md | 28 ++- README.md | 13 +- ots/README.md | 11 +- 4 files changed, 92 insertions(+), 297 deletions(-) diff --git a/.github/workflows/ots-anchor.yml b/.github/workflows/ots-anchor.yml index 6e5bad4..0c6d715 100644 --- a/.github/workflows/ots-anchor.yml +++ b/.github/workflows/ots-anchor.yml @@ -19,24 +19,40 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 permissions: - # main is protected and a direct GITHUB_TOKEN push is not assumed. The - # proof commit is published through a bot-branch pull request instead. + # API evidence on 2026-09-02 showed no effective rules, required checks, + # reviews, or push restrictions blocking a GITHUB_TOKEN update to main. contents: write - pull-requests: write env: - BOT_BRANCH: automation/ots-anchor - GH_TOKEN: ${{ github.token }} MAIN_BRANCH: main JOURNAL_BRANCH: codex/thesis-ledger-facts OTS_BIN: uvx --from opentimestamps-client==0.7.2 ots steps: + - name: Log main publication rule evidence + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -uo pipefail + + log_api() { + endpoint="$1" + printf 'gh api %s\n' "$endpoint" + if ! gh api "$endpoint"; then + printf '::warning::Could not read %s; the bounded push remains authoritative.\n' \ + "$endpoint" >&2 + fi + } + + log_api "repos/$GITHUB_REPOSITORY/rules/branches/$MAIN_BRANCH" + log_api "repos/$GITHUB_REPOSITORY/rulesets" + log_api "repos/$GITHUB_REPOSITORY/branches/$MAIN_BRANCH/protection" + - name: Check out trusted main uses: actions/checkout@v4 with: ref: main path: main fetch-depth: 0 - # A credential is needed only to push the proof commit to BOT_BRANCH. persist-credentials: true - name: Check out journal manifests without credentials @@ -109,296 +125,53 @@ jobs: fi if [[ -n "$(git status --porcelain)" ]]; then git status --short >&2 - printf 'refusing to publish a dirty worktree\n' >&2 - return 1 - fi - } - - # Carry an existing bot PR's proof bytes across a retry, but trust no - # other path from that mutable branch. Every imported proof must map - # to a current immutable journal manifest and is re-verified below by - # main's script before it can be committed again. - import_bot_proofs() { - bot_remote_sha="" - if ! git fetch --no-tags origin "refs/heads/$BOT_BRANCH"; then - return - fi - bot_remote_sha="$(git rev-parse FETCH_HEAD)" - bot_base="$( - git merge-base "origin/$MAIN_BRANCH" "$bot_remote_sha" - )" - - deleted_paths="$( - git diff --diff-filter=D --name-only \ - "origin/$MAIN_BRANCH...$bot_remote_sha" - )" - if [[ -n "$deleted_paths" ]]; then - printf 'refusing bot branch proof deletions:\n%s\n' \ - "$deleted_paths" >&2 + printf 'refusing to push a dirty worktree\n' >&2 return 1 fi - - while IFS= read -r -d '' changed_path; do - if [[ ! "$changed_path" =~ ^ots/[0-9]{4}-[0-9a-f]{16}\.json\.ots$ ]]; then - printf 'refusing unexpected bot branch path: %s\n' \ - "$changed_path" >&2 - return 1 - fi - proof_name="${changed_path#ots/}" - manifest_path="$manifest_dir/${proof_name%.ots}" - if [[ ! -f "$manifest_path" || -L "$manifest_path" ]]; then - printf 'bot proof has no regular journal manifest: %s\n' \ - "$changed_path" >&2 - return 1 - fi - - base_blob="$( - git rev-parse "$bot_base:$changed_path" 2>/dev/null || true - )" - main_blob="$( - git rev-parse \ - "origin/$MAIN_BRANCH:$changed_path" 2>/dev/null || true - )" - bot_blob="$(git rev-parse "$bot_remote_sha:$changed_path")" - if [[ "$main_blob" != "$base_blob" && \ - "$main_blob" != "$bot_blob" ]]; then - printf 'refusing divergent main/bot proof: %s\n' \ - "$changed_path" >&2 - return 1 - fi - if [[ "$main_blob" == "$bot_blob" ]]; then - continue - fi - git checkout "$bot_remote_sha" -- "$changed_path" - done < <( - git diff --name-only -z \ - "origin/$MAIN_BRANCH...$bot_remote_sha" - ) } - # Record the effective rules in the run log. Authoring-time evidence - # established that main is protected but could not establish a direct - # GITHUB_TOKEN bypass, so publication deliberately stays on a PR path. - # Failure to read current metadata is deliberately fatal and occurs - # before any proof or branch mutation. - gh api \ - "repos/$GITHUB_REPOSITORY/rules/branches/$MAIN_BRANCH?per_page=100" \ - > "$RUNNER_TEMP/main-rules.json" - jq '{effective_rule_types: map(.type)}' \ - "$RUNNER_TEMP/main-rules.json" - gh api "repos/$GITHUB_REPOSITORY/branches/$MAIN_BRANCH" \ - > "$RUNNER_TEMP/main-branch.json" - jq '{protected, required_status_checks: .protection.required_status_checks}' \ - "$RUNNER_TEMP/main-branch.json" - main_protected="$(jq -r '.protected' "$RUNNER_TEMP/main-branch.json")" - git config user.name "github-actions[bot]" git config user.email \ "41898282+github-actions[bot]@users.noreply.github.com" - published_head_sha="" - pushed=0 - for attempt in 1 2 3; do - # A main race is resolved before every publication attempt. The - # trusted tool then stamps and verifies again against a freshly - # fetched journal tip, so neither a rebase nor a retry can publish - # a stale proof tree. - git fetch --no-tags origin "$MAIN_BRANCH" - verified_main_sha="$(git rev-parse "origin/$MAIN_BRANCH")" - git rebase "origin/$MAIN_BRANCH" - refresh_journal - import_bot_proofs - anchor_and_verify - commit_proofs + anchor_and_verify + commit_proofs + if git diff --quiet "origin/$MAIN_BRANCH"...HEAD; then + printf 'no proof changes to publish\n' + exit 0 + fi + assert_commit_scope - if git diff --quiet "origin/$MAIN_BRANCH"...HEAD; then - printf 'no proof changes to publish\n' + for attempt in 1 2 3; do + if push_output="$(git push origin HEAD:main 2>&1)"; then + printf '%s\n' "$push_output" exit 0 fi - assert_commit_scope - - # A bot-branch push cannot itself be rejected merely because main - # advanced. Check main explicitly so that such a race also goes - # through the bounded rebase + fresh journal + re-verification path. - git fetch --no-tags origin "$MAIN_BRANCH" - if [[ "$(git rev-parse "origin/$MAIN_BRANCH")" != \ - "$verified_main_sha" ]]; then - if [[ "$attempt" -eq 3 ]]; then - printf 'main kept moving after 3 verified attempts\n' >&2 - exit 1 - fi - printf 'main moved; rebasing and re-verifying\n' >&2 - continue + printf '%s\n' "$push_output" >&2 + if ! git fetch --no-tags origin main; then + printf 'push failed and main could not be fetched for retry analysis\n' >&2 + exit 1 fi - - # If an open bot PR is already based on this main tip and has the - # exact proof tree just re-verified, retain its head instead of - # force-pushing an equivalent commit and churning the PR. - if [[ -n "$bot_remote_sha" ]] && \ - git merge-base --is-ancestor \ - "origin/$MAIN_BRANCH" "$bot_remote_sha" && \ - git diff --quiet "$bot_remote_sha" HEAD -- ots/; then - published_head_sha="$bot_remote_sha" - printf 'existing bot head already has the verified proof tree\n' - else - if [[ -n "$bot_remote_sha" ]]; then - push_command=( - git push - "--force-with-lease=refs/heads/$BOT_BRANCH:$bot_remote_sha" - origin "HEAD:refs/heads/$BOT_BRANCH" - ) - else - push_command=( - git push origin "HEAD:refs/heads/$BOT_BRANCH" - ) - fi - - if push_output="$("${push_command[@]}" 2>&1)"; then - printf '%s\n' "$push_output" - published_head_sha="$(git rev-parse HEAD)" - else - printf '%s\n' "$push_output" >&2 - if [[ "$push_output" != *"non-fast-forward"* && \ - "$push_output" != *"(fetch first)"* && \ - "$push_output" != *"stale info"* ]]; then - printf 'bot branch push failed for an unexpected reason\n' >&2 - exit 1 - fi - if [[ "$attempt" -eq 3 ]]; then - printf 'bot branch remained stale after 3 attempts\n' >&2 - exit 1 - fi - printf '%s\n' \ - 'bot branch moved; fetching, rebasing, and re-verifying' >&2 - continue - fi + if git merge-base --is-ancestor HEAD origin/main; then + printf 'local proof commit is already contained in main\n' + exit 0 fi - - # Close the last main race between the pre-push check and the bot - # update. A changed tip goes around the loop and re-verifies again. - git fetch --no-tags origin "$MAIN_BRANCH" - if [[ "$(git rev-parse "origin/$MAIN_BRANCH")" != \ - "$verified_main_sha" ]]; then - if [[ "$attempt" -eq 3 ]]; then - printf 'main moved after all 3 publication attempts\n' >&2 - exit 1 - fi - printf 'main moved during publication; re-verifying\n' >&2 - continue + if git merge-base --is-ancestor origin/main HEAD; then + printf 'push failed for a reason other than non-fast-forward\n' >&2 + exit 1 fi - pushed=1 - break - done - - if [[ "$pushed" -ne 1 ]]; then - printf 'proof commit was not published\n' >&2 - exit 1 - fi - - pr_body="$RUNNER_TEMP/ots-anchor-pr.md" - { - printf '%s\n' \ - '## Automated OpenTimestamps publication' \ - '' \ - "- Publication path: bot-branch pull request into protected \`main\`." \ - "- Direct \`GITHUB_TOKEN\` pushes to \`main\` are never attempted." \ - "- API evidence: \`main\` protected=$main_protected; no direct token bypass was established." \ - '- The proof-only bot branch was pushed or reused before this PR was created or updated.' \ - "- The trusted script came from \`main\`; the journal checkout had no credential." \ - '- The complete manifest/proof tree was verified before this branch was pushed.' \ - "- Only \`ots/.json.ots\` paths were carried from an earlier bot retry." \ - '' \ - "Source run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ - '' \ - '🤖 Generated with [Claude Code](https://claude.com/claude-code)' - } > "$pr_body" - - pr_url="$( - gh pr list \ - --repo "$GITHUB_REPOSITORY" \ - --base "$MAIN_BRANCH" \ - --head "$BOT_BRANCH" \ - --state open \ - --json url \ - --jq '.[0].url // empty' - )" - if [[ -z "$pr_url" ]]; then - if ! create_output="$( - gh pr create \ - --repo "$GITHUB_REPOSITORY" \ - --base "$MAIN_BRANCH" \ - --head "$BOT_BRANCH" \ - --title "Update Bitcoin anchors for the witnessed journal" \ - --body-file "$pr_body" 2>&1 - )"; then - printf '%s\n' "$create_output" >&2 - pr_url="$( - gh pr list \ - --repo "$GITHUB_REPOSITORY" \ - --base "$MAIN_BRANCH" \ - --head "$BOT_BRANCH" \ - --state open \ - --json url \ - --jq '.[0].url // empty' - )" - if [[ -z "$pr_url" ]]; then - printf 'failed to create or find proof publication PR\n' >&2 - exit 1 - fi - else - printf '%s\n' "$create_output" - pr_url="$( - gh pr list \ - --repo "$GITHUB_REPOSITORY" \ - --base "$MAIN_BRANCH" \ - --head "$BOT_BRANCH" \ - --state open \ - --json url \ - --jq '.[0].url // empty' - )" - if [[ -z "$pr_url" ]]; then - printf 'created proof PR could not be resolved by head\n' >&2 - exit 1 - fi + if [[ "$attempt" -eq 3 ]]; then + printf 'push remained non-fast-forward after 3 attempts\n' >&2 + exit 1 fi - fi - gh pr edit "$pr_url" --body-file "$pr_body" - printf 'proof publication PR: %s\n' "$pr_url" - - merge_status=0 - merge_output="$( - gh pr merge "$pr_url" \ - --auto \ - --merge \ - --delete-branch \ - --match-head-commit "$published_head_sha" 2>&1 - )" || merge_status=$? - if [[ -n "$merge_output" ]]; then - printf '%s\n' "$merge_output" - fi - pr_state="$( - gh pr view "$pr_url" \ - --json state,headRefOid,autoMergeRequest,mergeStateStatus - )" - observed_head="$(jq -r '.headRefOid' <<< "$pr_state")" - observed_state="$(jq -r '.state' <<< "$pr_state")" - auto_merge_set="$(jq -r '.autoMergeRequest != null' <<< "$pr_state")" - if [[ "$observed_head" != "$published_head_sha" ]]; then - if [[ "$auto_merge_set" == "true" ]]; then - gh pr merge "$pr_url" --disable-auto || true + git rebase origin/main + refresh_journal + anchor_and_verify + commit_proofs + if git diff --quiet origin/main...HEAD; then + printf 'proof changes already landed during retry\n' + exit 0 fi - printf 'PR head moved after verification: expected %s, found %s\n' \ - "$published_head_sha" "$observed_head" >&2 - exit 1 - fi - if [[ "$observed_state" == "MERGED" ]]; then - exit 0 - fi - if [[ "$observed_state" == "OPEN" && "$auto_merge_set" == "true" ]]; then - exit 0 - fi - printf '%s\n' "$pr_state" >&2 - printf 'merge/auto-merge did not stick (gh exit %s)\n' \ - "$merge_status" >&2 - exit 1 + assert_commit_scope + done diff --git a/PROGRESS.md b/PROGRESS.md index 4a43555..95f6eec 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,10 +2,11 @@ ## State -The pending-proof classification fix is complete and locally verified. Work is -in progress on the direct-push publication workflow. The worktree started from -`ots-anchor-main` at `03b27674`, matching the locally cached remote-tracking ref; -GitHub DNS is currently unavailable. +The pending-proof classification fix and direct-push publication workflow are +complete and locally verified. Final repository verification, reporting, and +GitHub delivery remain. The worktree started from `ots-anchor-main` at +`03b27674`, matching the locally cached remote-tracking ref; GitHub DNS is +currently unavailable. ## Done @@ -33,11 +34,26 @@ GitHub DNS is currently unavailable. - Ran real status with the cached 0.7.2 executable. All 15 complete proofs were recognized, then calendar DNS errors prevented binding classification for the five pending proofs; the fixture suite covers their supplied captured output. +- Replaced bot-branch import, PR creation, and auto-merge machinery with a + proof-only, non-force `git push origin HEAD:main` using only `contents: write`. +- Added best-effort start-of-job logging for the three supplied effective-rules + and classic-protection API endpoints; metadata read failures warn but do not + block the publication attempt. +- Retained the credential-free journal checkout, `run` + `verify` + `guard`, + `ots/`-only staging and committed-scope checks, and dirty-worktree refusal. +- Added a three-attempt non-fast-forward path that fetches and rebases current + `main`, refreshes the journal, reruns all checks, and recommits before retry. + Failed-push classification uses fetched commit ancestry, covering server-side + ref races and ambiguous successes without parsing Git's localized output. +- Updated both proof documentation sections to say automation pushes proof-only + commits to `main` and to document bounded retries plus the guarantees against + force-pushing or pushing the journal branch. `actionlint` passes. +- Reconfirmed all 20 `.ots` blob bytes are unchanged (combined checksum + `420c3f54b47df5c58c58edc7202f580dcbad40193a4264bec20c133b71a375b1`) + and there are no `releases/` or `ledger/` changes. ## Next -- Replace bot-branch/PR/auto-merge publication with a bounded, non-force direct - push to `main`, and update documentation. - Run focused tests, lint, formatting, actionlint, guard checks, and real-proof status against the journal checkout. - Update this log and `out.md`, edit PR #229 when GitHub is reachable, and push diff --git a/README.md b/README.md index d0c4015..ee8d539 100644 --- a/README.md +++ b/README.md @@ -573,9 +573,10 @@ normalized_fact = convert_units(fact, 1000, "count") ## Bitcoin checkpoints for the witnessed journal The `codex/thesis-ledger-facts` branch's witnessed release manifests are -additionally anchored through OpenTimestamps. Trusted automation and the -mutable `ots/.json.ots` proofs live on `main`, while the immutable -manifests and journal remain on their protected branch. Each proof binds a -manifest's exact bytes into Bitcoin, giving the journal state it commits to an -external anteriority bound. See [`ots/README.md`](ots/README.md) for the limits, -cross-branch verification command, and publication design. +additionally anchored through OpenTimestamps. Trusted automation pushes +proof-only commits to `main`, where the mutable `ots/.json.ots` proofs +live, while the immutable manifests and journal remain on their protected +branch. Each proof binds a manifest's exact bytes into Bitcoin, giving the +journal state it commits to an external anteriority bound. See +[`ots/README.md`](ots/README.md) for the limits, cross-branch verification +command, and publication design. diff --git a/ots/README.md b/ots/README.md index 0291f23..d47b82b 100644 --- a/ots/README.md +++ b/ots/README.md @@ -65,6 +65,11 @@ calendar transactions confirm. They therefore cannot live under `releases/`. Keeping the proof tree, anchoring script, tests, and scheduled workflow together on protected `main` also establishes the privilege boundary. The workflow runs `main`'s trusted script against a separate, shallow journal checkout that has no -persisted credential. It verifies the entire manifest/proof tree and rejects -any worktree change outside `ots/` before publication. It never pushes to the -journal branch. +persisted credential. It runs `run`, `verify`, and `guard`, stages only `ots/`, +and refuses a dirty worktree or a committed path outside `ots/` before it pushes +the proof-only commit directly to `main` with `git push origin HEAD:main`. + +A non-fast-forward rejection starts a bounded retry: fetch and rebase onto the +new `origin/main`, refresh the credential-free journal checkout, rerun `run`, +`verify`, and `guard`, then recommit and retry. The workflow makes at most three +non-force push attempts and never pushes the journal branch. From ee99d955ea005250f05218186c6e38b0fd624c40 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 2 Sep 2026 13:31:51 -0400 Subject: [PATCH 16/22] Record PR 229 verification and delivery status Co-Authored-By: Claude Fable 5.1 --- PROGRESS.md | 26 ++++++---- out.md | 145 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 9 deletions(-) create mode 100644 out.md diff --git a/PROGRESS.md b/PROGRESS.md index 95f6eec..f2159e5 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,11 +2,10 @@ ## State -The pending-proof classification fix and direct-push publication workflow are -complete and locally verified. Final repository verification, reporting, and -GitHub delivery remain. The worktree started from `ots-anchor-main` at -`03b27674`, matching the locally cached remote-tracking ref; GitHub DNS is -currently unavailable. +The pending-proof classification fix, direct-push publication workflow, final +repository verification, and report are complete. GitHub delivery is blocked: +shell DNS prevents fetch/push/`gh pr edit`, and the authenticated connector's PR +body mutation was cancelled. PR #229 remains open and unmerged. ## Done @@ -51,10 +50,19 @@ currently unavailable. - Reconfirmed all 20 `.ots` blob bytes are unchanged (combined checksum `420c3f54b47df5c58c58edc7202f580dcbad40193a4264bec20c133b71a375b1`) and there are no `releases/` or `ledger/` changes. +- Ran the final required suite: 17 focused tests, repository-wide Ruff lint, + focused Ruff formatting, and actionlint all pass. +- Confirmed local proof structure with the real cached OpenTimestamps 0.7.2 + executable: 15 Bitcoin-complete and 5 pending. Calendar DNS prevents the full + status binding pass, so the captured-output fixture tests are the documented + no-network fallback. +- Wrote the final implementation, verification, integrity, and delivery report + to `out.md`. +- Read PR #229 through the authenticated connector and prepared the replacement + “Publication path” body at `/tmp/pr229-body.md`. The exact `gh pr edit` command + failed on DNS, and the connector write was cancelled. ## Next -- Run focused tests, lint, formatting, actionlint, guard checks, and real-proof - status against the journal checkout. -- Update this log and `out.md`, edit PR #229 when GitHub is reachable, and push - `ots-anchor-main` without merging it. +- When GitHub access is available, push `ots-anchor-main` and run + `gh pr edit 229 --body-file /tmp/pr229-body.md` without merging the PR. diff --git a/out.md b/out.md new file mode 100644 index 0000000..890ddfd --- /dev/null +++ b/out.md @@ -0,0 +1,145 @@ +# PR #229 final report + +## Outcome + +Implementation and local verification are complete for both requested changes: + +- The scheduled job now publishes proof-only commits directly to `main` with a + bounded, non-force retry path. The bot-branch import, PR creation, and + auto-merge machinery are gone. +- OpenTimestamps 0.7.2 pending, complete-with-leftover-pending, exact mismatch, + and unknown outputs are classified according to the captured real contract. + Unknown output fails closed. + +Shell access to GitHub remained DNS-blocked, so the required branch push and +`gh pr edit 229 --body-file` have not succeeded as of this report. An +authenticated connector could read PR #229, but its attempted body update was +cancelled. PR #229 remains open and was not merged. + +## Implementation + +The workflow now: + +1. Logs the three supplied `main` rules/ruleset/protection API queries on a + best-effort basis at job start. +2. Checks out trusted `main` with full history and a persisted push credential, + and checks out the journal separately without credentials. +3. Runs `run`, `verify`, and `guard`, stages only `ots/`, and refuses a dirty + worktree or any committed path outside `ots/`. +4. Runs `git push origin HEAD:main` without force. +5. After a failed push, fetches `main` and uses commit ancestry to distinguish an + ambiguous success, a non-race rejection, and a retryable remote advance. A + retry rebases, refreshes the journal checkout, reruns all three commands, + recommits, and pushes again, with three total attempts. + +The proof classifier now reads local `ots info` structure first. Any +`BitcoinBlockHeaderAttestation` makes the local state complete even when +`PendingAttestation` lines remain. Binding then accepts only the complete +captured line structures at exit 1: one or more full calendar-pending lines, or +one or more paired Bitcoin-disabled/manual-verification lines with 64-hex merkle +roots. Only the exact line `File does not match original` is a mismatch. + +## Verification + +The sandbox does not allow `uv` to initialize its default cache, so the three +requested `uv run` commands used the writable cache location +`UV_CACHE_DIR=/tmp/chronicle-uv-cache`; the command bodies were otherwise +unchanged. + +```console +$ uv run pytest -q tests/test_ots_anchor.py +................. [100%] +17 passed in 3.32s +``` + +```console +$ uv run ruff check . +All checks passed! +``` + +```console +$ uv run ruff format --check scripts/ots_anchor.py tests/test_ots_anchor.py +2 files already formatted +``` + +```console +$ actionlint .github/workflows/ots-anchor.yml +# no output; exit 0 (actionlint 1.7.12) +``` + +The requested real-proof command could not complete in this sandbox. With the +literal `uvx` invocation, `uvx` cannot write its default cache: + +```console +$ python3 scripts/ots_anchor.py status --manifests /tmp/journal/releases/manifests --ots-bin "uvx --from opentimestamps-client==0.7.2 ots" +ots anchor failed: unrecognized ots verify outcome for 0000-307cedbc91de43be.json.ots: error: Failed to initialize cache at `/Users/maxghenis/.cache/uv` + Caused by: failed to open file `/Users/maxghenis/.cache/uv/sdists-v9/.git`: Operation not permitted (os error 1) +``` + +The machine already has the exact client in a read-only cached environment, so +the equivalent direct executable was checked: + +```console +$ /Users/maxghenis/.cache/uv/archive-v0/SDUkLw8BSnRSiJdUQjAU4/bin/ots --version +v0.7.2 +``` + +Running status with that executable classified all fifteen complete proofs, +then stopped when the first pending proof tried to contact its calendars: + +```console +$ python3 scripts/ots_anchor.py status --manifests /tmp/journal/releases/manifests --ots-bin "/Users/maxghenis/.cache/uv/archive-v0/SDUkLw8BSnRSiJdUQjAU4/bin/ots" +ots anchor failed: unrecognized ots verify outcome for 0015-fdcfd0e570214f6b.json.ots: Calendar https://btc.calendar.catallaxy.com: [Errno 8] nodename nor servname provided, or not known +Calendar https://finney.calendar.eternitywall.com: [Errno 8] nodename nor servname provided, or not known +Calendar https://alice.btc.calendar.opentimestamps.org: [Errno 8] nodename nor servname provided, or not known +Calendar https://bob.btc.calendar.opentimestamps.org: [Errno 8] nodename nor servname provided, or not known +0000-307cedbc91de43be.json: bitcoin attestation stored locally +0001-916626696d034b80.json: bitcoin attestation stored locally +0002-a69272175b73c83b.json: bitcoin attestation stored locally +0003-cfae6e9b4524db6d.json: bitcoin attestation stored locally +0004-36322993cf45b6d1.json: bitcoin attestation stored locally +0005-9bcc4ff6b3fad5d2.json: bitcoin attestation stored locally +0006-770683e59da14f45.json: bitcoin attestation stored locally +0007-2b5ed02908832f0c.json: bitcoin attestation stored locally +0008-070e797b855dce92.json: bitcoin attestation stored locally +0009-995768a31dd8fa6d.json: bitcoin attestation stored locally +0010-6ba8c08f34189164.json: bitcoin attestation stored locally +0011-34319583df55ce83.json: bitcoin attestation stored locally +0012-3a5ef7eeee484370.json: bitcoin attestation stored locally +0013-d47323bbaacda2d1.json: bitcoin attestation stored locally +0014-bd12e9e3e79a5529.json: bitcoin attestation stored locally +``` + +This is the assignment's allowed no-network case. Local `ots info` inspection +with that same 0.7.2 executable reports `bitcoin=15 pending=5`; the 17 hermetic +tests reproduce the supplied pending and complete output verbatim and exercise +binding classification without a network assumption. + +## Integrity and review + +- All 20 `.ots` proof blobs are byte-identical to starting commit `03b27674`. +- Combined checksum over the sorted per-proof SHA-256 list: + `420c3f54b47df5c58c58edc7202f580dcbad40193a4264bec20c133b71a375b1`. +- No path under `releases/` or `ledger/` changed. +- All assignment commits end with the required Claude Fable co-author trailer. +- Three independent read-only reviews covered classifier behavior, workflow + races/permissions, documentation, and proof/restricted-path integrity. The one + workflow race finding was fixed; final rereview found no remaining issue. + +## Delivery attempts + +```console +$ git fetch origin +fatal: unable to access 'https://github.com/PolicyEngine/chronicle.git/': Could not resolve host: github.com + +$ git push origin ots-anchor-main +fatal: unable to access 'https://github.com/PolicyEngine/chronicle.git/': Could not resolve host: github.com + +$ gh pr edit 229 --body-file /tmp/pr229-body.md +error connecting to api.github.com +check your internet connection or https://githubstatus.com +``` + +The replacement PR body is staged at `/tmp/pr229-body.md`; it replaces “Open +items before merge” with the requested “Publication path” section and includes +the supplied API evidence. No merge command was run. From 412617a6b680e0d7e0867f2af178be52397dba11 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 3 Sep 2026 09:21:24 -0400 Subject: [PATCH 17/22] Drop the lane's root out.md; PROGRESS.md is the repo's journal Co-Authored-By: Claude Fable 5.1 --- out.md | 145 --------------------------------------------------------- 1 file changed, 145 deletions(-) delete mode 100644 out.md diff --git a/out.md b/out.md deleted file mode 100644 index 890ddfd..0000000 --- a/out.md +++ /dev/null @@ -1,145 +0,0 @@ -# PR #229 final report - -## Outcome - -Implementation and local verification are complete for both requested changes: - -- The scheduled job now publishes proof-only commits directly to `main` with a - bounded, non-force retry path. The bot-branch import, PR creation, and - auto-merge machinery are gone. -- OpenTimestamps 0.7.2 pending, complete-with-leftover-pending, exact mismatch, - and unknown outputs are classified according to the captured real contract. - Unknown output fails closed. - -Shell access to GitHub remained DNS-blocked, so the required branch push and -`gh pr edit 229 --body-file` have not succeeded as of this report. An -authenticated connector could read PR #229, but its attempted body update was -cancelled. PR #229 remains open and was not merged. - -## Implementation - -The workflow now: - -1. Logs the three supplied `main` rules/ruleset/protection API queries on a - best-effort basis at job start. -2. Checks out trusted `main` with full history and a persisted push credential, - and checks out the journal separately without credentials. -3. Runs `run`, `verify`, and `guard`, stages only `ots/`, and refuses a dirty - worktree or any committed path outside `ots/`. -4. Runs `git push origin HEAD:main` without force. -5. After a failed push, fetches `main` and uses commit ancestry to distinguish an - ambiguous success, a non-race rejection, and a retryable remote advance. A - retry rebases, refreshes the journal checkout, reruns all three commands, - recommits, and pushes again, with three total attempts. - -The proof classifier now reads local `ots info` structure first. Any -`BitcoinBlockHeaderAttestation` makes the local state complete even when -`PendingAttestation` lines remain. Binding then accepts only the complete -captured line structures at exit 1: one or more full calendar-pending lines, or -one or more paired Bitcoin-disabled/manual-verification lines with 64-hex merkle -roots. Only the exact line `File does not match original` is a mismatch. - -## Verification - -The sandbox does not allow `uv` to initialize its default cache, so the three -requested `uv run` commands used the writable cache location -`UV_CACHE_DIR=/tmp/chronicle-uv-cache`; the command bodies were otherwise -unchanged. - -```console -$ uv run pytest -q tests/test_ots_anchor.py -................. [100%] -17 passed in 3.32s -``` - -```console -$ uv run ruff check . -All checks passed! -``` - -```console -$ uv run ruff format --check scripts/ots_anchor.py tests/test_ots_anchor.py -2 files already formatted -``` - -```console -$ actionlint .github/workflows/ots-anchor.yml -# no output; exit 0 (actionlint 1.7.12) -``` - -The requested real-proof command could not complete in this sandbox. With the -literal `uvx` invocation, `uvx` cannot write its default cache: - -```console -$ python3 scripts/ots_anchor.py status --manifests /tmp/journal/releases/manifests --ots-bin "uvx --from opentimestamps-client==0.7.2 ots" -ots anchor failed: unrecognized ots verify outcome for 0000-307cedbc91de43be.json.ots: error: Failed to initialize cache at `/Users/maxghenis/.cache/uv` - Caused by: failed to open file `/Users/maxghenis/.cache/uv/sdists-v9/.git`: Operation not permitted (os error 1) -``` - -The machine already has the exact client in a read-only cached environment, so -the equivalent direct executable was checked: - -```console -$ /Users/maxghenis/.cache/uv/archive-v0/SDUkLw8BSnRSiJdUQjAU4/bin/ots --version -v0.7.2 -``` - -Running status with that executable classified all fifteen complete proofs, -then stopped when the first pending proof tried to contact its calendars: - -```console -$ python3 scripts/ots_anchor.py status --manifests /tmp/journal/releases/manifests --ots-bin "/Users/maxghenis/.cache/uv/archive-v0/SDUkLw8BSnRSiJdUQjAU4/bin/ots" -ots anchor failed: unrecognized ots verify outcome for 0015-fdcfd0e570214f6b.json.ots: Calendar https://btc.calendar.catallaxy.com: [Errno 8] nodename nor servname provided, or not known -Calendar https://finney.calendar.eternitywall.com: [Errno 8] nodename nor servname provided, or not known -Calendar https://alice.btc.calendar.opentimestamps.org: [Errno 8] nodename nor servname provided, or not known -Calendar https://bob.btc.calendar.opentimestamps.org: [Errno 8] nodename nor servname provided, or not known -0000-307cedbc91de43be.json: bitcoin attestation stored locally -0001-916626696d034b80.json: bitcoin attestation stored locally -0002-a69272175b73c83b.json: bitcoin attestation stored locally -0003-cfae6e9b4524db6d.json: bitcoin attestation stored locally -0004-36322993cf45b6d1.json: bitcoin attestation stored locally -0005-9bcc4ff6b3fad5d2.json: bitcoin attestation stored locally -0006-770683e59da14f45.json: bitcoin attestation stored locally -0007-2b5ed02908832f0c.json: bitcoin attestation stored locally -0008-070e797b855dce92.json: bitcoin attestation stored locally -0009-995768a31dd8fa6d.json: bitcoin attestation stored locally -0010-6ba8c08f34189164.json: bitcoin attestation stored locally -0011-34319583df55ce83.json: bitcoin attestation stored locally -0012-3a5ef7eeee484370.json: bitcoin attestation stored locally -0013-d47323bbaacda2d1.json: bitcoin attestation stored locally -0014-bd12e9e3e79a5529.json: bitcoin attestation stored locally -``` - -This is the assignment's allowed no-network case. Local `ots info` inspection -with that same 0.7.2 executable reports `bitcoin=15 pending=5`; the 17 hermetic -tests reproduce the supplied pending and complete output verbatim and exercise -binding classification without a network assumption. - -## Integrity and review - -- All 20 `.ots` proof blobs are byte-identical to starting commit `03b27674`. -- Combined checksum over the sorted per-proof SHA-256 list: - `420c3f54b47df5c58c58edc7202f580dcbad40193a4264bec20c133b71a375b1`. -- No path under `releases/` or `ledger/` changed. -- All assignment commits end with the required Claude Fable co-author trailer. -- Three independent read-only reviews covered classifier behavior, workflow - races/permissions, documentation, and proof/restricted-path integrity. The one - workflow race finding was fixed; final rereview found no remaining issue. - -## Delivery attempts - -```console -$ git fetch origin -fatal: unable to access 'https://github.com/PolicyEngine/chronicle.git/': Could not resolve host: github.com - -$ git push origin ots-anchor-main -fatal: unable to access 'https://github.com/PolicyEngine/chronicle.git/': Could not resolve host: github.com - -$ gh pr edit 229 --body-file /tmp/pr229-body.md -error connecting to api.github.com -check your internet connection or https://githubstatus.com -``` - -The replacement PR body is staged at `/tmp/pr229-body.md`; it replaces “Open -items before merge” with the requested “Publication path” section and includes -the supplied API evidence. No merge command was run. From 24edc0f0ef5e56991750b6f8b78df43c309c417b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 3 Sep 2026 18:59:43 -0400 Subject: [PATCH 18/22] Bind proofs by the ots info digest and match the real client's verify output The byte binding no longer depends on the shape of `ots --no-bitcoin verify` output. `inspect_proof` parses the `File sha256 hash:` line that `ots info` prints without any calendar traffic and compares it to the manifest's SHA-256; the client's own verification still runs as an independent check, its calendar chatter (`Got N attestation(s) from ...`, `Calendar : `, pending lines) is logged verbatim instead of grammar-checked, and only its exact `File does not match original!` line (opentimestamps-client 0.7.2, otsclient/cmds.py verify_command) or an exit status the client never uses can fail a proof. `verify` and `status` now report every manifest instead of aborting at the first one they cannot classify: a proof bound to other bytes or a manifest whose bytes contradict its filename shows as FAIL / MISMATCH, and a client failure as ERROR. `run` keeps stopping at the first manifest it cannot anchor. The fake client mirrors the real one: log lines on stderr, `info` output on stdout with the digest header, the mismatch line with its exclamation mark, and fixtures for confirmed, mixed, and transient-error calendar states. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 471239d45b512a5838bd32e8004eaac66fd20241) --- scripts/ots_anchor.py | 230 +++++++++++++------ tests/test_ots_anchor.py | 468 ++++++++++++++++++++++++++++++++------- 2 files changed, 552 insertions(+), 146 deletions(-) diff --git a/scripts/ots_anchor.py b/scripts/ots_anchor.py index 5ac4186..fcd3fa7 100644 --- a/scripts/ots_anchor.py +++ b/scripts/ots_anchor.py @@ -20,15 +20,20 @@ - ``run``: stamp any manifest that lacks a proof, then try to upgrade pending proofs to complete Bitcoin attestations. Idempotent; safe on a schedule. - ``verify``: check every proof against its manifest's current bytes and - report the state stored in each local proof. Exits nonzero on a digest - mismatch or a manifest with no proof. -- ``status``: list proofs and whether each is unanchored, pending locally, or - Bitcoin-complete locally. + report the state stored in each local proof. Every manifest is reported; + the exit status is nonzero if any proof is missing or mismatched. +- ``status``: list proofs and whether each is unanchored, mismatched, pending + locally, or Bitcoin-complete locally. Exits nonzero only if the client + could not inspect a proof, so the listing would be incomplete. - ``guard``: fail if the repository has any change outside ``ots/``. Requires the ``ots`` CLI (PyPI ``opentimestamps-client``); stamping and -upgrading contact public calendar servers. Verification with ``--no-bitcoin`` -checks the file binding and prints manual Bitcoin block-check information. +upgrading contact public calendar servers. The binding between a proof and a +manifest is established locally: the ``File sha256 hash`` that ``ots info`` +reads out of the proof must equal the manifest's SHA-256. The client's own +``--no-bitcoin verify`` then runs as an independent check. Its calendar +messages are logged, never parsed, and only its exact digest-mismatch line +can fail a proof. """ from __future__ import annotations @@ -43,6 +48,7 @@ import subprocess import sys import tempfile +from typing import NamedTuple ROOT = pathlib.Path(__file__).resolve().parents[1] DEFAULT_MANIFEST_DIR = pathlib.Path("releases/manifests") @@ -50,17 +56,16 @@ MANIFEST_NAME_RE = re.compile(r"^(\d{4})-([0-9a-f]{16})\.json$") SUBPROCESS_TIMEOUT = 300 -# Full-line output observed from opentimestamps-client 0.7.2. Calendar URLs and -# errors are untrusted text, so substring matches are intentionally unsafe. -_MISMATCH_LINE = "File does not match original" -_VERIFY_PENDING_LINE_RE = re.compile( - r"Calendar \S+: Pending confirmation in Bitcoin blockchain" +# Full-line output of opentimestamps-client 0.7.2 (otsclient/cmds.py). Calendar +# URLs and error reasons are untrusted text, so every match below is anchored +# to a whole line and nothing is matched inside text a calendar can influence. +# +# verify_command: logging.error("File does not match original!"), then exit 1. +_MISMATCH_LINE = "File does not match original!" +# info_command: print("File %s hash: %s" % (hash name, hex digest)) on stdout. +_INFO_FILE_HASH_LINE_RE = re.compile( + r"^File (?P[a-z0-9]+) hash: (?P[0-9a-fA-F]+)$", re.MULTILINE ) -_VERIFY_MANUAL_LINE_RE = re.compile( - r"To verify manually, check that Bitcoin block \d+ " - r"has merkleroot [0-9a-fA-F]{64}" -) -_VERIFY_BITCOIN_DISABLED_LINE = "Not checking Bitcoin attestation; Bitcoin disabled" _UPGRADE_PENDING_LINE_RE = re.compile( r"^\s*(?:Failed!\s*)?Timestamp not complete\.?\s*$", re.MULTILINE ) @@ -186,8 +191,22 @@ def stamp_manifest( return destination -def local_proof_state(proof: pathlib.Path, ots_bin: list[str]) -> str: - """Return the attestation state serialized in the local proof itself.""" +class ProofInfo(NamedTuple): + """What ``ots info`` reads out of a local proof file.""" + + digest: str + """Lowercase hex SHA-256 of the file the proof commits to.""" + state: str + """``"bitcoin"`` or ``"pending"``.""" + + +def inspect_proof(proof: pathlib.Path, ots_bin: list[str]) -> ProofInfo: + """Read the bound file digest and attestation state from the proof itself. + + ``ots info`` only deserializes the proof; it never contacts a calendar, so + it is the trusted source for both facts. The digest line must appear + exactly once and name SHA-256; anything else fails closed. + """ check_proof_destination(proof) if not proof.is_file(): @@ -196,14 +215,34 @@ def local_proof_state(proof: pathlib.Path, ots_bin: list[str]) -> str: output = completed.stdout + completed.stderr if completed.returncode != 0: raise AnchorError(f"ots info failed for {proof.name}: {output.strip()}") + digest_lines = _INFO_FILE_HASH_LINE_RE.findall(completed.stdout) + if len(digest_lines) != 1: + raise AnchorError( + f"ots info for {proof.name} printed {len(digest_lines)} file hash " + "lines; expected exactly one" + ) + algorithm, digest = digest_lines[0] + if algorithm != "sha256" or len(digest) != 64: + raise AnchorError( + f"proof {proof.name} commits to a {algorithm} digest of " + f"{len(digest)} hex characters; only SHA-256 proofs are anchored" + ) if _INFO_BITCOIN_LINE_RE.search(output): - return "bitcoin" - if _INFO_PENDING_LINE_RE.search(output): - return "pending" - raise AnchorError( - f"proof {proof.name} lists neither a Bitcoin nor a pending " - "attestation; refusing to guess" - ) + state = "bitcoin" + elif _INFO_PENDING_LINE_RE.search(output): + state = "pending" + else: + raise AnchorError( + f"proof {proof.name} lists neither a Bitcoin nor a pending " + "attestation; refusing to guess" + ) + return ProofInfo(digest=digest.lower(), state=state) + + +def local_proof_state(proof: pathlib.Path, ots_bin: list[str]) -> str: + """Return the attestation state serialized in the local proof itself.""" + + return inspect_proof(proof, ots_bin).state def proof_is_complete(proof: pathlib.Path, ots_bin: list[str]) -> bool: @@ -278,46 +317,100 @@ def upgrade_proof( return state == "bitcoin" +def _log_client_output(label: str, completed: subprocess.CompletedProcess[str]) -> None: + """Echo client output into the run log without interpreting it. + + Every echoed line is indented so that untrusted calendar text can never + start a line: GitHub Actions reads ``::``-prefixed workflow commands from + job output. + """ + + print(f"{label}: exit status {completed.returncode}", file=sys.stderr) + for line in completed.stdout.splitlines() + completed.stderr.splitlines(): + print(f" {line}", file=sys.stderr) + + def verify_proof_binding( manifest: pathlib.Path, proof: pathlib.Path, ots_bin: list[str] ) -> bool: - """Return false only for the client's exact digest-mismatch output.""" + """Run the client's own verification as an independent, fail-closed check. + + Returns false only when the client prints its exact digest-mismatch line, + which it does before consulting any calendar. Otherwise + ``--no-bitcoin verify`` exits 1 whether the proof is pending or complete, + and first asks each calendar for upgrades, printing lines such as + ``Got 1 attestation(s) from ``, ``Calendar : Pending + confirmation in Bitcoin blockchain``, or ``Calendar : `` + whose exact shape depends on calendar state and network conditions. That + output is logged verbatim and never parsed; the binding itself comes from + ``inspect_proof``. An exit status the client never uses means the + verification did not run, which fails closed. + """ completed = _run_ots( ots_bin, ["--no-bitcoin", "verify", "-f", str(manifest), str(proof)] ) - output = completed.stdout + completed.stderr + _log_client_output(f"ots verify {proof.name}", completed) lines = completed.stdout.splitlines() + completed.stderr.splitlines() if _MISMATCH_LINE in lines: return False - if ( - completed.returncode == 1 - and lines - and all(_VERIFY_PENDING_LINE_RE.fullmatch(line) for line in lines) - ): - return True - if completed.returncode == 1 and len(lines) >= 2 and len(lines) % 2 == 0: - pairs = zip(lines[::2], lines[1::2]) - if all( - disabled == _VERIFY_BITCOIN_DISABLED_LINE - and _VERIFY_MANUAL_LINE_RE.fullmatch(manual) - for disabled, manual in pairs - ): - return True - raise AnchorError( - f"unrecognized ots verify outcome for {proof.name}: {output.strip()}" - ) + if completed.returncode not in (0, 1): + raise AnchorError( + f"ots verify did not run to completion for {proof.name} " + f"(exit status {completed.returncode})" + ) + return True def classify_proof( manifest: pathlib.Path, proof: pathlib.Path, ots_bin: list[str] ) -> str: - """Classify a locally stored proof after checking its exact-file binding.""" + """Classify a local proof as ``"mismatch"``, ``"pending"``, or ``"bitcoin"``. + + The digest ``ots info`` reads out of the proof must equal the manifest's + SHA-256. That comparison needs no calendar traffic and is the binding this + tool relies on; a mismatch is final and skips the client's verification. + Otherwise the client's own verification runs as a second, independent + check that can only downgrade the result. + """ - state = local_proof_state(proof, ots_bin) + info = inspect_proof(proof, ots_bin) + if info.digest != sha256_file(manifest): + return "mismatch" if not verify_proof_binding(manifest, proof, ots_bin): return "mismatch" - return state + return info.state + + +def classify_manifest( + root: pathlib.Path, manifest: pathlib.Path, ots_bin: list[str] +) -> tuple[str, str]: + """Classify one manifest for a report without aborting the sweep. + + Returns ``(state, detail)``. ``state`` is ``"unanchored"``, ``"mismatch"``, + ``"error"``, ``"pending"``, or ``"bitcoin"``; ``detail`` explains the first + three. ``run`` deliberately does not use this: it must stop at the first + manifest it cannot anchor, while ``verify`` and ``status`` must report + every manifest so that no failure hides behind an earlier one. + """ + + try: + check_manifest_name_digest(manifest) + except AnchorError: + return "mismatch", "manifest bytes contradict its filename" + except OSError as exc: + return "error", str(exc) + proof = proof_path(root, manifest) + try: + check_proof_destination(proof) + if not proof.exists(): + return "unanchored", "no OpenTimestamps proof" + state = classify_proof(manifest, proof, ots_bin) + except (AnchorError, OSError) as exc: + return "error", str(exc) + if state == "mismatch": + return "mismatch", "proof does not match manifest bytes" + return state, "" def command_run( @@ -381,21 +474,15 @@ def command_verify( pending_count = 0 bitcoin_count = 0 for manifest in manifests: - check_manifest_name_digest(manifest) - proof = proof_path(root, manifest) - check_proof_destination(proof) - if not proof.exists(): - failures.append(f"{manifest.name}: no OpenTimestamps proof") - continue - state = classify_proof(manifest, proof, ots_bin) - if state == "mismatch": - failures.append(f"{manifest.name}: proof does not match manifest bytes") + state, detail = classify_manifest(root, manifest, ots_bin) + if state == "bitcoin": + bitcoin_count += 1 elif state == "pending": pending_count += 1 if require_bitcoin: failures.append(f"{manifest.name}: attestation not yet in local proof") else: - bitcoin_count += 1 + failures.append(f"{manifest.name}: {detail}") print( f"ots anchor verify: {len(manifests)} manifests, " f"{bitcoin_count} locally Bitcoin-complete, {pending_count} pending locally" @@ -412,21 +499,24 @@ def command_status( root: pathlib.Path, manifest_dir: pathlib.Path, ots_bin: list[str] ) -> int: manifests = discover_manifests(manifest_dir) + errors = 0 for manifest in manifests: - check_manifest_name_digest(manifest) - proof = proof_path(root, manifest) - check_proof_destination(proof) - if not proof.exists(): - print(f"{manifest.name}: unanchored") - continue - state = classify_proof(manifest, proof, ots_bin) - label = { - "bitcoin": "bitcoin attestation stored locally", - "pending": "pending local proof", - "mismatch": "MISMATCH", - }[state] + state, detail = classify_manifest(root, manifest, ots_bin) + if state == "bitcoin": + label = "bitcoin attestation stored locally" + elif state == "pending": + label = "pending local proof" + elif state == "unanchored": + label = "unanchored" + elif state == "mismatch": + label = f"MISMATCH ({detail})" + else: + errors += 1 + label = f"ERROR ({detail})" print(f"{manifest.name}: {label}") - return 0 + # A mismatch is a finding this listing exists to show; a client failure + # means the listing is incomplete, which is the only nonzero exit here. + return 1 if errors else 0 def git_changed_paths(root: pathlib.Path) -> list[str]: diff --git a/tests/test_ots_anchor.py b/tests/test_ots_anchor.py index 5c4a565..1478d76 100644 --- a/tests/test_ots_anchor.py +++ b/tests/test_ots_anchor.py @@ -1,9 +1,11 @@ """Tests for scripts/ots_anchor.py. The suite never contacts calendar servers or Bitcoin: a fake ``ots`` -executable reproduces the observed opentimestamps-client 0.7.2 output -contract (stamp/upgrade/info/verify), and every invocation is logged so the -tests can assert which operations ran. +executable reproduces the opentimestamps-client 0.7.2 output contract +(stamp/upgrade/info/verify, as read from ``otsclient/cmds.py`` and observed +against the real client), and every invocation is logged so the tests can +assert which operations ran. Like the real client, the fake prints ``info`` +output on stdout and every logged message on stderr. """ from __future__ import annotations @@ -29,24 +31,61 @@ "https://alice.btc.calendar.opentimestamps.org", "https://bob.btc.calendar.opentimestamps.org", ) -PENDING_VERIFY_OUTPUT = "".join( - f"Calendar {calendar}: Pending confirmation in Bitcoin blockchain\n" - for calendar in CALENDARS +BITCOIN_ATTESTATIONS = ( + (963242, "34ff137ec701d2ee72ac4f88a08ddee948932f6be2840a066198acfae077a24d"), + (963243, "583d3abcc52c06fdffa5ba3d24177b6fb6f048f63733e2f79734897648827278"), + (963253, "20f7fb6f9e04f098f4cdecb138618d62d4e302a22f1e144c1e3f16c7d97fb00e"), + (963257, "376fd236cf6f231bb0453fcb5b109d3dfc2bebfac2455f3e723f0a79b6919f75"), ) -PENDING_INFO_OUTPUT = "".join( + + +def pending_line(calendar: str) -> str: + return f"Calendar {calendar}: Pending confirmation in Bitcoin blockchain\n" + + +def confirmed_line(calendar: str) -> str: + return f"Got 1 attestation(s) from {calendar}\n" + + +def manual_check_lines(block: int, merkle_root: str) -> str: + return ( + "Not checking Bitcoin attestation; Bitcoin disabled\n" + f"To verify manually, check that Bitcoin block {block} " + f"has merkleroot {merkle_root}\n" + ) + + +MISMATCH_VERIFY_OUTPUT = "File does not match original!\n" +PENDING_VERIFY_OUTPUT = "".join(pending_line(calendar) for calendar in CALENDARS) +COMPLETE_VERIFY_OUTPUT = "".join( + manual_check_lines(block, root) for block, root in BITCOIN_ATTESTATIONS +) +# A locally pending proof whose calendars have all confirmed: the client's +# verify path upgrades in memory first, then reports each attestation. +RESOLVED_VERIFY_OUTPUT = ( + "".join(confirmed_line(calendar) for calendar in CALENDARS) + COMPLETE_VERIFY_OUTPUT +) +# The normal progression: some calendars confirmed, the rest still pending. +MIXED_VERIFY_OUTPUT = ( + confirmed_line(CALENDARS[0]) + + pending_line(CALENDARS[1]) + + confirmed_line(CALENDARS[2]) + + pending_line(CALENDARS[3]) + + "".join( + manual_check_lines(block, root) for block, root in BITCOIN_ATTESTATIONS[:2] + ) +) +# Transient calendar failures are logged with the URLError reason text. +TRANSIENT_VERIFY_OUTPUT = ( + f"Calendar {CALENDARS[0]}: timed out\n" + f"Calendar {CALENDARS[1]}: [Errno -3] Temporary failure in name resolution\n" + + pending_line(CALENDARS[2]) + + pending_line(CALENDARS[3]) +) +PENDING_INFO_TREE = "".join( f" verify PendingAttestation('{calendar}')\n" for calendar in CALENDARS ) -COMPLETE_VERIFY_OUTPUT = """\ -Not checking Bitcoin attestation; Bitcoin disabled -To verify manually, check that Bitcoin block 963242 has merkleroot 34ff137ec701d2ee72ac4f88a08ddee948932f6be2840a066198acfae077a24d -Not checking Bitcoin attestation; Bitcoin disabled -To verify manually, check that Bitcoin block 963243 has merkleroot 583d3abcc52c06fdffa5ba3d24177b6fb6f048f63733e2f79734897648827278 -Not checking Bitcoin attestation; Bitcoin disabled -To verify manually, check that Bitcoin block 963253 has merkleroot 20f7fb6f9e04f098f4cdecb138618d62d4e302a22f1e144c1e3f16c7d97fb00e -Not checking Bitcoin attestation; Bitcoin disabled -To verify manually, check that Bitcoin block 963257 has merkleroot 376fd236cf6f231bb0453fcb5b109d3dfc2bebfac2455f3e723f0a79b6919f75 -""" -COMPLETE_INFO_OUTPUT = """\ +COMPLETE_INFO_TREE = """\ verify PendingAttestation('https://btc.calendar.catallaxy.com') verify BitcoinBlockHeaderAttestation(963257) verify PendingAttestation('https://bob.btc.calendar.opentimestamps.org') @@ -57,6 +96,11 @@ verify BitcoinBlockHeaderAttestation(963253) """ + +def info_output(digest: str, tree: str) -> str: + return f"File sha256 hash: {digest}\nTimestamp:\n{tree}" + + FAKE_OTS = r""" import hashlib import json @@ -91,18 +135,56 @@ ) -def print_pending_calendars(): - for calendar in CALENDARS: - print(f"Calendar {calendar}: Pending confirmation in Bitcoin blockchain") +def log_line(message): + # otsclient/ots.py: logging.basicConfig(format="%(message)s") -> stderr. + print(message, file=sys.stderr) -def print_complete_verification(): - for block, merkle_root in BITCOIN_ATTESTATIONS: - print("Not checking Bitcoin attestation; Bitcoin disabled") - print( - f"To verify manually, check that Bitcoin block {block} " - f"has merkleroot {merkle_root}" +def calendar_pending(calendar): + log_line(f"Calendar {calendar}: Pending confirmation in Bitcoin blockchain") + + +def calendar_confirmed(calendar): + log_line(f"Got 1 attestation(s) from {calendar}") + + +def manual_check(block, merkle_root): + log_line("Not checking Bitcoin attestation; Bitcoin disabled") + log_line( + f"To verify manually, check that Bitcoin block {block} " + f"has merkleroot {merkle_root}" + ) + + +def verify_pending_proof(mode): + # upgrade_timestamp() chatter first, then one report per attestation now + # held in memory, exactly as verify_timestamp() in otsclient/cmds.py does. + confirmed = 0 + if mode == "pending": + for calendar in CALENDARS: + calendar_pending(calendar) + elif mode == "resolved": + for calendar in CALENDARS: + calendar_confirmed(calendar) + confirmed = len(CALENDARS) + elif mode == "mixed": + calendar_confirmed(CALENDARS[0]) + calendar_pending(CALENDARS[1]) + calendar_confirmed(CALENDARS[2]) + calendar_pending(CALENDARS[3]) + confirmed = 2 + elif mode == "transient": + log_line(f"Calendar {CALENDARS[0]}: timed out") + log_line( + f"Calendar {CALENDARS[1]}: " + "[Errno -3] Temporary failure in name resolution" ) + calendar_pending(CALENDARS[2]) + calendar_pending(CALENDARS[3]) + else: + raise SystemExit(f"unexpected FAKE_OTS_VERIFY_CALENDARS: {mode}") + for block, merkle_root in BITCOIN_ATTESTATIONS[:confirmed]: + manual_check(block, merkle_root) def log(entry): @@ -128,10 +210,17 @@ def main(): write_proof( str(target) + ".ots", {"digest": digest, "state": "pending"} ) - print("Submitting to remote calendar https://fake.calendar") + for calendar in CALENDARS: + log_line(f"Submitting to remote calendar {calendar}") return 0 if command == "info": proof = read_proof(arguments[1]) + spoof = os.environ.get("FAKE_OTS_INFO_SPOOF") + print(f"File sha256 hash: {proof['digest']}") + print("Timestamp:") + if spoof == "hash-line": + # A second full digest line must not be trusted over the first. + print(f"File sha256 hash: {os.environ['FAKE_OTS_SPOOF_DIGEST']}") if proof["state"] == "bitcoin": print(" verify PendingAttestation('https://btc.calendar.catallaxy.com')") print(" verify BitcoinBlockHeaderAttestation(963257)") @@ -150,11 +239,16 @@ def main(): "'https://finney.calendar.eternitywall.com')" ) print(" verify BitcoinBlockHeaderAttestation(963253)") - elif os.environ.get("FAKE_OTS_INFO_SPOOF") == "yes": + elif spoof == "attestation": print( "verify PendingAttestation(" "'https://fake/BitcoinBlockHeaderAttestation(1)')" ) + elif spoof == "hash-in-uri": + print( + " verify PendingAttestation('https://fake/" + f"File sha256 hash: {os.environ['FAKE_OTS_SPOOF_DIGEST']}')" + ) else: for calendar in CALENDARS: print(f" verify PendingAttestation('{calendar}')") @@ -169,36 +263,40 @@ def main(): pathlib.Path(str(path) + ".bak").write_text( "backup", encoding="utf-8" ) - print("Success! Timestamp complete") + for calendar in CALENDARS: + calendar_confirmed(calendar) + log_line("Success! Timestamp complete") return 0 if upgrade == "broken": path.replace(pathlib.Path(str(path) + ".bak")) - print("calendar response could not be serialized") + log_line("calendar response could not be serialized") return 2 - print_pending_calendars() - print("Failed! Timestamp not complete") + for calendar in CALENDARS: + calendar_pending(calendar) + log_line("Failed! Timestamp not complete") return 1 if command == "verify": target = pathlib.Path(arguments[arguments.index("-f") + 1]) proof = read_proof(arguments[-1]) digest = hashlib.sha256(target.read_bytes()).hexdigest() - if digest != proof["digest"]: - print("File does not match original") - return 1 - if os.environ.get("FAKE_OTS_VERIFY_OUTCOME") == "unrecognized": - print("File does not match original!") - print( - "Calendar https://fake.calendar: Pending confirmation in Bitcoin " - "blockchain (unexpected trailing text)" - ) - return 1 if ( - proof["state"] == "bitcoin" - or os.environ.get("FAKE_OTS_VERIFY_RESOLVED") == "yes" + digest != proof["digest"] + or os.environ.get("FAKE_OTS_VERIFY_FORCE_MISMATCH") == "yes" ): - print_complete_verification() + # otsclient/cmds.py verify_command: the digest check happens + # before any calendar is consulted. + log_line("File does not match original!") return 1 - print_pending_calendars() + abnormal_exit = os.environ.get("FAKE_OTS_VERIFY_EXIT") + if abnormal_exit: + log_line("Traceback (most recent call last):") + log_line("RuntimeError: simulated client crash") + return int(abnormal_exit) + if proof["state"] == "bitcoin": + for block, merkle_root in BITCOIN_ATTESTATIONS: + manual_check(block, merkle_root) + return 1 + verify_pending_proof(os.environ.get("FAKE_OTS_VERIFY_CALENDARS", "pending")) return 1 raise SystemExit(f"unexpected fake ots command: {command}") @@ -258,6 +356,18 @@ def invoke_fake_ots(repo: dict, *arguments: str) -> subprocess.CompletedProcess[ ) +def proof_for(repo: dict, index: int) -> Path: + return repo["root"] / "ots" / f"{repo['manifests'][index].name}.ots" + + +def rebind_proof(proof: Path, **changes: str) -> None: + """Rewrite a fake proof so it binds other bytes or carries another state.""" + + payload = json.loads(proof.read_text(encoding="utf-8")) + payload.update(changes) + proof.write_text(json.dumps(payload), encoding="utf-8") + + def test_run_stamps_every_manifest_into_ots_dir(repo: dict) -> None: assert run_cli(repo, "run") == 0 proofs = sorted((repo["root"] / "ots").iterdir()) @@ -313,16 +423,14 @@ def test_verify_passes_with_pending_proofs_by_default(repo: dict) -> None: def test_verify_fails_on_missing_proof(repo: dict) -> None: assert run_cli(repo, "run") == 0 - (repo["root"] / "ots" / f"{repo['manifests'][0].name}.ots").unlink() + proof_for(repo, 0).unlink() assert run_cli(repo, "verify") == 1 def test_verify_fails_when_proof_binds_different_bytes(repo: dict) -> None: assert run_cli(repo, "run") == 0 - proof = repo["root"] / "ots" / f"{repo['manifests'][1].name}.ots" - payload = json.loads(proof.read_text(encoding="utf-8")) - payload["digest"] = "ab" * 32 - proof.write_text(json.dumps(payload), encoding="utf-8") + proof = proof_for(repo, 1) + rebind_proof(proof, digest="ab" * 32) assert ( ots_anchor.classify_proof( repo["manifests"][1], proof, shlex.split(repo["ots_bin"]) @@ -332,13 +440,72 @@ def test_verify_fails_when_proof_binds_different_bytes(repo: dict) -> None: assert run_cli(repo, "verify") == 1 +def test_fake_client_emits_the_real_mismatch_line(repo: dict) -> None: + """opentimestamps-client 0.7.2 logs ``File does not match original!``.""" + + assert run_cli(repo, "run") == 0 + proof = proof_for(repo, 1) + verify = invoke_fake_ots( + repo, "--no-bitcoin", "verify", "-f", str(repo["manifests"][0]), str(proof) + ) + assert verify.returncode == 1 + assert verify.stdout == "" + assert verify.stderr == MISMATCH_VERIFY_OUTPUT + assert verify.stderr.strip() == ots_anchor._MISMATCH_LINE + + +def test_verify_enumerates_every_mismatch(repo: dict, capsys) -> None: + assert run_cli(repo, "run") == 0 + for index in (0, 1): + rebind_proof(proof_for(repo, index), digest="ab" * 32) + capsys.readouterr() + + assert run_cli(repo, "verify") == 1 + captured = capsys.readouterr() + failures = [line for line in captured.err.splitlines() if "FAIL" in line] + assert failures == [ + f" FAIL {manifest.name}: proof does not match manifest bytes" + for manifest in repo["manifests"] + ] + assert "0 locally Bitcoin-complete, 0 pending locally" in captured.out + + +def test_verify_reports_tampered_manifest_without_aborting(repo: dict, capsys) -> None: + assert run_cli(repo, "run") == 0 + repo["manifests"][0].write_bytes(b'{"releaseIndex": 0, "tampered": true}\n') + capsys.readouterr() + + assert run_cli(repo, "verify") == 1 + captured = capsys.readouterr() + failures = [line for line in captured.err.splitlines() if "FAIL" in line] + assert failures == [ + f" FAIL {repo['manifests'][0].name}: manifest bytes contradict its filename" + ] + assert "0 locally Bitcoin-complete, 1 pending locally" in captured.out + + +def test_status_prints_mismatch_for_tampered_manifest_and_proof( + repo: dict, capsys +) -> None: + assert run_cli(repo, "run") == 0 + repo["manifests"][0].write_bytes(b'{"releaseIndex": 0, "tampered": true}\n') + rebind_proof(proof_for(repo, 1), digest="ab" * 32) + capsys.readouterr() + + assert run_cli(repo, "status") == 0 + assert capsys.readouterr().out.splitlines() == [ + f"{repo['manifests'][0].name}: MISMATCH (manifest bytes contradict its filename)", + f"{repo['manifests'][1].name}: MISMATCH (proof does not match manifest bytes)", + ] + + def test_status_reports_multi_calendar_pending_proofs(repo: dict, capsys) -> None: assert run_cli(repo, "status") == 0 output = capsys.readouterr().out assert output.count("unanchored") == 2 assert run_cli(repo, "run") == 0 - proof = repo["root"] / "ots" / f"{repo['manifests'][0].name}.ots" + proof = proof_for(repo, 0) verify = invoke_fake_ots( repo, "--no-bitcoin", @@ -348,13 +515,16 @@ def test_status_reports_multi_calendar_pending_proofs(repo: dict, capsys) -> Non str(proof), ) assert verify.returncode == 1 - assert verify.stdout == PENDING_VERIFY_OUTPUT + assert verify.stdout == "" + assert verify.stderr == PENDING_VERIFY_OUTPUT info = invoke_fake_ots(repo, "info", str(proof)) assert info.returncode == 0 - assert info.stdout == PENDING_INFO_OUTPUT + digest = hashlib.sha256(repo["manifests"][0].read_bytes()).hexdigest() + assert info.stdout == info_output(digest, PENDING_INFO_TREE) + assert info.stderr == "" upgrade = invoke_fake_ots(repo, "upgrade", str(proof)) assert upgrade.returncode == 1 - assert upgrade.stdout == PENDING_VERIFY_OUTPUT + "Failed! Timestamp not complete\n" + assert upgrade.stderr == PENDING_VERIFY_OUTPUT + "Failed! Timestamp not complete\n" commands_before = len(logged_commands(repo)) assert run_cli(repo, "status") == 0 @@ -367,14 +537,13 @@ def test_status_prefers_bitcoin_info_with_leftover_pending_attestations( repo: dict, capsys ) -> None: assert run_cli(repo, "run") == 0 - proof = repo["root"] / "ots" / f"{repo['manifests'][0].name}.ots" - payload = json.loads(proof.read_text(encoding="utf-8")) - payload["state"] = "bitcoin" - proof.write_text(json.dumps(payload), encoding="utf-8") + proof = proof_for(repo, 0) + rebind_proof(proof, state="bitcoin") info = invoke_fake_ots(repo, "info", str(proof)) assert info.returncode == 0 - assert info.stdout == COMPLETE_INFO_OUTPUT + digest = hashlib.sha256(repo["manifests"][0].read_bytes()).hexdigest() + assert info.stdout == info_output(digest, COMPLETE_INFO_TREE) verify = invoke_fake_ots( repo, "--no-bitcoin", @@ -384,7 +553,7 @@ def test_status_prefers_bitcoin_info_with_leftover_pending_attestations( str(proof), ) assert verify.returncode == 1 - assert verify.stdout == COMPLETE_VERIFY_OUTPUT + assert verify.stderr == COMPLETE_VERIFY_OUTPUT assert run_cli(repo, "status") == 0 output = capsys.readouterr().out @@ -392,16 +561,167 @@ def test_status_prefers_bitcoin_info_with_leftover_pending_attestations( assert output.count("pending local proof") == 1 -def test_status_fails_closed_on_unrecognized_verify_output( +@pytest.mark.parametrize( + ("calendars", "expected_output", "logged_marker"), + [ + ("mixed", MIXED_VERIFY_OUTPUT, "Got 1 attestation(s) from"), + ("transient", TRANSIENT_VERIFY_OUTPUT, "Temporary failure in name resolution"), + ("resolved", RESOLVED_VERIFY_OUTPUT, "Got 1 attestation(s) from"), + ], +) +def test_run_reaches_upgrade_despite_verify_calendar_chatter( + repo: dict, + monkeypatch: pytest.MonkeyPatch, + capsys, + calendars: str, + expected_output: str, + logged_marker: str, +) -> None: + """Calendar progress and transient errors during verify never abort a run. + + The real client's verify path upgrades in memory first, so a locally + pending proof whose calendars have confirmed prints ``Got N + attestation(s) from ...`` lines, and a calendar timeout prints + ``Calendar : ``. Neither is a grammar the tool checks: the + binding comes from ``ots info``, the chatter is logged, and the run goes + on to ``upgrade_proof`` for every pending proof. + """ + + assert run_cli(repo, "run") == 0 + monkeypatch.setenv("FAKE_OTS_VERIFY_CALENDARS", calendars) + proof = proof_for(repo, 0) + verify = invoke_fake_ots( + repo, "--no-bitcoin", "verify", "-f", str(repo["manifests"][0]), str(proof) + ) + assert verify.returncode == 1 + assert verify.stderr == expected_output + upgrades_before = logged_commands(repo).count("upgrade") + capsys.readouterr() + + assert run_cli(repo, "run") == 0 + captured = capsys.readouterr() + assert logged_commands(repo).count("upgrade") == upgrades_before + 2 + assert "still pending 2" in captured.out + assert f"ots verify {proof.name}: exit status 1" in captured.err + assert logged_marker in captured.err + + assert run_cli(repo, "status") == 0 + assert capsys.readouterr().out.count("pending local proof") == 2 + assert run_cli(repo, "verify") == 0 + + +def test_verify_chatter_is_indented_in_the_log( + repo: dict, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + """Echoed calendar text can never start a log line.""" + + assert run_cli(repo, "run") == 0 + monkeypatch.setenv("FAKE_OTS_VERIFY_CALENDARS", "transient") + capsys.readouterr() + + assert run_cli(repo, "status") == 0 + err_lines = capsys.readouterr().err.splitlines() + echoed = [line for line in err_lines if "Calendar " in line] + assert len(echoed) == 8 + assert all(line.startswith(" ") for line in echoed) + + +def test_classify_reports_mismatch_from_info_digest_without_calendar_traffic( + repo: dict, +) -> None: + assert run_cli(repo, "run") == 0 + proof = proof_for(repo, 1) + rebind_proof(proof, digest="ab" * 32) + commands_before = len(logged_commands(repo)) + + assert ( + ots_anchor.classify_proof( + repo["manifests"][1], proof, shlex.split(repo["ots_bin"]) + ) + == "mismatch" + ) + assert logged_commands(repo)[commands_before:] == ["info"] + + +def test_classify_fails_closed_on_the_clients_mismatch_line( repo: dict, monkeypatch: pytest.MonkeyPatch, capsys ) -> None: + """Even when ``ots info`` agrees, the client's own mismatch line wins.""" + assert run_cli(repo, "run") == 0 - monkeypatch.setenv("FAKE_OTS_VERIFY_OUTCOME", "unrecognized") + monkeypatch.setenv("FAKE_OTS_VERIFY_FORCE_MISMATCH", "yes") + proof = proof_for(repo, 0) + assert ( + ots_anchor.classify_proof( + repo["manifests"][0], proof, shlex.split(repo["ots_bin"]) + ) + == "mismatch" + ) + capsys.readouterr() + assert run_cli(repo, "verify") == 1 + assert capsys.readouterr().err.count("FAIL") == 2 + assert run_cli(repo, "run") == 1 + + +def test_classify_fails_closed_on_abnormal_verify_exit( + repo: dict, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + assert run_cli(repo, "run") == 0 + monkeypatch.setenv("FAKE_OTS_VERIFY_EXIT", "2") + capsys.readouterr() assert run_cli(repo, "status") == 1 captured = capsys.readouterr() - assert "unrecognized ots verify outcome" in captured.err - assert "MISMATCH" not in captured.out + assert captured.out.count("ERROR (ots verify did not run to completion") == 2 + assert run_cli(repo, "verify") == 1 + assert run_cli(repo, "run") == 1 + + +def test_inspect_proof_reads_digest_and_state(repo: dict) -> None: + assert run_cli(repo, "run") == 0 + proof = proof_for(repo, 0) + ots_bin = shlex.split(repo["ots_bin"]) + digest = hashlib.sha256(repo["manifests"][0].read_bytes()).hexdigest() + assert ots_anchor.inspect_proof(proof, ots_bin) == ots_anchor.ProofInfo( + digest=digest, state="pending" + ) + rebind_proof(proof, state="bitcoin", digest=digest.upper()) + assert ots_anchor.inspect_proof(proof, ots_bin) == ots_anchor.ProofInfo( + digest=digest, state="bitcoin" + ) + + +def test_inspect_proof_fails_closed_without_exactly_one_digest_line( + repo: dict, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + assert run_cli(repo, "run") == 0 + proof = proof_for(repo, 0) + rebind_proof(proof, digest="ab" * 32) + digest = hashlib.sha256(repo["manifests"][0].read_bytes()).hexdigest() + monkeypatch.setenv("FAKE_OTS_INFO_SPOOF", "hash-line") + monkeypatch.setenv("FAKE_OTS_SPOOF_DIGEST", digest) + + with pytest.raises(ots_anchor.AnchorError, match="2 file hash lines"): + ots_anchor.inspect_proof(proof, shlex.split(repo["ots_bin"])) + capsys.readouterr() + assert run_cli(repo, "status") == 1 + assert "ERROR (ots info for" in capsys.readouterr().out + assert run_cli(repo, "run") == 1 + + +def test_inspect_proof_ignores_digest_text_inside_attestation_lines( + repo: dict, monkeypatch: pytest.MonkeyPatch +) -> None: + assert run_cli(repo, "run") == 0 + proof = proof_for(repo, 0) + rebind_proof(proof, digest="ab" * 32) + digest = hashlib.sha256(repo["manifests"][0].read_bytes()).hexdigest() + monkeypatch.setenv("FAKE_OTS_INFO_SPOOF", "hash-in-uri") + monkeypatch.setenv("FAKE_OTS_SPOOF_DIGEST", digest) + + ots_bin = shlex.split(repo["ots_bin"]) + assert ots_anchor.inspect_proof(proof, ots_bin).digest == "ab" * 32 + assert ots_anchor.classify_proof(repo["manifests"][0], proof, ots_bin) == "mismatch" def test_manifests_option_reads_external_checkout(repo: dict) -> None: @@ -447,15 +767,11 @@ def test_guard_accepts_only_ots_and_rejects_outside_changes(tmp_path: Path) -> N def test_run_reverifies_bitcoin_complete_proof_binding(repo: dict) -> None: assert run_cli(repo, "run") == 0 - proof = repo["root"] / "ots" / f"{repo['manifests'][0].name}.ots" - payload = json.loads(proof.read_text(encoding="utf-8")) - payload.update(state="bitcoin", digest="ab" * 32) - proof.write_text(json.dumps(payload), encoding="utf-8") - verifies_before = logged_commands(repo).count("verify") + proof = proof_for(repo, 0) + rebind_proof(proof, state="bitcoin", digest="ab" * 32) upgrades_before = logged_commands(repo).count("upgrade") assert run_cli(repo, "run") == 1 - assert logged_commands(repo).count("verify") == verifies_before + 1 assert logged_commands(repo).count("upgrade") == upgrades_before @@ -463,8 +779,8 @@ def test_local_state_resists_calendar_output_spoofing( repo: dict, monkeypatch: pytest.MonkeyPatch, capsys ) -> None: assert run_cli(repo, "run") == 0 - monkeypatch.setenv("FAKE_OTS_INFO_SPOOF", "yes") - monkeypatch.setenv("FAKE_OTS_VERIFY_RESOLVED", "yes") + monkeypatch.setenv("FAKE_OTS_INFO_SPOOF", "attestation") + monkeypatch.setenv("FAKE_OTS_VERIFY_CALENDARS", "resolved") assert run_cli(repo, "status") == 0 assert capsys.readouterr().out.count("pending local proof") == 2 @@ -478,7 +794,7 @@ def test_failed_upgrade_restores_original_backup( repo: dict, monkeypatch: pytest.MonkeyPatch ) -> None: assert run_cli(repo, "run") == 0 - proof = repo["root"] / "ots" / f"{repo['manifests'][0].name}.ots" + proof = proof_for(repo, 0) original = proof.read_bytes() monkeypatch.setenv("FAKE_OTS_UPGRADE", "broken") @@ -491,7 +807,7 @@ def test_timed_out_upgrade_restores_original_backup( repo: dict, monkeypatch: pytest.MonkeyPatch ) -> None: assert run_cli(repo, "run") == 0 - proof = repo["root"] / "ots" / f"{repo['manifests'][0].name}.ots" + proof = proof_for(repo, 0) original = proof.read_bytes() real_run_ots = ots_anchor._run_ots From f5e4a459244382b17380b4962d6a422016ca215b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 3 Sep 2026 19:03:17 -0400 Subject: [PATCH 19/22] Restore PROGRESS.md from main The PR replaced the repository's progress ledger with a session log for this change. Durable notes about the anchoring design live in ots/README.md. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 5f630f9210bbb3f68d8798563480bda49b1c55c9) --- PROGRESS.md | 101 ++++++++++++++++++++++------------------------------ 1 file changed, 42 insertions(+), 59 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index f2159e5..2477300 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,68 +1,51 @@ -# PR #229 completion +# Lane C5 progress ## State -The pending-proof classification fix, direct-push publication workflow, final -repository verification, and report are complete. GitHub delivery is blocked: -shell DNS prevents fetch/push/`gh pr edit`, and the authenticated connector's PR -body mutation was cancelled. PR #229 remains open and unmerged. +- Branch: `be-2025-vintages` from `origin/main` at `5c15bfd`. +- Worktree inputs are staged under `.lane-raw/` and must remain uncommitted. +- Lane C5 is complete, validated, independently reviewed, and ready for handoff. +- The requested staged C2 report is absent, but root `LANE_C2_REPORT.md` is byte-identical + to the sibling lane's staged copy (SHA-256 `4590e0dc...50f06e7`) and is the pattern used. ## Done -- Checked for another `ots-anchor-main` process; this sandbox denies process-list - access to both `pgrep` and `ps`, so no process result was available. -- Attempted the required `git fetch origin`; DNS resolution for GitHub failed. -- Confirmed local `HEAD` and `origin/ots-anchor-main` both resolve to - `03b27674028a0d8cf5bdc71437f5944661f3c6cb`, then reset the worktree to that - remote-tracking ref. -- Read the inherited progress log. Attempted to read PR #229 with `gh`; the same - GitHub DNS failure prevented access, so the supplied dispatch facts and PR-body - requirements are the current source of truth. -- Accepted the Chronicle boundary and the prohibitions on changing `releases/`, - `ledger/`, or proof bytes. -- Fixed proof classification to inspect local `ots info` structure first, give - any Bitcoin block-header attestation precedence over leftover pending - attestations, and then validate binding from the exact 0.7.2 output contract. -- Replaced permissive verify-output recognition with exact/full-line parsing: - the exact mismatch line is the only mismatch, captured pending and paired - Bitcoin-disabled/manual lines bind successfully, and unknown output fails - closed. -- Updated the hermetic fake client with the captured four-calendar pending, - mixed complete, mismatch, upgrade, and unrecognized outputs. All 17 focused - tests pass; focused Ruff lint and formatting checks pass. -- Ran real status with the cached 0.7.2 executable. All 15 complete proofs were - recognized, then calendar DNS errors prevented binding classification for the - five pending proofs; the fixture suite covers their supplied captured output. -- Replaced bot-branch import, PR creation, and auto-merge machinery with a - proof-only, non-force `git push origin HEAD:main` using only `contents: write`. -- Added best-effort start-of-job logging for the three supplied effective-rules - and classic-protection API endpoints; metadata read failures warn but do not - block the publication attempt. -- Retained the credential-free journal checkout, `run` + `verify` + `guard`, - `ots/`-only staging and committed-scope checks, and dirty-worktree refusal. -- Added a three-attempt non-fast-forward path that fetches and rebases current - `main`, refreshes the journal, reruns all checks, and recommits before retry. - Failed-push classification uses fetched commit ancestry, covering server-side - ref races and ambiguous successes without parsing Git's localized output. -- Updated both proof documentation sections to say automation pushes proof-only - commits to `main` and to document bounded retries plus the guarantees against - force-pushing or pushing the journal branch. `actionlint` passes. -- Reconfirmed all 20 `.ots` blob bytes are unchanged (combined checksum - `420c3f54b47df5c58c58edc7202f580dcbad40193a4264bec20c133b71a375b1`) - and there are no `releases/` or `ledger/` changes. -- Ran the final required suite: 17 focused tests, repository-wide Ruff lint, - focused Ruff formatting, and actionlint all pass. -- Confirmed local proof structure with the real cached OpenTimestamps 0.7.2 - executable: 15 Bitcoin-complete and 5 pending. Calendar DNS prevents the full - status binding pass, so the captured-output fixture tests are the documented - no-network fallback. -- Wrote the final implementation, verification, integrity, and delivery report - to `out.md`. -- Read PR #229 through the authenticated connector and prepared the replacement - “Publication path” body at `/tmp/pr229-body.md`. The exact `gh pr edit` command - failed on DNS, and the connector write was cancelled. +- Read the repository Chronicle boundary rules in `AGENTS.md`. +- Read `.lane-raw/SOURCES.md` and confirmed all five named publisher artifacts are present. +- Confirmed the worktree is otherwise clean apart from `.lane-raw/` and the shared `.venv` link. +- Verified all five staged artifact SHA-256 pins exactly. +- Mapped FPB workbook cells: 990 facts across T01/T06/T07/T11/T17/T24, with + 2022–2025 observations and 2026–2031 `source_projection` facts. +- Confirmed PDF boundary evidence: printed page 19 calls 2026 the first projection year; + annex table units appear on printed pages 45, 48, 49, 53, 58, and 65. +- Chosen Eurostat layout: two vintage-specific source-package aliases share new manifest + entries, preserving the prior package YAMLs, raw bytes, and fact outputs unchanged. +- Reproduced the Statbel curator logic: 18 NUTS1 × sex × age-band cells totaling 11,825,551. +- Added the hash-pinned FPB workbook and publication PDF plus the + `fpb-economic-outlook-2026-2031-june-2026` package alias. +- Built 990 line-specific publisher facts (99 per year): 396 observations for + 2022–2025 and 594 `source_projection` facts for 2026–2031. +- Passed FPB `validate-package` and `build-suite`: 990 facts, full cell lineage, + zero acceptance errors, and pinned 2025 cells 320578 / 77771 / 5602 million euro. +- Re-ran the Statbel 2026 curator logic on the 2025 ZIP and added the hash-pinned + raw capture plus its deterministic 18-row curated CSV. +- Passed Statbel 2025 `validate-package` and `build-suite`: 18 facts totaling + 11,825,551, 66 constraints, full lineage, and zero acceptance errors. +- Added the Eurostat `gov_10a_taxag` 2025 and `spr_exp_func` 2024 manifest + entries plus vintage-specific package aliases, without modifying either + prior artifact or prior package specification. +- Passed both new Eurostat package validations and suite builds: 12 tax facts + and 9 ESSPROS facts, full lineage, and zero acceptance errors. +- Extended Belgium and Eurostat regressions for FPB table counts/cells and + assertion boundary, vintage non-overlap, prior-output digests, Statbel pins, + and the declared 0.25% Statbel/FPB population comparison tolerance. +- Passed 43 focused tests and the full merged-bundle regression: 157,177 facts, + 148 packages, zero aggregate-key duplicates, and expected goldens throughout. +- Recorded pins, counts, boundary evidence, curator commands, validation tails, + and consumer fact families in `LANE_C5_REPORT.md`. +- Passed independent `ledger-source-fidelity` and `ledger-boundary` reviews with + no required corrections. ## Next -- When GitHub access is available, push `ots-anchor-main` and run - `gh pr edit 229 --body-file /tmp/pr229-body.md` without merging the PR. +- None; ready for handoff. No push was performed. From b024795ddabe5dcccecfdeb51fc7f8a1676a2b7c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 3 Sep 2026 19:04:04 -0400 Subject: [PATCH 20/22] Exercise the anchoring script against the real OpenTimestamps client The fake client encodes the 0.7.2 output contract; this module checks it against the client the workflow runs, without network access. Proofs are built from the detached proof format and carry either a Bitcoin block attestation, which the client never tries to upgrade, or a pending attestation for a calendar outside the default whitelist, which the client refuses to contact. Covers the committed proofs' digest headers, the exact mismatch line, MISMATCH / FAIL enumeration, and a run that reaches upgrade for a pending proof. Skips when opentimestamps-client 0.7.2 cannot be launched. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 5354d67bfc1f03f9f1fc8449417a20b41681e912) --- tests/test_ots_anchor.py | 1 + tests/test_ots_anchor_real_client.py | 299 +++++++++++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 tests/test_ots_anchor_real_client.py diff --git a/tests/test_ots_anchor.py b/tests/test_ots_anchor.py index 1478d76..a12c7bd 100644 --- a/tests/test_ots_anchor.py +++ b/tests/test_ots_anchor.py @@ -568,6 +568,7 @@ def test_status_prefers_bitcoin_info_with_leftover_pending_attestations( ("transient", TRANSIENT_VERIFY_OUTPUT, "Temporary failure in name resolution"), ("resolved", RESOLVED_VERIFY_OUTPUT, "Got 1 attestation(s) from"), ], + ids=["mixed", "transient", "resolved"], ) def test_run_reaches_upgrade_despite_verify_calendar_chatter( repo: dict, diff --git a/tests/test_ots_anchor_real_client.py b/tests/test_ots_anchor_real_client.py new file mode 100644 index 0000000..57a8ec8 --- /dev/null +++ b/tests/test_ots_anchor_real_client.py @@ -0,0 +1,299 @@ +"""Exercise scripts/ots_anchor.py against the real opentimestamps-client. + +The fake client in test_ots_anchor.py encodes the client's output contract; +these tests check that contract against the client the workflow actually runs +(``uvx --from opentimestamps-client==0.7.2 ots``) without any network access: + +- ``ots info`` only deserializes the proof and never contacts a calendar. +- ``ots --no-bitcoin verify`` of mismatching bytes stops at the digest check, + before any calendar is consulted (otsclient/cmds.py, verify_command). +- The proofs built here carry either a Bitcoin block attestation, for which the + client's upgrade loop never runs, or a pending attestation naming a calendar + outside the client's default whitelist, which the client refuses to contact + (``Ignoring attestation from calendar ...: Calendar not in whitelist``). + +The client is tried offline first, then with uv's normal resolution, which +installs it on a CI runner. Set ``OTS_ANCHOR_TEST_OTS_BIN`` to use another +invocation. The module is skipped when no invocation works. +""" + +from __future__ import annotations + +import hashlib +import os +import shlex +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + +sys.path.insert(0, str(ROOT / "scripts")) + +import ots_anchor # noqa: E402 + +CLIENT_PIN = "opentimestamps-client==0.7.2" +DEFAULT_INVOCATIONS = ( + f"uvx --offline --from {CLIENT_PIN} ots", + f"uvx --from {CLIENT_PIN} ots", +) +CLIENT_TIMEOUT = 300 # seconds; the first online invocation installs the client + +# OpenTimestamps detached proof format (opentimestamps/core/timestamp.py, +# notary.py, serialize.py in opentimestamps 0.4.5): magic, version, file hash +# op tag, file digest, then the timestamp tree. The proofs built here attest +# the file digest directly, with no further operations. +HEADER_MAGIC = b"\x00OpenTimestamps\x00\x00Proof\x00\xbf\x89\xe2\xe8\x84\xe8\x92\x94" +MAJOR_VERSION = 1 +OP_SHA256_TAG = b"\x08" +PENDING_ATTESTATION_TAG = bytes.fromhex("83dfe30d2ef90c8e") +BITCOIN_ATTESTATION_TAG = bytes.fromhex("0588960d73d71901") +UNLISTED_CALENDAR = "https://calendar.example.invalid" +BLOCK_HEIGHT = 963242 +MISMATCH_LINE = "File does not match original!" + + +def varuint(value: int) -> bytes: + if value == 0: + return b"\x00" + encoded = bytearray() + while value: + septet = value & 0x7F + value >>= 7 + encoded.append(septet | 0x80 if value else septet) + return bytes(encoded) + + +def varbytes(payload: bytes) -> bytes: + return varuint(len(payload)) + payload + + +def pending_attestation(uri: str) -> bytes: + return PENDING_ATTESTATION_TAG + varbytes(varbytes(uri.encode("ascii"))) + + +def bitcoin_attestation(height: int) -> bytes: + return BITCOIN_ATTESTATION_TAG + varbytes(varuint(height)) + + +def build_proof(payload: bytes, *attestations: bytes) -> bytes: + """Serialize a detached proof that attests ``payload``'s SHA-256 directly.""" + + digest = hashlib.sha256(payload).digest() + tree = b"".join(b"\xff\x00" + attestation for attestation in attestations[:-1]) + tree += b"\x00" + attestations[-1] + return HEADER_MAGIC + varuint(MAJOR_VERSION) + OP_SHA256_TAG + digest + tree + + +def run_client( + invocation: list[str], *arguments: str +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [*invocation, *arguments], + capture_output=True, + text=True, + timeout=CLIENT_TIMEOUT, + check=False, + ) + + +@pytest.fixture(scope="module") +def real_ots_bin(tmp_path_factory: pytest.TempPathFactory) -> str: + probe = tmp_path_factory.mktemp("probe") / "probe.ots" + probe.write_bytes(build_proof(b"probe\n", pending_attestation(UNLISTED_CALENDAR))) + override = os.environ.get("OTS_ANCHOR_TEST_OTS_BIN") + invocations = (override,) if override else DEFAULT_INVOCATIONS + for invocation in invocations: + # --no-cache keeps the client from reading or writing ~/.cache/ots. + candidate = f"{invocation} --no-cache" + try: + completed = run_client(shlex.split(candidate), "info", str(probe)) + except (OSError, subprocess.TimeoutExpired): + continue + if completed.returncode == 0 and "File sha256 hash:" in completed.stdout: + return candidate + pytest.skip(f"{CLIENT_PIN} is not runnable here") + + +def anchor_root(tmp_path: Path) -> tuple[Path, Path]: + manifest_dir = tmp_path / "releases" / "manifests" + manifest_dir.mkdir(parents=True) + (tmp_path / "ots").mkdir() + return tmp_path, manifest_dir + + +def make_manifest(directory: Path, index: int, payload: bytes) -> Path: + digest = hashlib.sha256(payload).hexdigest() + path = directory / f"{index:04d}-{digest[:16]}.json" + path.write_bytes(payload) + return path + + +def write_proof(root: Path, manifest: Path, proof_bytes: bytes) -> Path: + proof = root / "ots" / f"{manifest.name}.ots" + proof.write_bytes(proof_bytes) + return proof + + +def run_cli(root: Path, ots_bin: str, *arguments: str) -> int: + return ots_anchor.main([*arguments, "--root", str(root), "--ots-bin", ots_bin]) + + +def test_committed_proofs_bind_the_digest_in_their_filenames( + real_ots_bin: str, +) -> None: + proofs = sorted((ROOT / "ots").glob("*.json.ots")) + assert proofs + for proof in proofs: + stem_digest = proof.name.split("-", 1)[1][:16] + info = ots_anchor.inspect_proof(proof, shlex.split(real_ots_bin)) + assert info.digest.startswith(stem_digest), proof.name + assert info.state in {"bitcoin", "pending"}, proof.name + + +def test_synthetic_proofs_round_trip_through_ots_info( + real_ots_bin: str, tmp_path: Path +) -> None: + payload = b'{"releaseIndex": 0}\n' + digest = hashlib.sha256(payload).hexdigest() + ots_bin = shlex.split(real_ots_bin) + + pending = tmp_path / "pending.ots" + pending.write_bytes(build_proof(payload, pending_attestation(UNLISTED_CALENDAR))) + assert ots_anchor.inspect_proof(pending, ots_bin) == ots_anchor.ProofInfo( + digest=digest, state="pending" + ) + + complete = tmp_path / "complete.ots" + complete.write_bytes( + build_proof( + payload, + bitcoin_attestation(BLOCK_HEIGHT), + pending_attestation(UNLISTED_CALENDAR), + ) + ) + assert ots_anchor.inspect_proof(complete, ots_bin) == ots_anchor.ProofInfo( + digest=digest, state="bitcoin" + ) + + +def test_client_prints_exact_mismatch_line_and_nothing_else( + real_ots_bin: str, tmp_path: Path +) -> None: + root, manifest_dir = anchor_root(tmp_path) + manifest = make_manifest(manifest_dir, 0, b'{"releaseIndex": 0}\n') + proof = write_proof( + root, + manifest, + build_proof(b"other bytes\n", pending_attestation(UNLISTED_CALENDAR)), + ) + + completed = run_client( + shlex.split(real_ots_bin), + "--no-bitcoin", + "verify", + "-f", + str(manifest), + str(proof), + ) + assert completed.returncode == 1 + assert completed.stdout == "" + assert completed.stderr == MISMATCH_LINE + "\n" + assert ots_anchor._MISMATCH_LINE == MISMATCH_LINE + assert ( + ots_anchor.verify_proof_binding(manifest, proof, shlex.split(real_ots_bin)) + is False + ) + + +def test_status_and_verify_enumerate_mismatches( + real_ots_bin: str, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + root, manifest_dir = anchor_root(tmp_path) + good = make_manifest(manifest_dir, 0, b'{"releaseIndex": 0}\n') + write_proof( + root, good, build_proof(good.read_bytes(), bitcoin_attestation(BLOCK_HEIGHT)) + ) + swapped = make_manifest(manifest_dir, 1, b'{"releaseIndex": 1}\n') + write_proof( + root, + swapped, + build_proof(b'{"releaseIndex": 99}\n', pending_attestation(UNLISTED_CALENDAR)), + ) + tampered = make_manifest(manifest_dir, 2, b'{"releaseIndex": 2}\n') + write_proof( + root, + tampered, + build_proof(tampered.read_bytes(), pending_attestation(UNLISTED_CALENDAR)), + ) + tampered.write_bytes(b'{"releaseIndex": 2, "tampered": true}\n') + + assert run_cli(root, real_ots_bin, "status") == 0 + captured = capsys.readouterr() + assert captured.out.splitlines() == [ + f"{good.name}: bitcoin attestation stored locally", + f"{swapped.name}: MISMATCH (proof does not match manifest bytes)", + f"{tampered.name}: MISMATCH (manifest bytes contradict its filename)", + ] + assert " Not checking Bitcoin attestation; Bitcoin disabled" in captured.err + assert ( + f" To verify manually, check that Bitcoin block {BLOCK_HEIGHT} has merkleroot" + in captured.err + ) + + assert run_cli(root, real_ots_bin, "verify") == 1 + captured = capsys.readouterr() + failures = [line for line in captured.err.splitlines() if line.startswith(" FAIL")] + assert failures == [ + f" FAIL {swapped.name}: proof does not match manifest bytes", + f" FAIL {tampered.name}: manifest bytes contradict its filename", + ] + assert "3 manifests, 1 locally Bitcoin-complete, 0 pending locally" in captured.out + + +def test_run_reaches_upgrade_for_pending_proof( + real_ots_bin: str, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + root, manifest_dir = anchor_root(tmp_path) + complete = make_manifest(manifest_dir, 0, b'{"releaseIndex": 0}\n') + write_proof( + root, + complete, + build_proof(complete.read_bytes(), bitcoin_attestation(BLOCK_HEIGHT)), + ) + pending = make_manifest(manifest_dir, 1, b'{"releaseIndex": 1}\n') + proof = write_proof( + root, + pending, + build_proof(pending.read_bytes(), pending_attestation(UNLISTED_CALENDAR)), + ) + original = proof.read_bytes() + + assert run_cli(root, real_ots_bin, "run") == 0 + captured = capsys.readouterr() + assert "2 manifests, stamped 0, upgraded 0, still pending 1" in captured.out + assert ( + f" Ignoring attestation from calendar {UNLISTED_CALENDAR}: " + "Calendar not in whitelist" + ) in captured.err + assert proof.read_bytes() == original + assert not proof.with_name(proof.name + ".bak").exists() + assert run_cli(root, real_ots_bin, "verify") == 0 + assert run_cli(root, real_ots_bin, "verify", "--require-bitcoin") == 1 + + +def test_run_refuses_proof_bound_to_other_bytes( + real_ots_bin: str, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + root, manifest_dir = anchor_root(tmp_path) + manifest = make_manifest(manifest_dir, 0, b'{"releaseIndex": 0}\n') + proof = write_proof( + root, manifest, build_proof(b"other bytes\n", bitcoin_attestation(BLOCK_HEIGHT)) + ) + original = proof.read_bytes() + + assert run_cli(root, real_ots_bin, "run") == 1 + assert "does not match manifest bytes; refusing to skip" in capsys.readouterr().err + assert proof.read_bytes() == original From 58f149b5d5ba4c5ffb9aac5e629a09b6dc375415 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 3 Sep 2026 19:04:04 -0400 Subject: [PATCH 21/22] Drop the co-author trailer from automated proof commits The scheduled job is unattended; its commits carry only the subject line. The test executes commit_proofs from the workflow in a throwaway repository and checks both the fresh-commit and amend paths. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 0f62ff082561f4ea3439d97931b86d28b24134bf) --- .github/workflows/ots-anchor.yml | 3 +- tests/test_ots_anchor_workflow.py | 101 ++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 tests/test_ots_anchor_workflow.py diff --git a/.github/workflows/ots-anchor.yml b/.github/workflows/ots-anchor.yml index 0c6d715..c256014 100644 --- a/.github/workflows/ots-anchor.yml +++ b/.github/workflows/ots-anchor.yml @@ -98,8 +98,7 @@ jobs: fi if git diff --quiet "origin/$MAIN_BRANCH"...HEAD; then git commit \ - -m "Update OpenTimestamps anchors for release manifests" \ - -m "Co-Authored-By: Claude Fable 5.1 " + -m "Update OpenTimestamps anchors for release manifests" else git commit --amend --no-edit fi diff --git a/tests/test_ots_anchor_workflow.py b/tests/test_ots_anchor_workflow.py new file mode 100644 index 0000000..92e95b5 --- /dev/null +++ b/tests/test_ots_anchor_workflow.py @@ -0,0 +1,101 @@ +"""Tests for the publication shell functions in .github/workflows/ots-anchor.yml.""" + +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "ots-anchor.yml" +PUBLISH_STEP = "Anchor, verify, and publish proof-only changes" +COMMIT_SUBJECT = "Update OpenTimestamps anchors for release manifests" +BOT_NAME = "github-actions[bot]" +BOT_EMAIL = "41898282+github-actions[bot]@users.noreply.github.com" + + +def publish_script() -> str: + workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + steps = workflow["jobs"]["anchor"]["steps"] + (step,) = [step for step in steps if step.get("name") == PUBLISH_STEP] + return step["run"] + + +def shell_function(script: str, name: str) -> str: + match = re.search( + rf"^{re.escape(name)}\(\) \{{\n.*?^\}}$", script, re.DOTALL | re.MULTILINE + ) + assert match is not None, f"{name}() not found in the publish step" + return match.group(0) + + +def git(repo: Path, *arguments: str) -> str: + return subprocess.run( + ["git", "-C", str(repo), *arguments], + check=True, + capture_output=True, + text=True, + ).stdout + + +def bot_checkout(tmp_path: Path) -> Path: + """A repository shaped like the job's main checkout after anchoring.""" + + repo = tmp_path / "main" + repo.mkdir() + git(repo, "init", "-q") + git(repo, "config", "user.name", BOT_NAME) + git(repo, "config", "user.email", BOT_EMAIL) + (repo / "README.md").write_text("baseline\n", encoding="utf-8") + (repo / "ots").mkdir() + (repo / "ots" / "0000.json.ots").write_bytes(b"pending") + git(repo, "add", "-A") + git(repo, "commit", "-qm", "baseline") + git(repo, "update-ref", "refs/remotes/origin/main", "HEAD") + return repo + + +def commit_proofs(repo: Path) -> None: + script = ( + "set -euo pipefail\n" + + shell_function(publish_script(), "commit_proofs") + + "\ncommit_proofs\n" + ) + subprocess.run( + ["bash", "-c", script], + cwd=repo, + env={**os.environ, "MAIN_BRANCH": "main"}, + check=True, + capture_output=True, + text=True, + ) + + +def test_publish_script_carries_no_coauthor_trailer() -> None: + assert "Co-Authored-By" not in publish_script() + + +def test_commit_proofs_writes_a_plain_bot_commit(tmp_path: Path) -> None: + repo = bot_checkout(tmp_path) + (repo / "ots" / "0000.json.ots").write_bytes(b"upgraded") + + commit_proofs(repo) + message = git(repo, "log", "-1", "--format=%B") + assert message.strip() == COMMIT_SUBJECT + assert "Co-Authored-By" not in message + assert git(repo, "log", "-1", "--format=%an <%ae>").strip() == ( + f"{BOT_NAME} <{BOT_EMAIL}>" + ) + + # A retry amends the proof-only commit instead of stacking a second one. + (repo / "ots" / "0001.json.ots").write_bytes(b"new") + commit_proofs(repo) + assert git(repo, "rev-list", "--count", "origin/main..HEAD").strip() == "1" + assert git(repo, "log", "-1", "--format=%B").strip() == COMMIT_SUBJECT + assert git(repo, "diff", "--name-only", "origin/main...HEAD").split() == [ + "ots/0000.json.ots", + "ots/0001.json.ots", + ] From 101e0143654cb7ff2588397093e799c813c4ac5e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 3 Sep 2026 19:04:04 -0400 Subject: [PATCH 22/22] Describe the unprotected main and the committed proof inventory accurately ots/README.md no longer says main is protected: the workflow itself records that the repository reported no rules on main, and the direct push relies on that. A new section says what the ots/-only guard provides (a client-side bound on what a correct run publishes, in the same job that holds the token) and what it does not (a server-side control). The proof inventory now matches the tree: 20 proofs for releases 0000-0019, 15 Bitcoin-complete and 5 committed while pending, dated so the statement stays true as the bot upgrades them. The verify section explains that the binding comes from the ots info digest and that the client's calendar messages are logged, not parsed. The top-level README stops calling the journal branch protected, which this change could not verify. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 8a3b6e2068da1e4eec66fe20a021cd42b41f5c21) --- README.md | 2 +- ots/README.md | 56 +++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ee8d539..c3f4da2 100644 --- a/README.md +++ b/README.md @@ -575,7 +575,7 @@ normalized_fact = convert_units(fact, 1000, "count") The `codex/thesis-ledger-facts` branch's witnessed release manifests are additionally anchored through OpenTimestamps. Trusted automation pushes proof-only commits to `main`, where the mutable `ots/.json.ots` proofs -live, while the immutable manifests and journal remain on their protected +live, while the immutable manifests and journal remain on the journal branch. Each proof binds a manifest's exact bytes into Bitcoin, giving the journal state it commits to an external anteriority bound. See [`ots/README.md`](ots/README.md) for the limits, cross-branch verification diff --git a/ots/README.md b/ots/README.md index d47b82b..4e4a4a1 100644 --- a/ots/README.md +++ b/ots/README.md @@ -21,10 +21,14 @@ accepted a proposal at a particular time, or that no parallel fork exists. A rewritten history can acquire new anchors, but Bitcoin exposes the later time at which those replacement bytes first existed; it cannot be backdated. -The 15 proofs initially carried here bind releases 0000–0014 and were first -stamped on 2026-08-19. The daily workflow stamps later manifests after they -appear and upgrades locally pending proof files when calendar attestations can -be folded into the serialized proof. +As of 2026-09-02 this directory carries 20 proofs, one per release 0000–0019, +and every proof is committed whatever its state. The proofs for releases +0000–0014 were first stamped on 2026-08-19 and already contain Bitcoin block +attestations. The proofs for releases 0015–0019 were stamped on 2026-09-02 and +are committed while still pending: each holds only calendar commitments until +the workflow upgrades it in place. The daily workflow stamps later manifests +after they appear and upgrades pending proof files when calendar attestations +can be folded into the serialized proof. ## Verify @@ -50,11 +54,23 @@ python3 scripts/ots_anchor.py verify \ --ots-bin "uvx --from opentimestamps-client==0.7.2 ots" ``` -This is strict: a mismatched or missing proof fails. Add `--require-bitcoin` to -also fail while any committed proof file still contains only pending calendar +This is strict: a mismatched or missing proof fails, and every manifest is +reported rather than only the first failure. Add `--require-bitcoin` to also +fail while any committed proof file still contains only pending calendar attestations. `status` distinguishes that local serialized state from an attestation a calendar may resolve in memory during verification. +The script establishes the binding without calendar traffic: the +`File sha256 hash` that `ots info` reads out of the proof must equal the +manifest's SHA-256. It then runs the client's own `--no-bitcoin verify` as an +independent check and echoes that output into the log. The client's verify path +first asks each calendar for upgrades, so for a pending proof the log carries +lines such as `Got 1 attestation(s) from `, `Calendar : Pending +confirmation in Bitcoin blockchain`, or `Calendar : ` after a +transient failure. None of that is interpreted. Only the client's exact +`File does not match original!` line, or an exit status the client never uses, +fails a proof. + ## Why proofs and automation live on `main` The journal's `releases/` history is immutable, and its append gate admits only @@ -63,13 +79,31 @@ stamping creates them after a release exists, and upgrading rewrites them as calendar transactions confirm. They therefore cannot live under `releases/`. Keeping the proof tree, anchoring script, tests, and scheduled workflow together -on protected `main` also establishes the privilege boundary. The workflow runs -`main`'s trusted script against a separate, shallow journal checkout that has no -persisted credential. It runs `run`, `verify`, and `guard`, stages only `ots/`, -and refuses a dirty worktree or a committed path outside `ots/` before it pushes -the proof-only commit directly to `main` with `git push origin HEAD:main`. +on `main` keeps the trusted code and the mutable proofs in one place. The +workflow runs `main`'s script against a separate, shallow journal checkout that +has no persisted credential. It runs `run`, `verify`, and `guard`, stages only +`ots/`, and refuses a dirty worktree or a committed path outside `ots/` before +it pushes the proof-only commit directly to `main` with a plain, non-force +`git push origin HEAD:main`. A non-fast-forward rejection starts a bounded retry: fetch and rebase onto the new `origin/main`, refresh the credential-free journal checkout, rerun `run`, `verify`, and `guard`, then recommit and retry. The workflow makes at most three non-force push attempts and never pushes the journal branch. + +## What the `ots/`-only guard does and does not provide + +`main` is not protected. When the workflow was written (2026-09-02) the +repository API reported no rulesets, required checks, review requirements, or +push restrictions on `main`, and the direct push depends on that absence. The +workflow logs the current rule evidence at the start of every run, so a later +change shows up in the job log. + +The `guard` subcommand and the workflow's `assert_commit_scope` run client-side, +in the same job that holds the `contents: write` token. They bound what a +correctly functioning run can publish: a stray file, an unexpected edit, or a +dirty worktree stops the push. They are not a server-side control. A compromised +job, a malicious dependency pulled in by the pinned client, or anyone else with +push access to `main` is not constrained by them. A repository ruleset that +protects `main` and lists this workflow as a bypass actor would add the +server-side boundary this design does not provide.