Skip to content

Naming and identity

A repository is identified by its owner’s DID and its key. The name is for people. It can change, and once a repository is deleted or renamed away, another repository can take the name. This page covers how records refer to a repository so that they survive this, how renames and transfers work, and how commits are tied to accounts.

Several records name a repository: a pull, issue, comment or state, a pull’s head, and a fork’s forkOf. Each holds a repoRef: the at:// URI of the repo record as it was named when the record was written, plus the key. Readers match on the URI’s owner and the key, never on the name. So those records follow a rename, and never attach to a later repository that takes the same name.

A knotless://handle/name remote resolves the handle on every use, from a cache kept for KNOTLESS_IDENTITY_TTL_SECS. An account that moves to another PDS is looked up again sooner: once the PDS it was cached as on answers that it is not there (RepoNotFound, RepoDeactivated, AccountDeactivated, or “Could not find repo”), as the one it left does, the cache no longer answers for it, and a command that fails on that answer drops it from identity.json, so the next one finds the new PDS. The helper records the DID the remote first resolved to, in remote.<name>.knotlessdid, and refuses to push or fetch if the handle later names another account.

A rename is one applyWrites that:

  1. creates the repo record under the new name;
  2. deletes the old one;
  3. leaves a redirect under the old name;
  4. repoints every redirect already leading to the repository, so an old name is always one hop from the current one;
  5. deletes a redirect under the new name, if there was one.

A new name another repository has, public or private, is refused before any of it, with <url> already exists: a rename never writes over a repository (see storage). Renaming to the name it has is refused too (<url> is already named <name>).

A lookup by name that finds no repo record reads the redirect under that name and follows it. Clone URLs, remotes and links made with the old name keep working, and git is told the new name. A redirect is followed only within the account, and only to a record with the key the redirect names. A lookup follows up to four redirects: one is the rule, and the rest let a read race a rename. Creating a repository under a name that holds a redirect replaces the redirect. Deleting a repository deletes its redirects.

On the web, a page asked for by an old name redirects to the same page under the new name. The redirect is temporary, because the old name can be given to another repository. Clones over HTTP follow the old name without a redirect.

A private repository’s records stay in the space named for its first name, its home. A rename creates a space under the new name, with the same members, holding only a redirect whose space names the home. A member who opens the repository by its new name finds the redirect there and joins the home. Sharing and unsharing change every such space, and deleting the repository deletes them all.

Members’ cached credentials are bound to a space’s address, so a space’s name is never given to another repository. knotless refuses to create a repository under a name where a renamed repository already has a space.

Neither account can write to the other’s repo, so a repository moves to another account in two steps:

  1. The owner offers it, by setting transfer on the repo record to a repoRef in the new account.
  2. The recipient accepts. The repository is copied into their account with the same key, and with transferredFrom naming the old place.

Only a public repository can be offered: a private one’s pulls and issues are in its space, which cannot move. An offer to the account it is already in is refused (<url> is already in that account: rename it instead). Accepting copies the runners the repository names along with it, and is refused when nothing is offered (<url> has not been offered to anyone) or the offer is to another account (<url> is offered to <did>, not to you).

The owner offers it with knotless repo transfer, MCP’s repo_transfer, or the Danger zone of its settings on the pages; the recipient accepts with knotless repo accept, MCP’s repo_accept, or the offer the repository’s pages show them while they are logged in (getRepo’s viewer.transfer). A rename is the same on each: knotless repo rename, repo_rename, or the pages’ settings.

set transfer on repo record (repoRef in B's account)read the offercopy the repository: same key,transferredFrom names the old placedelete the old copy (optional):its name and redirects now lead into Breaders follow the move only when both sides say soOld ownerNew ownerOld ownerNew owner

A move holds only when both sides say so:

  • A reader following the old name goes to the new place only if the new repo record names the old one in transferredFrom. The old place leads there by its transfer offer, or, once the old copy is deleted, by a redirect into the other account. A redirect to another account is never followed as a rename.
  • Records about the old repository count for the new one only if the old owner’s offer leads there, or the redirect they leave when they delete the old copy does. The appview’s getForge then returns them with the new repository’s own.
  • State and merge-queue records the old owner made before the new repo record was created still count as a maintainer’s. Later ones do not.

Moves are followed across accounts up to four times. The remote helper follows a moved or renamed repository and prints the git remote set-url <remote> <new url> to point the remote at it.

When the old copy is deleted, its name and redirects become redirects into the new account.

The web pages show a commit as its author’s account, with the account’s picture, when they can tie the commit’s author address to an account.

An account’s emails record lists the addresses its commits are authored as. Each entry is the hex SHA-256 of the DID, a newline, and the address trimmed and lowercased. Salting with the DID means the record names no address, and the same address hashes differently in each account that claims it.

Nothing proves that an address belongs to the account claiming it. So a page believes a claim only from:

  • the repository’s owner and collaborators;
  • the author of a merged pull, for the commits that pull brought in. Only the owner or a collaborator can merge, so the merge vouches for those commits.

A merge’s state record says where the base was before the merge (from), so from..commit is what it brought in. For a merge recorded without from, a merge commit brought in its second parent’s history less its first’s, and a fast-forward or squash brought in nothing.

The owner and collaborators are matched first, so a pull’s author cannot take their addresses. Someone who claims your address gains nothing beyond the commits they got merged.

A commit keeps its ID when its history is pushed somewhere else, as in a fork, a mirror, or a copy pushed as a repository of its own. Where a page’s own repository believes no claim for an address in a commit, it asks which accounts other repositories with that commit believe there:

  • Exactly one: the page shows that account.
  • More than one: the page shows none, rather than pick between them.
  • None: the address stays unlinked.

So a copy shows its commits’ authors as the original shows them. Someone who copies your commits and claims your address is believed only in their own repository. Everywhere else the claims disagree, and the commits show as no one’s rather than as theirs.

What each repository believes comes from a walk of its default branch, kept in the index. The walk runs in the background when a page shows the repository’s commits, and goes on from the tip where the last walk stopped. It starts over when the people the repository believes change, or when their claims change, since an address it no longer believes vouches for nothing. Only public repositories the index knows count, and not those taken down. A private repository vouches for nothing outside itself.

A commit knotless makes names who made it as git would where it runs:

  • The CLI and MCP over stdio run on a person’s own machine, so they commit as git does there: user.name and user.email, or GIT_AUTHOR_* and GIT_COMMITTER_*, the config of the repository in the directory they run in included. That names the author of repo commit with no --author, and the committer of a merge, squash, branch update or revert, whose author is the merger too but for a squash’s.
  • Where git has no identity it would not guess from the machine’s name, and wherever a server acts for an account (the pages, MCP over HTTP, a gateway’s commit that names no committer), it is the account: its handle, at <did>@knotless.invalid, which needs no claim.
  • A runner is knotless, at its own account’s <did>@knotless.invalid: the committer of the squashes it lands from the merge queue, and both author and committer of what it writes back for a workflow.

So a history made with knotless reads elsewhere, such as a mirror on GitHub, as one made with git: each person’s own address on what they wrote and merged, and the runner’s on what it landed.

A coding agent has no account to claim its address, so the pages know its face themselves. An author or co-author at noreply@anthropic.com, the address Claude Code signs Co-authored-by trailers with, comes with agent: claude and is drawn with Claude’s mark, whoever claims the address.

The first push from a machine that lands a ref claims the address git authors commits as there, if the account has made no claim yet, and says so. The cache’s emails.json remembers which accounts were looked up, so it is asked once per machine. After that, knotless account email add and account email remove change the list, as do MCP’s account_email_add, account_email_remove and the pages’ settings (addEmail, removeEmail). An address taken back stays taken back. Since only hashes are kept, no one can list an account’s addresses, its own pages included: they say how many it claims, and an address is added or taken back by name.

An account’s page shows its Bluesky profile. A dev.knotless.profile record overrides it on knotless field by field: each field set replaces Bluesky’s, and a field set to '' shows Bluesky’s again. Readers get the merged profile from dev.knotless.getProfile?actor=<handle or did>.

A handle is shown for a DID only if the handle resolves back to that DID.