Skip to content

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
masterfrom
share/server-gate
Open

shared graphs: the gate on the server, and knoten serve for remotes friends can be invited to#32
BY571 wants to merge 84 commits into
masterfrom
share/server-gate

Conversation

@BY571

@BY571 BY571 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

A graph is a folder in git, so sharing one is already git push. What was missing is the
gate. knoten hook protects the person who ran it, in the clone they ran it in, and
git commit --no-verify walks past it. On a shared graph the normal contributor is one
who 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 a pre-receive hook on the repo everyone pushes
to. It unpacks the tree being pushed, finds every graph in it, runs knoten validate
on each, and refuses the push if any fails.

# on any box you and your collaborators can reach
git init --bare lab-graph.git
knoten hook --server lab-graph.git

# everyone else, human or agent
git clone you@box:lab-graph.git

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.yaml to read at install time. A path recorded then would rot the
moment 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.yaml is not a name knoten owns. Another tool's config of the same name fails
validation, 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 or
a knoten-specific key. Both tests are needed: git does not track an empty nodes/, so a
graph whose nodes were all deleted slips through the first, and a graph.yaml too
malformed to name its keys slips through the second.

One shell detail worth flagging in review: git archive and tar are two statements
rather than a pipe, because POSIX sh reports only the last command's status. A pipeline
would hide a failed archive behind a happy tar and accept the push unchecked.

Testing

tests/test_server_hook.py, 23 tests, all doing real pushes against real bare repos
rather 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; malformed graph.yaml; an unparseable node; deleting the graph;
every ref in one git push --all; a non-master branch; a force push; a tag; temp
directory cleanup under a controlled TMPDIR; and a repo path containing a space, which
is the classic way this breaks and it breaks open.

Verified end to end outside the suite: a contributor who never ran knoten hook and used
git commit --no-verify was still refused, and the server kept only the clean commit.
knoten viz was rendered from a fresh clone of the shared repo to confirm the read-only
path needs nothing installed.

Docs

README gains a ## A shared graph section covering the setup, why nodes go straight to
master while graph.yaml is the file worth protecting, why concurrent edits do not
collide (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.md gains a pull-first line, because a
frontier 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-receive is binary and has no
pending state, so building "this needs two approvals" on bare git means reinventing pull
requests.

🤖 Generated with Claude Code

https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb

BY571 and others added 28 commits September 4, 2026 22:08
… 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
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
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
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
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
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
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
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
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
BY571 and others added 24 commits September 5, 2026 18:45
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 5 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
…#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)
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