# 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 push` by 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 resubmit` does, 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.status` record; the newest one wins. Reopening writes a new `open` record rather than deleting the `closed` one. 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//` names the owner; `tangled.org/` 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.