A handle, a DID, an email address and a typo all arrive as strings. Deciding
which is which used to be a did: prefix test in one place and a split('/')
in another, and the failures were quiet: an ordinary git email posted to a
public handle resolver with the address in the query string, an environment
variable naming nobody producing the advice for a variable naming somebody
else.
A default is a person's. The pointer is machine-wide, and rule 4
used to be consulted by every process, so the first agent to log in on
a fresh machine could write it and every later process — other agents
and a person's scripts included — fell back to that agent's credentials.
auth default now refuses when an agent runs it, refuses an account
that holds no person's login, login writes the pointer only for a
person's login, selection consults it only on the human plane, and an
account losing its person's grant takes the pointer with it. Machines
upgrading with a stale pointer naming an agent-only account ignore it
rather than acting as the agent
Every argument that names an account is spelled HANDLE|DID, and every
line of advice that names one says <handle|did>. auth login was
declared <HANDLE> and documented as taking a handle — to the point
that scope.rs stated in a doc comment that it rejects a DID — and
pr list --author printed clap's default AUTHOR. Both took either,
through the one classifier, and always had. A test walks the command
tree by argument id, so the next command to take an account cannot pick
a third name
auth status re-resolves a handle only once its cache has gone stale.
It resolved every account's DID document and then let
HANDLE_CACHE_TTL decide whether the answer was worth writing down —
putting a day-long timer in front of a lock acquisition and behind a
network round trip, the wrong way round by orders of magnitude. On a
machine holding an account per agent that is one request per agent, to
somebody else's host, to print a column. Measured beforehand: 0.7s for a
hundred accounts and 137s for a thousand with the network answering
instantly, which is superlinear and nothing to do with latency.
What it costs is a handle up to a day out of date, and that is the right
trade: a handle is the one thing here that is not an identifier, nothing
is keyed by it, and every record, remote and session is addressed by the
DID. The single document status reads for the resolved account's PDS
stays, being O(1) in the listing. Counted rather than timed by a test,
so it asserts the request was not made rather than that it was quick
auth status looks accounts up concurrently. It read one DID document
per account in sequence, so the command scaled with how many accounts a
machine had rather than with anything a reader cares about — and it is
the command somebody runs because something is wrong, so it degraded
exactly when it was needed. Bounded by
clients::atproto::handles::CONCURRENCY, reused rather than paired with
a second number for the same work. buffered and not
buffer_unordered, so rows still print in the order known() produced
them and one run can be diffed against the last. The cache repairs stay
sequential on purpose: refresh_cached_handle takes the config lock, so
running those concurrently would trade N round trips for N waiters on
one file, and it writes nothing when the cache already agrees. Measured
on three live accounts: 0.47s to 0.25s
A worktree can be its own account. git config --local in a linked
worktree writes the file every worktree of the repo shares, so N agents
in N worktrees could not hold N identities — whoever ran repo configure
last set it for all of them, silently. Rank 3 now reads --worktree
first and --local second (never global, for the two reasons
local_config gives), which is also the order git itself resolves
user.email in: reading only the shared file had atgc acting as one
account while git commit authored as another, in the same directory.
repo configure in a worktree enables extensions.worktreeConfig and
writes there, --local forces the shared file, and a git too old to do
it errors before anything is written rather than after. core.sshCommand
follows the identity, since a worktree acting as its own account has to
push with that account's key
A linked worktree does not fall through to the default-account pointer.
That pointer is a machine-wide default for a checkout that says nothing,
and in a worktree it is how an agent publishes as whoever owns the
laptop. The sole-account rank is deliberately kept: a default is a choice
among several, and with one account logged in there is no choice — taking
it away would break repo configure in a worktree for anyone with a
single account, since that command skips rank 3 by design. A worktree
still reading the shared identity is legal and now says so
auth login resolves handles DNS-first, as the handle spec orders it.
jacquard's resolver has both steps in the right order but
JacquardResolver::new — which OAuthClient::new calls — leaves its
DNS field None, and dns_txt then returns an empty vector rather
than an error, so the step was a silent no-op and every handle went to
https://<handle>/.well-known/atproto-did. Found on a handle with a
good TXT record whose A record pointed at a parked IP that drops
packets: one DNS query away from resolving, 30s of stall on every login
One identifier classifier (src/lexicon/identity.rs) behind every place an
account is named — --account, ATGC_ACCOUNT, auth login/switch/ logout, repo list <owner>, pr status --author, and a checkout's
user.email. DID syntax per the atproto spec rather than a did:
prefix test, did:plc's 24 base32 characters, hostname-only
did:web, and handles by the handle spec. An ordinary git email is
named as one and refused locally instead of being sent to a public
handle resolver with the address in the query string
DID methods parsed as a variable, not hardcoded: an unrecognized
method is still a valid DID and can still select an account (refusing
it would fall through to somebody else's), and only fetching its
document refuses, naming the method. did:web gained its first test
coverage and a real document fixture
The disallowed-TLD list from the handle spec (.local, .arpa,
.invalid, .internal, .localhost, .example, .alt, .onion) is enforced,
and was already: jacquard::types::string::Handle refuses one, which
is why explain_handle carries a DISALLOWED_TLDS branch whose only
job is to name the reserved TLD — it exists precisely because that
is the one refusal domain knows nothing about. Verified:
ATGC_ACCOUNT=a.localhost is refused with ".localhost is reserved and
cannot be a handle".
The entry's own worry is the live behaviour, not a hypothetical:
"enforcing it would break anyone pointed at a local test PDS" is what
happens today. If that ever costs somebody something, the fix is an
escape hatch, not the enforcement — but nobody has asked
account::names_account prefix-tested for did: and answered a
boolean, so a value the classifier rejects and a value naming somebody
else both came back false and both produced EnvWins — "something
else outranks you" — when only one of them is true. As the entry said,
routing it through classify alone changes no outcome; the fix is the
third answer. classify_env returns Names / NamesAnother /
NamesNothing, and Advice::EnvUnusable is the new variant:
"ATGC_ACCOUNT=me@example.com names no account, so nothing that needs
one will work until it is changed", with "or unset it" as a remedy that
is real here and nowhere else in this function. Two lines, for
RepoWins' reason. The classification is lexicon::identity::classify
so that what counts as an identifier here is what resolve_spec will
decide a moment later
auth default and auth logout named their positional field
account, which clap resolves to the same argument id as the global
--account selector — so --account silently landed in the
positional instead of choosing who to act as, and
atgc auth logout --account <handle> failed to parse at all. Same bug
class already fixed once, for repo list and key list: renamed both
fields to owner, carrying the same explanatory comment
The atproto vocabulary comes from jacquard rather than being restated.
Did/Handle carry the grammar and identity.rs explains a refusal in
atgc's own words afterwards; AtUri decides whether a string is a
record URI, which the old split('/') could not — it knew nothing about
NSIDs; DidDocument replaces indexing into a serde_json::Value by
hand. Breaking: the handle spec's reserved TLDs (.local, .internal,
…) are refused now, where this module deliberately allowed them. Such a
handle was never resolvable — the list exists because those names are
not globally unique — so what changed is where the refusal happens
A handle resolves the four ways the spec defines — DNS TXT, well-known,
the account's PDS, then a public appview — instead of only asking
public.api.bsky.app. A self-hosted account whose appview has never
indexed it did not resolve here at all, and every handle anyone named
went to a third party. jacquard::identity was already compiled in:
dns is in jacquard's default features, so atgc had been shipping
hickory-resolver and never calling it. Demonstrated on this project's
own handle, which now resolves by DNS with zero requests to Bluesky
A display name and avatar come from the account's own repository when
the appview cannot have them. The same argument as the handle entry
above, one field further on: app.bsky.actor.profile at rkey self is
where a display name is written , and public.api.bsky.app is an index
of it, so an account that index has never seen had no name and no
avatar. did:web skips the appview outright — atproto did:web is
self-hosting by definition, so asking is one guaranteed-useless request
to a third party per account — and every other method still asks first,
because what comes back includes the avatar as a CDN URL where the
record carries a blob reference to assemble. A miss then falls through
to the record whatever the method, which is what covers the commoner
self-hosted shape: a did:plc whose DID document points at its owner's
own box. Verified against a live PDS: the record carries
displayName, and the getBlob URL the code builds answers 200
image/jpeg
One DID-to-handle resolver, clients::atproto::handles, in front of a
memo that lives exactly as long as the process. Four listings had their
own buffer_unordered over handle_from_did_doc — pr list, issue list, search and a view's comment thread — differing in the parts
nobody meant them to differ in and agreeing that each would re-fetch a
document the same invocation had already read. What is cached is only
DID to handle, only successes, only up to a cap, and never to disk: a
handle is a hint that can be re-registered by somebody else, so seconds
is the whole safe window. The other direction is not cached at all —
resolve_handle decides which account atgc acts as, and repo checkout keeps asking the document itself because that handle is
written into a checkout's config