Skip to content

The graph owns identity: signed contributors, invites and revocations - #34

Merged
BY571 merged 33 commits into
share/server-gatefrom
remote/identity
Sep 6, 2026
Merged

The graph owns identity: signed contributors, invites and revocations#34
BY571 merged 33 commits into
share/server-gatefrom
remote/identity

Conversation

@BY571

@BY571 BY571 commented Sep 6, 2026

Copy link
Copy Markdown
Owner

The graph owns identity

Phase 2 of shared graphs (on top of #32, the transport). Who may write is written down in the graph and signed, not held on the server.

  • contributors.yaml beside graph.yaml: {name: {key, role, revoked?, invited_by?, invite: {blob, sig}?}}. Keys are ed25519 signing keys (SSH key format, ssh-keygen -Y); everything still travels over HTTPS, there is no SSH access.

  • knoten remote create makes your key, lists you as admin, and commits that constitution before the server hears about the graph. knoten key NAME shows or makes a key.

  • knoten invite signs the invite with the admin's key; the server verifies it at /invite and again at /join. An invite is a bearer of a name and a role; the newcomer binds their own key when they join.

  • knoten join clones, makes the newcomer's key, adds their own entry (carrying the admin-signed invite) and pushes it. Readers hold a token only; they are not listed.

  • knoten revoke marks revoked: YYYY-MM-DD in the graph, signed by an admin, pushes, then kills the token. Idempotent; a failed server call after the push says to re-run.

  • The gate is now Python (knoten gate, exec'd by the pre-receive hook) and checks every pushed commit against the contributors at its parent. Three shapes: unchanged constitution, any active writer; one added entry with an admin-signed invite, the newcomer's own key and nothing else in the commit; anything else, an active admin of the previous version. Merges refused; names are revoked, never removed; the constitution is never deleted.

  • A hosted graph is one line of history: one branch, no tags, no deletions, no force-pushes, checked in the gate itself.

  • Under knoten serve, only the admin token may bootstrap a constitution into a phase-1 graph, and that commit touches nothing else.

  • Team workflow, added after review: knoten pull rebases (the gate refuses merges, so a merge could never be pushed; replayed commits are re-signed); knoten push refuses while the graph has uncommitted changes (knoten commit files a node, git commits it); a push behind the remote says pull first; knoten remote add on a signed graph configures signing for a listed name whose key is on this machine (the second-laptop path). SKILL.md has an "In a shared graph" section; README a worked team in three places, every command test-verified.

Known properties (by design)

  • An invite is a bearer of a name and a role, not of a key: whoever holds the blob and signature joins as that name under any key. The role cannot be escalated, and a redeemed name cannot be joined again.
  • The expires inside the signed invite blob is enforced nowhere; the server's own record of the invite gates /join. Same number, checked in one place.
  • A hosted graph's directory cannot be renamed once it has a constitution: git mv g h is indistinguishable from deleting g's contributors.yaml, which is refused.
  • Only under knoten serve does the admin token gate the first constitution. A bare repo gated by hand with knoten hook --server has no tokens, so there any key that names itself admin can bootstrap a phase-1 graph.
  • Revocation is prompt, not instant: a push already past authentication runs to completion.
  • Each pushed commit costs one signature verification per graph directory; MAX_PUSH_BYTES is the only bound on how much work one push can ask for.

Verification

python -m pytest tests -q -W error: 609 passed, no warnings (was 461 at the base of this branch).

Reviewed per task and as a whole by separate agents with mutation checks (each defence removed, a named test fails, restored). Spec section 17 "phase 2 as built" records the decisions.

🤖 Generated with Claude Code

https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb

BY571 and others added 30 commits September 5, 2026 17:59
…signature

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
YAML 1.1 implicitly parses unquoted dates as datetime.date objects. When an
entry loaded from disk was compared to one built in code with today(), diff()
reported spurious changes and json.dumps crashed. Coerce revoked to str in
parse() and validate against YYYY-MM-DD regex. MAX_NAME was duplicated in
registry.py and contributors.py; move to core.py next to ID_RE and import
in both, so the name length cap is one constant across the system.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
A security review found `gdir` reaching `git archive` as a bare argv element (a
directory named `--output=x` or `--remote=host:path` is an option, not a path,
to git), a non-GraphError bug in main() escaping as a raw traceback relayed to
the pusher, and _git letting children inherit the hook's own stdin (git's ref
list). All three are closed here: graph_dirs refuses any directory name git
could parse as an option or pathspec magic, extract adds the `--` separator as
defense in depth, main() catches Exception and fails closed with one line, and
_git defaults child stdin to /dev/null unless input= is given.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
A commit that moved or deleted a graph directory was checked against nothing:
check_ref only walked graph_dirs at the commit, never at its parent, so an
unlisted contributor could rename g to h and self-appoint as h's admin (the
bootstrap branch has nobody else to blame), and an unsigned commit could
rm -rf a graph outright with no signature check ever running. Also closes a
rev-list-failure-reads-as-nothing-to-check gap, rejects a contributors.yaml
that lists two names under one key (%GS can't tell them apart), stops a
merge deep in already-accepted history from refusing every future branch,
and fixes a temp-file leak in signature() and the WHY["U"] wording.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
…ame is read before the commit

A join was approved on the invite plus the newcomer's signature alone, so a read invite
bought its holder one unrestricted write anywhere in the tree; the graph's name it was
checked against was also read at the commit itself, so a same-commit rename made an
invite for one graph verify against whatever name the joiner picked. The changed-paths
set must now be exactly contributors.yaml, and the name is read at the parent. Also
corrects the comment on the newcomer-signature check: it proves who committed the entry
holds its key, not that the invite can't be used by whoever holds it under someone else's
key -- that's the accepted invite-by-code model for phase 2, not a gap this line closes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
…t not read as unsigned

head_graph read only HEAD, so a bare repo whose only branch was `main` (git init leaves
HEAD at refs/heads/master, and `remote create` pushes whatever branch the user is on)
looked unsigned even fully bootstrapped, and every invite for it minted without a
signature; it now falls back to the repo's one branch when HEAD is unborn, and refuses
when there is more than one with no HEAD to pick between them. gate._git and both
subprocesses in Registry.create now build their env from server_git_env(), which strips
any stray GIT_DIR before reasserting SERVER_GIT_ENV, so an absolute GIT_DIR left in the
operator's shell can no longer outrank `-C` and redirect a hosted-repo read -- the hook's
own in-process calls keep inheriting git's environment unchanged, since that is where
git's quarantine object-directory variables live during a push. redeem() re-verifies a
signed invite's signature against a fresh contributors.yaml at redeem time, so a revoked
admin's still-unexpired invite no longer mints a live token before the gate ever gets a
chance to refuse the join commit. _invite caps blob/sig size, refuses either on an
unsigned graph, guards the .encode() calls against a lone surrogate crashing to a 500,
and distinguishes "you hold no key here at all" from "wrong signing key".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
…a code no one checked

install_server's own git call re-merged os.environ under the caller's chosen env, so a
GIT_DIR left in the daemon's environment still won the race against `-C` and wrote the
gate to the wrong repo's hooks directory -- an unsigned push into the real one was then
accepted with nothing enforcing it. hook._git now uses a given env exactly as given, and
create() passes server_git_env(); a postcondition after install_server confirms
hooks/pre-receive actually landed and rolls back the whole graph if it did not.

head_graph's branch fallback now fixes HEAD the first time it resolves one, so a second
branch pushed later can't turn a working graph into a refusal, and that refusal names the
`symbolic-ref` fix. _invite's size cap now counts encoded bytes, not code points, checked
before the cap rather than after. redeem(name, code, contribs_for) takes its re-check as
a callable invoked only once the code is already found and spent, so a bad code costs no
git read and a misconfigured repo's own error can't leak through /join ahead of the
code's own verdict; an invite minted before the graph was ever signed is refused the same
way, not treated as if a key had vouched for it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
`git add -A` staged the whole enclosing repo, so `remote create` in a monorepo
could push an unrelated scratch file or secret with no listing or confirmation;
now it adds only contributors.yaml. A failed commit (no user.name/user.email)
printed git's multi-line identity block instead of the one line every other
refusal here gives. And the admin name is checked against an existing
contributors.yaml before a key is generated, so a typo'd --as no longer leaves
a stray keypair behind after the refusal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Fix round 1 on review of "a friend arrives with the admin's invite and their own
signature":

- cli.py's join line now says a signing key was made and the clone signs, on a signed
  graph -- the one file the brief listed that Task 8 had left untouched.
- invite() bounds --expires to 1..365 (the server's own wording) before it ever reaches
  timedelta, which raised a raw OverflowError for anything absurd.
- Extracted _my_key(contribs, name), shared by _bootstrap and invite: it checks the key
  already on disk against what contributors.yaml lists BEFORE calling ensure_key, so a
  refusal on a machine that never held the listed key no longer mints a wrong keypair
  under that name and strands it there forever (ensure_key never regenerates).
- join() resolves which directory inside the clone holds the graph with
  gate.graph_dirs, the same call the gate itself makes on every push, instead of
  assuming the clone's root -- a monorepo layout now joins correctly, and more than one
  graph is refused in one line.
- The blob/sig the server hands back to join() are now part of the malformed-reply
  check, not written into contributors.yaml unchecked.
- A failed join commit now reports git's first stderr line, not the whole block, and
  says the clone and credentials are already in place -- matching the wording
  _bootstrap already used for its own commit failure.
- Re-aimed test_a_joiner_whose_push_is_refused_is_told_why at a refusal that actually
  reaches the gate (the admin renames the graph between invite and join; /join only
  re-checks the signer, so redemption succeeds and the gate is what catches the stale
  blob), and reverted registry.py's wording, which only existed to fit the old,
  mis-aimed version of that test.
- Added tests for the day bound, the no-stray-key guarantee, and a join into a
  monorepo subdirectory.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The gate refused a broken tree and let the shapes around it through. A deletion
carried no tree, so it was waved past; a force push and a second branch were
left to `receive.denyDeletes` and `receive.denyNonFastForwards`, which live in
one repo's config and not in a repo somebody gated by hand. Refs are now judged
before their contents: no deletions, no rewrites, and the first push creates the
only branch there will be.

Four holes behind that one. A writer could `git rm -r nodes`, leaving
contributors.yaml with no graph under it, and the server read the result as
phase-1: unsigned invites accepted for any role, /join skipping the signature
re-check. head_graph now refuses that, and the gate makes it an admin's call in
the first place. A writer could plant a second graph in a fresh directory naming
themselves its sole admin, and head_graph then died with "holds 2 graphs" for
everyone; a second constitution now needs an admin of the one already there. A
write token could bootstrap contributors.yaml into a hosted phase-1 graph and be
its admin, because a signature says which key wrote a commit and never which
token pushed it; `knoten serve` now tells the hook, per request, who it
authenticated. And an entry could be dropped from contributors.yaml rather than
marked revoked, which erases the record revocation exists to keep.

`\Z` rather than `$` in ID_RE, so "maria\n" is a name no longer; MAX_NAME where
a name becomes a key file; gpg.ssh.program pinned beside gpg.format; and the
several-branches refusal no longer echoes the server's own data path back to
whoever called /invite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
`knoten join` made every newcomer a signing key, wrote them into
contributors.yaml and pushed that commit -- a reader included, whose push the
server then refused for having read access. The file says who may WRITE here,
and every entry in it is a key the gate will accept a commit from, so listing
someone who has nothing to sign is both a failed push and a wrong record. A read
invite now clones, stores the token, and stops there; `join` says whether it
signed rather than making the CLI go back and re-read the graph to guess.

The rest is names. `default_signing_name()` replaces a 118-character line in the
`key` command and refuses instead of yielding "", `_graph_dir` is `_graph_subdir`
because it returns a name, MAX_DAYS moves to core so the client's bound and the
server's cannot drift, and `invite_blob`'s docstring says which of its five
fields is enforced where -- `expires` by the server's own record and `nonce` by
nothing at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Each defence added in the two commits before this one has a test that fails
without it: the graph whose nodes are removed but whose constitution stays, the
second graph planted by a writer, the write token bootstrapping a hosted
phase-1 graph, the bootstrap with a node edit bundled in, the entry deleted
rather than revoked, the revoked writer branching off history they could once
write to. Alongside each, where there is one, the version that must still land:
an admin may retire their own graph, and the admin's own token bootstraps.

Assertions that were true of nothing in particular are now true of the thing.
`authenticate(..., "anything")` asserted that a string nobody minted is not a
token; it uses maria's real one. `assert "signed" in error` matched any refusal
mentioning signatures. `ensure_key` returning the same PATH twice said nothing
about the key at that path. And the invite-name check was only ever exercised
where the path check would have refused the push anyway, so it now has a sibling
whose rename landed in an earlier, accepted commit.

Seven copies of a twelve-line "push a signed graph" block in test_registry
become one fixture whose two arguments are the two things that ever varied. The
size-cap tests become one parametrized test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The README said "the hosted repo refuses force pushes and branch deletion" and
left the rest of the ref rules unwritten, showed `knoten key` with no name when
the name is the whole point, and never said where the private half lives or what
happens when it is gone. Someone deciding whether to join a shared graph is
deciding to hold a file they cannot lose, and that was nowhere on the page.

Also here: one branch and no tags, readers holding a token rather than a listing,
only the admin's token laying down the first contributors.yaml, and revoke never
being delete. SPEC.md's threat model gets the row for the line of history,
including what is still not defended -- two refs created by one push into a repo
that has none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
A house rule the code follows and the docs never did: 53 of them, all
pre-existing, none in README.md. Each one is replaced by the punctuation the
sentence actually wanted, which is usually a colon and sometimes a comma, a
semicolon, a full stop or a pair of brackets. `grep -c '—' README.md SKILL.md
SPEC.md` prints 0 for all three.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
"Three shapes" stopped being the whole story two commits ago: dropping a name and
bootstrapping under someone else's token are refused before any shape is chosen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Two rulings on the gaps the last wave left open.

Git cannot answer "how many branches will exist after this push": mid-push no ref
has moved, so every line of a `git push --all` into an empty repo read the same
empty branch list and every branch was created. The count is kept where the lines
are read instead, and the second creation is refused in the same words the first
rule uses.

And deleting contributors.yaml was an admin's to do, which left dropping a name
open in two commits: delete the file, then bootstrap a fresh one leaving somebody
out, which the bootstrap rules accept because nothing is left to weigh it against.
A graph's constitution now never stops existing, whoever signs. That costs one
thing worth naming: `git mv g h` on a graph directory takes contributors.yaml away
from the gdir its entries were written for, and the gate cannot tell that from a
deletion, so a hosted graph's directory can no longer be renamed. Refusing a
rename is the cheaper mistake.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Every rule in check_commit keys on contributors.yaml. The loop that calls it
walked graph directories, so a directory holding a constitution and no graph was
judged by nobody, and two pushes through a real server got past everything this
branch added.

Plant `mine/contributors.yaml` naming yourself admin: invisible, because `mine`
is not a graph directory. Add `mine/graph.yaml` and `mine/nodes/` in the next
commit and the constitution now reads as UNCHANGED, checked against the very key
it names. The bootstrap rules that would have caught it, an elder's signature and
the admin token, are on the branch that commit never reaches. The graph then has
two constitutions, which is the state where head_graph refuses to answer, so the
real admin's invites start failing and only the planted graph still works.

The same blind spot, the other way round: an admin may retire a graph, and while
its directory holds no graph a writer could rewrite its constitution wholesale,
admin dropped, then put the nodes back. Both pushes accepted.

So the walk is over every directory that holds a constitution now or held one at
the parent, as well as the graph directories, and the elders a new constitution
must answer to are read the same way: a retired graph still says who its admins
are, and they are exactly the people who may found its successor.

Two smaller things in the same file. The branch count reads `refs/heads/`, so it
could not see a tag: a tag pushed into a repo with no branch answered "none here"
and landed, and the branch landed after it. A created ref that is not a branch is
now refused before anything is counted, rather than by widening the count to
`refs/`, which would let a tag block the branch forever. And a refused tag no
longer spends the one creation a push may make, which had put the refusal on the
branch's line and named the wrong ref as the problem.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
`graph_dirs` refused a name git would read as an option; `contributors_dirs`,
added two commits ago and now feeding the same reads, did not. Nothing reachable
today parses such a name as an option -- `git show rev:path` carries it inside one
argument -- but that was an audit of call sites, not a property of the walk, and
the walk is what grows callers. The check moves into `_safe_dirs`, which both
walks return through.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
…ng one

A tag that predates the gate could still be moved; the non-branch refusal
sat under the creation branch. It now runs first. The parent's constitution
set is computed once per commit and passed down, instead of once per
directory inside the bootstrap rule, and contributors_dirs says why it does
not mode-check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
BY571 and others added 3 commits September 6, 2026 11:07
…pens everywhere

On a runner whose hostname carries a domain git invents user@host and the
commit succeeds; the test then asserts a refusal that never came.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
pull rebases instead of fast-forwarding: the gate refuses a merge, so a merge
was a pull that could never be pushed; the clone re-signs what it replays.
push refuses while the graph has uncommitted changes, since knoten commit
files a node and git has not seen it, and "pushed" then meant nothing left.
A push behind the remote says pull first; a rebase conflict says what to run.
remote add on a signed graph configures signing when the caller is listed and
this machine holds their key: the second-laptop path, verified by a push.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
… it exists

SKILL.md: an "In a shared graph" section with the three things that change
(pull first, git commit after knoten commit, push per node) and the three
refusals to know. README: a worked team in three places, including the
second laptop, with every command verified by a test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
@BY571
BY571 merged commit 01335a2 into share/server-gate Sep 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant