The stuff this cycle made, archived 2026-10-19 and rendered from
git show rev65-attempt12-iterations3ofx:stuff/โฆ.
rule2-as-one-ancestry-invariant.mdrule2-model-run.mdrule2-stops-concurrent-signatures.mdmember-1, cycle 48. Builds on the two earlier stuff files and runs member-2's open test. Answer to the direction: the ancestry check is not bookkeeping. It is the whole rule. The set-difference fold is what should be demoted.
In any commit's ancestry (the commit included), every revoke of a key k must descend from every commit signed by k:
for every d signed by k, for every r revoking k, both in anc*(c): d is an ancestor of r (or d == r)
That is the entire rule 2, stated once per commit over its ancestry set. No parents are paired, no merge is special, and no merge base is used.
A 50-line Python model with three checkers (rule 1 over strict ancestors; rule 2 per ordered parent pair at every merge in history; the invariant at the tip) and the live set from the set-difference fold. Cases P, O, CC, S, T from the first file (g adds r and k, x revokes k, signed by r), plus L (k signs, then the same line revokes k: an honest rotation) and F (cycle 47's concurrent re-add of k, signed by r, with no k-signature).
| Case | rule 1 (all commits) | rule 2 pairwise, every merge | invariant at tip | live |
|---|---|---|---|---|
| P plain | accept | reject | reject | {r} |
| O octopus | accept | reject | reject | {r} |
| CC criss-cross | accept | reject | reject | {r} |
| S stolen k | accept | reject | reject | {j, r} |
| T after merge | reject | accept | reject | {r} |
| L rotation | accept | accept | accept | {r} |
| F re-add | accept | accept | accept | {r} |
Three things the table shows that reasoning by hand did not:
An honest branch that signed with k while k was being rotated elsewhere is unmergeable until rewritten. Per-pair rule 2 has the same cost; the invariant only makes it visible at the tip instead of at one merge.
Drop the per-pair, per-merge wording and the cycle 47 repair. State rule 2 as the invariant above, checked at the tip. Keep rule 1 for "added before signing" only. Next cycle: fix the set-difference fold so a re-add after a revoke is live (case F), since that is now the only live-set error left.
The model is 50 lines of Python; the table above is its full output.
A 40-line Python model (commits with parents, a signer, and add/revoke ops) checked each case. Rule 1 is the set-difference fold over a commit's ancestors. Rule 2 was run three ways: per pair of parents (pairwise), "reachable from exactly one parent" (once), and with cycle 47's repair, where a branch clears a rejection by revoking the key itself (repair).
Every case starts from g, which adds keys r and k (signed by r). Then x (signed by r) revokes k.
| Case | History | rule 1 | pairwise | once | repair | live after |
|---|---|---|---|---|---|---|
| P plain | y signed by k; m = merge(x, y) | accept | reject | reject | reject | {r} |
| O octopus | y signed by k; z child of y; m = merge(x, y, z) | accept | reject | accept | reject | {r} |
| CC criss-cross | m1 = merge(x, y), m2 = merge(y, x), m3 = merge(m1, m2) | accept | m1, m2 reject; m3 accept | accept at m3 | accept at m3 | {r} |
| S stolen k | y1 signed by k adds j; y2 signed by j revokes k; m = merge(x, y2) | accept | reject | reject | accept | {r, j} |
| T after merge | t signed by k, child of a clean merge | reject | n/a | n/a | n/a | {r} |
What the run shows:
Position: keep rule 2 strict, checked per pair of parents at every merge in history, with no revoke-it-yourself repair. It is the only rule here that stops concurrent signatures by a revoked key.
member-2, cycle 48. Answers the direction: does the ancestry check stop any commit the set-difference fold (rule 1) would accept?
Yes. It stops exactly one class of commit, and rule 1 cannot see that class.
a is a root key that stays live. +k adds key k, -k revokes it, [k] means the commit is signed by k. Rule 1 checks a commit against its own strict ancestors only. Rule 2 checks a merge: no commit reachable from one parent and not from another may be signed by a key revoked in that other parent's history.
B +k [a]
โโ X: R -k [a]
โโ Y: S (change) [k]
M = merge(R, S) [a]
So rule 2 rejects a merge that rule 1 accepts, even though the live set is the same either way. The live set is the wrong measure: rule 1 governs which keys may sign from now on, and rule 2 governs which past signatures a branch may import. Cycle 47's case F (a concurrent add) is one instance; G is the general one, since any commit, not only an add, can be signed by k.
A k-signature made after the merge needs no rule 2: R is then an ancestor and rule 1 rejects it. Rule 2 matters only for signatures concurrent with the revoke.
M1 = merge(R, S) on X M2 = merge(S, R) on Y
Rule 2 uses reachability sets, not a merge base, so having two merge bases changes nothing. Both M1 and M2 import S across R and both are rejected.
P1: R -k [a] P2: S [k] P3: merge(P2, Q) [a] (Q never saw R)
M = merge(P1, P2, P3)
S is reachable from P2 and from P3, and not from P1. If "brought in from one parent" means "reachable from exactly one parent", S is skipped and M passes. Rule 2 has to be checked for each ordered pair of parents (p, q): commits in reach(p) \ reach(q) against the revokes in q's history. Checked that way, M is rejected through the pair (P2, P1).
Cycle 47 proposed that a branch clears a rejection by revoking the key itself, so a k-signed commit is excused if it is an ancestor of some revoke of k. Here is the attack, using only k:
B +k [a]
โโ X: R -k [a]
โโ Y: S1 +j [k] valid under rule 1: k is live on Y
S2 -k [j] valid: j is live after S1
M = merge(R, S2) [a]
Revoking k on Y proves nothing when the revoke can come from k or from a key k created.
Keep rule 2 strict and per pair of parents. A branch that holds a k-signature concurrent with a revoke of k cannot be merged until it is rewritten without that commit. Do not add the revoke-it-yourself repair. The only repair that would be safe is a revoke signed by a key whose add chain contains no k-signature, and checking that is a third rule, not a small change to rule 2.
Open: the test above is reasoned by hand, not run. A stuff turn that builds G, the octopus and the laundering case in a throwaway repo and runs both folds would confirm or break it.