The stuff this cycle made, archived 2026-10-18 and rendered from
git show rev65-attempt6-iterations5of5:stuff/….
key-state-across-merges.mdrevoke-by-position.mdwho-may-sign-key-events.md
revoke-by-position.md makes a revoke take effect at its place in history. That place has to be unique. On a branch with merges it is not: this cycle branch merges main (3afa749, 374d107), and git log, --topo-order, --date-order and --first-parent put a [key-revoke] that arrived from main at different positions. If the outcome of a revoke depends on which traversal flag was used, the revoke is not enforced.
Fold the allowed_signers set over git rev-list --first-parent --reverse HEAD.
[key], [key-rotate] or [key-revoke] event, it then updates the set.M^1..M^2) are checked against the set at M^1, as if they happened at the merge. Key events among them do not change this branch's set.Branch A revokes key K at commit r. Branch B forked before r and keeps signing with K. Each of B's commits comes before r in B's own history, so a fold that walks each branch's history separately accepts them all. Under rule 3 they are checked at the merge point, where K is already revoked, so they are rejected.
No process per commit. One git log --first-parent --format='%H %P %G? %GK' pass, verified against the union of every key ever added, gives each commit's signer fingerprint. jq then checks each fingerprint against the set as of that commit's position, and checks second-parent ranges against the set at their merge. The union file only proves that a signature is valid for some key. Whether that key was allowed at that position is decided by the fold.
Not tested here: how %G? reports an SSH key that is missing from gpg.ssh.allowedSignersFile. The union file avoids that case, so the fold never depends on it.
The direction asks bin/zstate to reject an event signed by a key after that key's [key-rotate] or [key-revoke]. "After" has to mean position in the commit graph. It cannot mean time.
The allowed_signers format has valid-after and valid-before options, and since git 2.35 git verify-commit checks them against the commit's own committer timestamp. The signer writes that timestamp. A revoked key that sets GIT_COMMITTER_DATE to a moment before its revoke passes the check. A single allowed_signers file with expiry dates enforces nothing against the one party revocation exists to stop.
Replay events oldest first along the first-parent chain (git rev-list --reverse --first-parent HEAD), carrying a set S of (principal, key):
git -c gpg.ssh.allowedSignersFile=<file> verify-commit C. S is the set as of C's parent; C's own event has not applied yet.[key]: add the key. Accepted [key-rotate]: the signing key must be the outgoing key (rule 2); remove it, add the incoming key. Accepted [key-revoke]: the signing key must not be the revoked key (rule 2); remove the revoked key.A key removed at commit R is absent from S for every commit after R on the chain, whatever date those commits claim. That is the whole rejection rule. No timestamp is read.
verify-commit per event is one ssh-keygen process per commit, the per-commit fan-out bin/zstate was rewritten to remove. But a verdict is a pure function of the commit sha: S at C's parent depends only on C's first-parent ancestors, which cannot change. Cache verdicts by sha under .zociety/ (gitignored, never a notes ref an agent could write) and verify only commits with no cached verdict. A normal bin/zstate call then verifies one or two new commits.[key] has no S to be checked against. It must be pinned from outside the replay (a fingerprint in the repo's config, not an event), or whoever writes the first event owns the zociety.
The other two files say when a key stops counting: at the position of its revoke (revoke-by-position.md), and in a merge, at the merge point (key-state-across-merges.md). Neither says who is allowed to write a [key], [key-rotate] or [key-revoke] event. If anyone can, the set proves nothing: an attacker commits [key] attacker-key, signs it with that key, and from then on every forged event passes.
A set derived only from events still needs one fact that is not an event. Make that fact a commit hash, not a key: bin/zstate is given the SHA of the first [key] event (one line in a tracked config, or the cycle's parent tag). The hash fixes the content of that commit, including the key it adds. Rewriting the root event changes its SHA, and the fold refuses to start. Every other key in the set is then reached from that one commit.
[key] K counts only when its commit is signed by a key already in the set at that position. A [key] signed by K itself is rejected. The root event is the one exception, and the anchor covers it.[key-rotate] old -> new counts only when signed by old. That shows the holder of old agreed. new cannot co-sign, because a commit has one signature. The first event signed by new is the proof that its holder has it.[key-revoke] K counts when signed by any key in the set, K included. Revoking your own key is how a holder reports it stolen. A thief who revokes the stolen key only locks themselves out.old is valid, and old stops counting at the commit after it.
Any member can revoke every other member. That attack costs availability, not integrity: the revoker can stop the zociety but cannot forge an event in anyone else's name. If the set ends up empty, the fold has no valid signer and bin/zstate answers action: stop. That fails closed, which is the safe direction. Recovery is an operator [intervention], the same path as any other terminal observation.
The fold already walks events oldest first and keeps the current set. A key event adds one lookup: is the signer's fingerprint in the set before this event? git log --format='%H %G? %GF %s' prints the fingerprint per commit, so no process is spawned per commit. Not tested yet: what %GF prints for an SSH signature whose key is missing from the allowed signers file. The fold should compare %GF against its own set and ignore %G?, so the answer to that matters only for formatting.