The model #
Tangled is a git forge built on ATProto, and almost everything surprising about atgc follows from that. This page is the part you cannot work out by reading atgc's code, because it is a description of somebody else's system.
Four parties #
- PDS: an account's data server. Every record lives here: pull requests, repo records, SSH keys, comments. Records in your PDS are yours to write.
- Knot: the git host. It serves clones and performs merges. It authorizes
a
git pushby SSH key, registered per account, and an XRPC call such as a merge or a branch delete by the caller's push access to that repo, which the owner and every collaborator has. Merging is not the owner's privilege. - Appview: the index (
api.tangled.org, and the website). It reads the firehose and answers questions no single PDS can, like "every pull against this repo". - Spindle: CI. atgc does not use it yet.
A pull request is a record, not a branch #
It lives in the author's PDS and carries its own gzipped patches. Three consequences, and they are the ones that trip people up:
- Pushing a branch never updates its pull request.
atgc pr resubmitdoes, by appending a round. Patches are append-only, and every earlier round stays addressable. - Opening one needs no permission on the target repo. It is a record in your own repository that happens to name theirs.
- A pull's state is not a field on it. It is a separate
sh.tangled.repo.pull.statusrecord; the newest one wins. Reopening writes a newopenrecord rather than deleting theclosedone.
A pull's number exists only in an appview's database. It is not in the record, so a number resolves only against a repo the appview knows, and a pull the index has not reached yet has no number at all.
An issue is the same object without the patches #
It lives in the PDS of whoever filed it and names its repo by that repo's
DID, so filing one needs no permission either. Your own issues are one public
read away, while everybody else's are scattered across PDSes nothing
enumerates. Its state is a separate sh.tangled.repo.issue.state record
and the newest wins, exactly as a pull's is. Its number is the appview's
too.
Where the lexicon and the appview disagree, the appview decides what exists.
sh.tangled.repo.issue marks body optional; tangled.org's ingester refuses
a record whose body is empty and drops it without retry. A schema is what a
PDS will accept, not what the network will show, and atgc refuses at the
narrower of the two.
Comments are where the two converge in the record itself. An issue comment and
a pull comment are both sh.tangled.feed.comment, told apart by what their
subject points at. The per-collection comment records the lexicons still
define, sh.tangled.repo.issue.comment and sh.tangled.repo.pull.comment,
are deprecated. The appview ingests a create on either as a no-op, so a
comment written there federates and is never seen.
A repo has its own DID #
Distinct from its owner's, minted by the knot. tangled.org/<owner>/<name>
names the owner; tangled.org/<repoDid> names the repo. Confusing the two is
silent, because both are well-formed DIDs, so the wrong one simply asks the
appview about a subject that has nothing.
The repo's DID document is the one public thing a repo has: no PDS, no
handle, one TangledKnot service naming the host that holds it. Nothing
publishes who owns a repo DID, so that document is how atgc finds the knot for
a repo the acting account does not own. The knot needs no more than the DID,
since a merge is routed by repo and authorized by push access.
Stacks are a chain of ordinary pulls #
Ordinary pulls, each dependentOn the one beneath. There is no stack record
and no stack API, and nothing says a member holds one commit: a stacked pull
is an ordinary pull that names a parent, carrying whatever patch it carries.
A member is a contiguous run of commits — cut at the marks atgc stack mark records, one per commit when a branch has none — and its patch is a
mailbox with a Change-Id: mail header per commit in the run. Marks are
recorded rather than inferred: a branch that merely points into the range is
not a cut, because backup before a rebase would otherwise reshape somebody's
stack in silence.
Those headers are the whole of stack identity. stack resubmit matches a
record to a run when any of the run's ids is one the record already claimed,
which is what lets a member survive an amend anywhere inside it — and what
lets the grouping be re-derived from the records instead of re-stated by the
caller. What the appview does with them is less settled than it looks: its
accessor for a submission's change-id (PullSubmission.ChangeId, read at
core e4eb96e9) answers only for a round holding exactly one patch, and
nothing in the appview calls it. So whether the web can correlate a
multi-commit round is not something this repository has established. atgc's
reconcile does not go through it either way.
A branch that is one change from bottom to top is not a stack: it is one
pull request holding every commit, and pr create is its command.
Marks live in git, not in any record, so they have to travel when the branch
does: atgc stack rebase replays with --update-refs, and atgc stack sync
is that followed by the reconcile. A plain git rebase strands them, and a
stack whose marks are gone falls back to the cuts its records already
describe — degraded, not broken.
The appview rejects would-be DAGs at ingest, so a reorder must be one
applyWrites and never a sequence of writes. Written one at a time, the chain
passes through a state where two pulls depend on the same parent.
Identity #
The DID is the key everywhere: session store, account registry, the
[user] email a Tangled checkout carries. A DID never changes hands.
A handle is display, and mutable. An account can rename, and a released handle can be re-registered by somebody else. So a cached handle is a hint, never proof.
Logging in grants no push access. The knot authorizes pushes by SSH key, per
account, so atgc key add is a separate step and its absence fails without
mentioning keys.
Sessions expire on a schedule that is not ours #
atgc is a public (localhost) OAuth client, and the spec caps those sessions at
two weeks. A lapsed token is not a logged-out account: the registry outlives
it, so the account still lists and is still selectable, and atgc auth login
re-authorizes it.
The cap is not liftable on terms atgc will take. Only a confidential client
gets a longer session, only a web client may be confidential, and only a
native client may use a loopback redirect — so a confidential atgc would need
a hosted HTTPS callback standing between a login and the PDS. That is a
runtime dependency on somebody's server for the one command that has none, and
it is refused. See plan/sign-in.md.
The client_id a grant is issued to includes the login's ephemeral callback
port, so it cannot be reconstructed later. It is recorded at login, and a
refresh presenting any other one is refused.
An absence only means something over a complete read #
Every read in this tree can stop early. A PDS listing has a page budget; a walk that looks backwards for records about a subject stops at a floor; the web scrape stops at a probe budget; a listing walk stops when a page cap runs out. That is not a flaw — the alternative is a command that reads a hundred thousand records to print thirty — but it puts one obligation on both sides of every one of those reads.
A walk that can stop early has to say that it did, and a caller that draws a conclusion from what did not come back has to ask. Absence is the load bearing part of a surprising number of answers here: "this branch has no pull yet", "this pull is not in a stack", "nothing has closed this issue", "no status record means still open". Every one of those reads a gap, and a gap left by a walk that ran out of budget looks exactly like a gap left by a record that was never written.
The failures are all the same shape and this project has shipped several of
them: a status walk that hit its page cap read as "no merge, still open", so
pr close would write over a merge it had not seen; a repo listing capped at
eight cross-account pulls reported itself complete; a title unique among the
rows a walk saw was treated as identifying a record that was not among
them. In each case the fact existed a layer down and was flattened away on
the way up.
The carriers are deliberately not one type, because they answer different
questions: pds::Evidence pairs how deep a listing was allowed to go with
whether it got there; pds::RecordsAbout says whether a backwards walk
reached its floor; web::Backfill counts what a probe budget never opened;
web::pulls::Coverage says whether a listing walk saw every numbered pull.
What they share is the obligation, not the shape. A new walk joins them by
answering the same question, and a Vec that answers it nowhere is the bug.
The index lags, so it is opt-in #
Routinely, and sometimes for hours. What makes that worth a section is not the delay but the shape of the failure: a stalled index does not answer "I don't know". It answers a question about a world several hours old, in the same shape and with the same confidence as a fresh one. There is nothing in the answer to distrust.
So reads go to a PDS, whose records are never behind, and no index is
consulted unless asked for. That is achievable for your own pull requests,
which are records in your own PDS, and impossible for anybody else's, which
are scattered across the PDSes of people nothing enumerates. A repo listing is
therefore yours-only by default and says so, rather than being repo-wide and
wrong. Asking is --source.
The trade runs the other way in one place. stack resubmit, stack merge and
pr merge read a listing to find a reason to refuse, such as a pull
depending on this one, and there a source that sees less cannot refuse more.
They read everything. A stale row costs an unnecessary refusal; it cannot cost
a merge that should not have happened.
Search is the exception with no fallback. Full-text search across every record
on the network is the work an appview does. No PDS holds it and no knot
holds it, so atgc search has nothing to merge, nothing to cross-check, and
no way to tell "the index has not reached this yet" from "it does not exist".
It refuses until an index is named rather than answering off one silently.