shared graphs: the gate on the server, and knoten serve for remotes friends can be invited to - #32
Open
BY571 wants to merge 84 commits into
Open
shared graphs: the gate on the server, and knoten serve for remotes friends can be invited to#32BY571 wants to merge 84 commits into
BY571 wants to merge 84 commits into
Conversation
… clone `knoten hook` gates one person, in one clone, and `git commit --no-verify` walks past it. On a shared graph the normal contributor is one who never ran it, so the rules held only as far as everyone's individual discipline. That is the failure mode the rule engine exists to replace. `knoten hook --server` installs a pre-receive hook on the repo everyone pushes to. It unpacks the pushed tree, finds every graph in it, runs `knoten validate` on each and refuses the push if any fails. No CI, no runner, no minutes, and it cannot be skipped from a laptop. Three things it has to get right, each with a test that fails without it: - It FINDS the graph rather than recording a path. A bare repo has no working tree to read one from, and a recorded path rots silently the moment someone moves the folder: the hook then finds no graph and accepts everything. - It fails closed. knoten missing from the server's PATH refuses the push. A gate that waves work through when it cannot check it is not a gate. - `graph.yaml` is not a name knoten owns. Another tool's file of the same name fails validation, and treating it as a graph would make the whole repo unpushable forever. A graph is one with a nodes/ directory or a knoten-specific key; two tests, because git does not track an empty nodes/ and a graph.yaml too malformed to name its keys still has to be caught. `git archive` and `tar` are two statements rather than a pipe: POSIX sh reports only the last command's status, so a pipeline would hide a failed archive behind a happy tar and accept the push unchecked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
… closed Three agents reviewed the branch. Applying what they found. The discriminator was `nodes/ exists OR graph.yaml declares a knoten key`. But `rules:` and `tags:` are ordinary top-level YAML keys, used by Ansible, CI configs and doc generators, so the second clause matched foreign files and handed them to `validate`, which rejects unknown keys. One vendored graph.yaml with a `tags:` key would have made the whole repo unpushable forever, citing a file nobody thinks of as a graph. That is the exact failure the first clause exists to prevent. The clause bought one thing: a graph whose nodes were all deleted, a window one push wide. It only ever fired because the fixture's `nodes/` was empty and git does not track an empty directory, so the fixture was testing a shape that cannot reach a server. The fixture now carries a node, which is what a graph in git always has, and the clause is gone. Also from the review: - `install` and `install_server` shared ten lines including a verbatim error message. One `_write_hook` helper now holds the clobber rule, which is the half a reader trusts rather than checks, so it cannot drift between the two gates. - `_git(repo, "rev-parse", "--git-dir")` was dead: `hooks_dir` calls `_git` on the next line and raises the same error. - `ZERO=` assumed SHA-1. A SHA-256 repo sends 64 zeros on a deletion, the guard would miss, and a routine branch delete would be refused. Matched by shape now. - `knoten validate` inherited the hook's stdin, which is the ref list. Any future read inside validate would have eaten refs and left them unchecked. `</dev/null`. - Dropped a dead `[ -n "$cfg" ]` guard, hoisted a loop-invariant, and merged the duplicated archive/tar error message while keeping them two statements. Tests: 23 -> 22. Deleted `test_deleting_the_graph_entirely_is_allowed` (the hook is stateless per push, so "graph removed" and "never had one" reach the same branch), folded the two CLI install tests into one parametrized pair, and gave the six tests that lacked one a docstring naming the failure they guard. Two review suggestions declined: `test_it_refuses_outside_a_git_repo` stays, since it pins a user-facing error on a distinct entry point for one line; and the branch/force-push/tag tests stay separate, because their setups genuinely differ and parametrizing them would need a callable per case and read worse. README section cut from 52 lines to 32. Co-Authored-By: Claude Opus 5 (1M context) <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
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
When git init, config, or install_server fail partway through, the repo directory exists on disk but lacks the pre-receive gate. Subsequent creates raise 'already exists' forever, blocking retry. Wrap the whole sequence in try/except, rollback with rmtree on any exception, and re-raise as GraphError so the invariant holds: if exists() is true, the gate is installed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
… not Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
graph_lock tries to open a file inside the graph directory, which raises a raw FileNotFoundError if the path does not exist. Like authenticate/invite/redeem, revoke must call self.repo(name) first to convert this to a domain GraphError. 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 http-backend can exit non-zero with no CGI header block at all — a genuine internal error, not a gate refusal (those travel the sideband with exit 0). The relay used to fall back to status 200 in that case, handing the client an empty 200 OK while the real failure sat only in the server's own stderr. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
…de joins Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
_json_body() now validates that parsed JSON is a dict, not a list or other type, preventing AttributeError on .get(). _invite() wraps int(days) in try-except to catch non-numeric values. Both now raise GraphError for 400 instead of uncaught exceptions. Added comments to _create() and _admin() explaining security and response sequencing rationale. 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
validate the --bind port before writing the owner secret, so a typo like --bind localhost:abc fails early without orphaning the secret on disk. also call server_close() in a finally block to close the socket when serve_forever() returns, fixing ResourceWarning on exit. 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
os.open's mode argument only applies when creating a new file; existing files keep their original permissions. A credentials file pre-existing with looser bits would leak tokens to other local users on every write. Call os.fchmod after open to enforce secure permissions regardless of file age. 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
token_urlsafe's alphabet includes '-', and a value beginning with '-' reads to argparse as a flag, not an option's value — knoten remote create failed one run in five with a perfectly valid --owner-secret. owner_secret() and invite() now use token_hex; mint()'s tokens travel only as a git HTTP password, never argv, so they keep token_urlsafe. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
getpass.getpass raised an uncaught EOFError with no terminal to prompt on (cron, CI, a pipe), producing a traceback instead of a one-line refusal. And _explain matched "401"/"403" against relayed remote: lines too, so a rule violation naming a node id like hyp-401-alive read as a credentials problem instead of the actual gate failure. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Pointing sys.stdin at an empty StringIO made getpass.fallback_getpass print a GetPassWarning about not controlling terminal echo on every run. Stubbing knoten.remote.getpass.getpass to raise EOFError directly gets the same no-terminal behaviour without the warning, keeping test output pristine. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
…mmands Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The server consumes the code before git clone runs, so a clone failure left the user only git's error and a stored credential nobody explained — they retried the same code and got refused for what looked like an unrelated reason. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
…none 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
A bare "401"/"403" substring search also matched git's own `fatal: unable to access '...'` line, so a port or graph name containing those three digits flipped the verdict -- the hub fixture binds port 0, and plenty of ephemeral ports contain "401". Match `returned error: 401/403` and `HTTP 401/403` instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
/join takes no credentials, so it must answer a wrong code and an unknown graph identically -- refusing to touch registry.redeem for a graph that does not exist keeps /join from being a name oracle, matching what /git already does for git-receive-pack and git-upload-pack. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
"Content-Length: abc" raised ValueError unguarded, escaping _route's except GraphError and killing the thread with no response to the client. "Content-Length: -1" reached rfile.read(-1), which reads until the socket closes -- an unauthenticated thread-exhaustion primitive on a connection the client never closes. Both are now a 400 GraphError. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
_body only reads Content-Length bytes; a Transfer-Encoding: chunked push (git goes chunked above http.postBuffer, default 1 MiB) handed http-backend an empty stdin, which died and left the client with an inscrutable bare 500. A plain `git clone` of a hosted graph, which the README treats as normal, hit this on any push over 1 MiB. Refuse with a 411 that names the fix. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
- serve.py: skip a relayed content-length header before appending
knoten's own, so a dumb-protocol response does not carry two.
- registry.py: entry.get("hash", "") instead of entry["hash"], so a
hand-edited or truncated tokens.json fails closed, not with a
traceback.
- README.md: the example invite code is pure lowercase hex
(token_hex), not dash-separated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
A symlink in a pushed tree resolves against the server, not the pusher. A write user pushed `g/nodes -> /some/server/dir` and the gate validated that directory, echoing its file names back on the `remote:` lines. The unpacked tree now loses every symlink, and a graph must have a real nodes/ next to its graph.yaml. The find-into-a-file-then-read loop also split any path containing a newline across two lines and validated neither, so `git archive` names that `git mktree` accepts walked past the gate. -exec passes the paths as arguments instead; `read -d ""` would be the other answer but it is bash, not the /bin/sh this hook runs under. 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
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
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
…hing 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
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
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
…oken 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
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
…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
…#34) The graph owns identity: signed contributors, invites and revocations
The client returns when the two-byte body lands; the handler's except block prints after that, on its own thread. Under load the test read stderr first and counted zero lines. Zero is the race, two is the double answer the test exists to catch, so it now waits for one and still insists on exactly one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb (cherry picked from commit 0a07ac5)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A graph is a folder in git, so sharing one is already
git push. What was missing is thegate.
knoten hookprotects the person who ran it, in the clone they ran it in, andgit commit --no-verifywalks past it. On a shared graph the normal contributor is onewho never ran it, which left the rules resting on everyone's individual discipline. That
is precisely the failure the rule engine exists to replace.
What this adds
knoten hook --server [REPO]installs apre-receivehook on the repo everyone pushesto. It unpacks the tree being pushed, finds every graph in it, runs
knoten validateon each, and refuses the push if any fails.
No CI, no runner, no minutes, and nobody can skip it from a laptop. It is also strictly
better than a CI check for this job: it refuses the push instead of reporting afterwards
that master is broken.
Three things it has to get right
Each has a test that fails without it.
It finds the graph rather than recording a path. A bare repo has no working tree, so
there is no
graph.yamlto read at install time. A path recorded then would rot themoment someone moved the folder, and rot silently: the hook would find no graph and
accept everything while reporting green.
It fails closed. knoten missing from the server's PATH refuses the push rather than
waving it through. The test actually strips PATH rather than grepping the script for a
guard.
graph.yamlis not a name knoten owns. Another tool's config of the same name failsvalidation, and treating it as a graph would make the whole repo unpushable forever,
citing a file nobody thinks of as a graph. A graph is one with a
nodes/directory ora knoten-specific key. Both tests are needed: git does not track an empty
nodes/, so agraph whose nodes were all deleted slips through the first, and a
graph.yamltoomalformed to name its keys slips through the second.
One shell detail worth flagging in review:
git archiveandtarare two statementsrather than a pipe, because POSIX
shreports only the last command's status. A pipelinewould hide a failed archive behind a happy
tarand accept the push unchecked.Testing
tests/test_server_hook.py, 23 tests, all doing real pushes against real bare reposrather than asserting on the script's text. Full suite 314 passed.
Covered: rejection and acceptance; a graph in a subdirectory; two graphs in one repo;
a repo with no graph; branch deletion (all-zeros sha); knoten absent from PATH; an
unrelated
graph.yaml; malformedgraph.yaml; an unparseable node; deleting the graph;every ref in one
git push --all; a non-master branch; a force push; a tag; tempdirectory cleanup under a controlled
TMPDIR; and a repo path containing a space, whichis the classic way this breaks and it breaks open.
Verified end to end outside the suite: a contributor who never ran
knoten hookand usedgit commit --no-verifywas still refused, and the server kept only the clean commit.knoten vizwas rendered from a fresh clone of the shared repo to confirm the read-onlypath needs nothing installed.
Docs
README gains a
## A shared graphsection covering the setup, why nodes go straight tomaster while
graph.yamlis the file worth protecting, why concurrent edits do notcollide (edges are declared once on the subject, back-links are generated at load), and a
pointer to Forgejo for anyone wanting per-user permissions or required approvals, since
that is the forge's job and not knoten's.
SKILL.mdgains a pull-first line, because afrontier computed from a week-old clone recommends work a collaborator already settled.
Not in scope
Cross-graph citation, a graph-level diff, and any notion of accounts or approval quorums.
Approval quorums in particular belong in a forge:
pre-receiveis binary and has nopending state, so building "this needs two approvals" on bare git means reinventing pull
requests.
🤖 Generated with Claude Code
https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb