chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
# Git collaboration without a pull request
**Status:** prototype, 2026-08-21. Runs locally with `just dev-git-collab`.
Nothing here is deployed and no lexicon is published.
## The question
A forge's pull request is one mutable row that several people write: the
branch, the review state, the merge button. A repository in an atproto account
has no such row. The space model gives every member their own copy on their own
server, the authority's copy is canonical, and no PDS is the merge authority
for the others. `packages/git/README.md` says as much: rules that need a
central merge authority, such as required reviews, have no enforcement point
and are deliberately absent.
Agents make the same point from the other side. A change is force-pushed tens
of times before anyone looks at it, so an artifact keyed to a commit id is lost
on every rewrite, and a state field somebody maintains by hand is stale before
it is read.
So the model here is: **a push is the proposal, and every other artifact is a
signed statement by a DID about an immutable subject.** The reader aggregates.
## What the prototype builds
- `contributorsOf` and `reviewersOf` in `packages/git/src/rules.js`. Two DID
lists in the `dev.pdsjs.git.config` record beside the repository. atproto has
no reverse index, so this list is the only thing connecting the canonical
repository to the copies on other servers.
- `dev.pdsjs.git.review` in `packages/git/src/lexicon.js`. A verdict, a note, a
strongref to the repository record version, a sha, and a Change-Id. It is
written into the reviewer's own repo, so a review needs no write access to
the repository it is about.
- An optional `changeId` field on the check record, and the `Change-Id:`
trailer read from a commit message. Gerrit's spelling, which jj already
writes. The runner reads it from the commit it checks out.
- `packages/git-ui/src/lib/collab.js`, which fans in: contributor DIDs from the
config, their PDS from the DID document, and their records and bundle blobs
from that server directly. Every read is anonymous and cross-origin.
- A work screen at `/<repo>/work`, and a commit page that reads a contributor's
copy at `/<repo>/commit/<did>/<sha>`.
- `scripts/dev-git-collab-demo.mjs`: four accounts on four servers, one
repository, a landed change, a rebased change, a conflicting pair, a change
behind main, and reviews from a human and from an agent.
## What it settles
**Fan-in works, with no server support.** A page served from alice's site
renders the diff of a commit that exists only in bob's bundles on bob's server.
The reader needed one thing the app did not have: an XRPC handler that honours
the `did` parameter rather than answering from this account's own records.
**Live and landed need no stored state.** A tip canonical main reaches has
landed; one it does not reach is in flight, ahead by the commits main lacks and
behind by the commits it lacks. Both walks come from different servers and
neither holds the other's objects, but a commit id is the same id everywhere,
so the intersection of the two walks is the merge base. There is no open state
to close and no closed state to reopen.
**A landed change needs the Change-Id to be readable at all.** Once main
reaches a branch there is no divergence left to measure: the change's commits
are main's commits, and nothing in the graph says where the work started. The
trailer does, so the run of commits at the tip carrying one Change-Id is the
change, and a landed change opens as a diff like any other. Without it the only
honest answer is the tip commit alone.
**The Change-Id earns its place at once.** The seed rebases one change and
checks each version. Keyed by sha, the screen shows a green check and forgets
that the first version failed review. Keyed by Change-Id, it shows `v2`, a
green check, an approval, and the earlier rejection against `v1`. That is the
single cheapest thing to adopt and everything else composes on it.
**An agent reviewer is a DID like any other.** Nova publishes reviews and
pushes no code. Adding it is one entry in the config's reviewer list, and its
verdicts render beside a human's with the source visible.
**File overlap is a useful signal at zero cost.** Two changes touching one file
is not a textual conflict, and the screen says "touches the same files" rather
than claiming one. It is computed from the commit lists already read, and it
tells you where to look. "Main has changed README.md since this branched" comes
from the same pass and is the honest form of "needs rebase".
## What it does not settle
- **Landing.** The screen has no merge button. The authority's `main` is the
only canonical branch and only the authority writes it. A browser-side land
is plausible (`pack.js`, `bundle.js` and `objects.js` already run in the
page, and a push is a bundle plus a `swapRecord`) but it is unbuilt.
- **The integration branch.** The screen counts how many changes touch the same
files as another, which is a fact about now. What could land together is the
next question and it needs a build to answer: a greedy set over a
newest-first list picks between two changes by push order and presents the
coin flip as advice. Building it for real belongs in `@pdsjs/git-ci`, which
already reads the firehose and sees every contributor's push: rebuild the
merge on any push and check the combination, so a cross-agent conflict is
found before anything lands.
- **Intent from the page.** A review publishes from the browser now; the
author's own draft or withdrawal still comes from the seed. Same record,
same session machinery, one more form.
- **Private repositories.** `git-ui` serves public records only, so a space
repository's work is out of reach from this page.
- **A stack's title.** The card shows the tip commit's subject, which for a
twelve-commit change written by an agent is whatever the last commit said.
The branch name is the more stable label and it is the second line.
- **A change page that stands on its own.** A change has a diff and a commit
list. The other two things a forge puts beside them, the checks and the
conversation, exist as records already and have no page here.
## What it costs at the size of this repository
`scripts/dev-git-scale-demo.mjs` pushes pds.js itself into the same four
accounts: 566 commits, 722 files. Two changes of the size an agent writes (41
files over 11 commits, and 24 files over 4), overlapping on 24 files, with main
three commits ahead of both.
- A copy is 4.8 MB in 5 blobs. Three copies of the repository, pushed from
three accounts, take about a second each against a local PDS.
- The work screen renders in well under a second over a blank page, reading
three chains from three servers. Localhost with warm caches, so it is a floor
rather than a figure to quote.
- A change's file list is one tree walk from its merge base to its tip, and
what main has moved under it is a second walk. Both skip any subtree whose
object id matches, so the cost follows the change rather than the repository.
Reading each commit instead, which is what the first cut did, fetches every
blob on both sides of every diff to build hunks the screen never shows.
The presentation is what breaks first, and it broke here: 41 paths printed in a
row buried the check, the review and the conflict under them. A file list is a
count and where the files are, opening to the list.
The other thing size broke is what a change opens as. The card led to the tip
commit, which for eleven commits of work is the last four files somebody
touched. A change opens as `base..tip` instead, one diff over the whole thing,
which for these 41 files is 180 ms and 18 requests against a contributor's
server.
## Offers: how a stranger reaches a repository
A contributor is a DID the repository typed into its config. That covers a team
and covers nothing else, so there are two tiers rather than one gate, the way a
forge already has two: a collaborator pushes a branch, an outsider proposes
from a fork.
- `dev.pdsjs.git.offer` is in the offering account's own repo. It names the
repository, the branch in their own copy that holds the work, and why. The
repository being offered to is untouched: no write to accept, nothing to
delete, and moderation is a read-side filter rather than a queue to clean.
- Nothing can find that record, because atproto has no reverse index. The
runner is what closes the loop: it already watches a firehose and already
publishes to its own repo, so it publishes `dev.pdsjs.git.offers`, one record
per repository, holding what it saw. A reader trusts it exactly as far as it
trusts the runner, which is the bar the checks are already read at.
- The work screen shows offers in their own section, read from the offering
account's own copy, marked as accepted by nobody. Promotion is one edit: add
the DID to `contributors` and the work moves into the list above.
**Indexing every offer on the network costs a socket.** Jetstream filters by
collection server-side, so a consumer asking for `dev.pdsjs.git.offer` alone
receives nothing else. Measured against `jetstream1.us-east.bsky.network`:
| subscription | rate |
| --- | --- |
| unfiltered | 4012 events, 219 KB/s |
| `wantedCollections=dev.pdsjs.git.offer` | 10 events, 0.1 KB/s |
The 10 are the account and identity events every subscriber receives whatever
it asked for. `wantedCollections` takes up to 100 NSIDs or prefixes and can be
changed on a live socket, so one connection covers the whole scheme and the
runner filters to the repositories it watches in memory. There is no appview
here, and none needed: the index is per repository, kept by an account the
repository already names.
What this leaves open is the spam question, and the honest answer is that it is
the same question a forge answers with account bans and rate limits, which
nobody here can do. The filter has to be a policy somebody picks: an allowlist,
the follow graph, a moderation list, an account-age floor, a check that passed.
The default in the screen today is a forge's default, which is to show them.
The runner's compute is the other exposure, and the same one a forge gates by
withholding CI from a first-time contributor.
## Deliberately absent
- No verdict gates a push. `@pdsjs/git` enforces branch protection on the
server that holds the branch, and that is the whole enforceable set. A
reviewer's approval is worth what the reader thinks the reviewer is worth.
- Line comments and replies exist. A review may carry an anchor (path, line,
side, and the line's own text) in the tree its sha names, so placement on
the reviewed version is exact and a rebase demotes the comment to "on v1"
rather than misplacing it. A reply names the review it answers by strongref
and still names the change, so a reader without the field degrades to a
flat stack instead of losing words. The snippet is there for a later,
smarter placement to search for.
- No "request changes" as a state. A reviewer holding their own copy can push a
fix branch instead, and it costs them nothing.
## Design review, end of day one
The prototype was reviewed against alternatives (a PR-record model, a global
appview, patch-based changes, and Tangled's knot-based forge). The direction
holds: every alternative trades away either the zero-infrastructure storage
model or the derivability of state. Four weaknesses came out of the review;
three are addressed in code, one is designed here and built with the daemon.
**One runner could omit.** A runner cannot forge an offer, because a reader
follows its index to the records it names and then to the branches themselves.
It could quietly drop one. The config now takes a `runners` list
(`runnersOf`), the reader unions every named runner's offer index, and an
offer is found if any one of them indexed it. Checks still read from the
first.
**Nothing carried intent.** The graph says a change is in flight; it cannot
say "draft" or "withdrawn", and deleting the branch was the only withdrawal.
The offer record now carries an optional `status` (draft, withdrawn), and a
named contributor writes the same record about their own branch as pure
intent. The reader decorates; it never hides. Derivation still owns the facts,
the author owns the words.
**Change-Id relied on our own tooling.** jj writes change ids natively and the
runner can trail its own commits, but a stock-git commit got an id only from
discipline. `git-remote-atproto install-hook` now installs a commit-msg hook
(Gerrit's approach): a trailer on every commit, preserved through amend,
rebase and cherry-pick, covered by tests against real git.
**Read amplification, deferred to the daemon.** Every visitor pays one DID
resolution and one repo read per contributor, plus commit walks. Measured fine
at this scale; it dies somewhere past a dozen contributors with cold caches.
The fix is the pattern this design already uses three times: the runner
publishes a derived record. A `dev.pdsjs.git.work` record per repository,
holding the Work shape the screen computes today (changes, placement, files,
overlap), written by the daemon on every push it sees, read the way
`latestCheck` is read, with the fan-in kept as fallback and as spot-check.
Design it with the offer indexer; they are one daemon session.
**What would falsify the design.**
- Repositories routinely exceeding a dozen active contributors, or mostly
anonymous cold-cache readers, before the work index exists. The fan-in dies
first; the model survives via the runner index.
- The substrate losing: if the people this is for are already on Tangled,
the collaboration layer has no repositories to collaborate on. The knot
model is the opposite bet (code on separate git servers, not in the PDS);
interop with `sh.tangled.*` collaboration records is worth a decision
before this design's lexicons publish, not after.
- The canonical-main question resolving toward unattended landing. A runner
that advances refs needs a delegated write grant, which changes the trust
analysis above and promotes the runner from witness to actor.
## The daemon closed the loop
The build-order step 1 landed the same day. `@pdsjs/git-ci` now:
- reads the `Change-Id:` trailer of the commit it checks out and writes it on
the check record, so a rebase keeps its runs with no seeding anywhere;
- indexes offers from the wire: a Jetstream consumer (`jetstream.js`) for the
network, the native firehose for local stacks, `PDSJS_CI_OFFER_SOURCES`
naming the sources, and a read-modify-write of the offers record with the
same swap-retry shape `latestCheck` uses.
`just dev-git-collab` runs the whole flow with nothing faked: nova's daemon
watches the three copies, clones every push over smart HTTP, runs the
repository's own `.pdsjs/workflows/ci.yml`, and publishes what happened. Bob's
farewell v1 fails its check because the test really fails ("farewell is
wrong", exit 1, logs in the blob); the rebase to v2 passes; both runs carry
the Change-Id read from the commits. Dan's offer appears on the offered tab
because the daemon saw the record on his firehose and indexed it. The only
seeded artifacts left are the reviews and carol's draft intent, which are the
two records whose writing surface (a signed-in page) is build-order step 2.
Step 2 landed the same evening for reviews. The browser is an atproto OAuth
client of the visitor's own PDS (PKCE, DPoP, PAR; the client `spaces-ui`
established), and the change page carries a composer. The proof drove real
Chrome end to end: sign in as carol from the change page, consent on her PDS,
publish a comment, and the record appears in her repo and on the work screen.
Intent (draft/withdraw) from the page is the remaining form.
## Issues
Issues landed as the same primitive as everything else: a
`dev.pdsjs.git.issue` record in the filer's own repo, replies naming their
parent by strongref, and the state as the owner's published disposition
rather than a mutable field. Closed is a statement attributed, and the
filer's disagreement sits beside it; the page renders a non-owner's "marked
resolved" as the comment it is. Discovery reuses the offer machinery whole:
the daemon indexes issue records off the firehose and Jetstream into a
per-repository `dev.pdsjs.git.issues` record, replies included, and the page
fetches each indexed record from the server that holds it, so the index can
point at nothing but cannot forge a word.
Tangled's `sh.tangled.repo.issue` ecosystem was deliberately not adopted;
the interop question stands where it stood, a decision for before these
lexicons publish.
## Order to build in
1. ~~The offer indexer, and `Change-Id` written on every run.~~ Done; see
above. The work index (the read-amplification fix) still rides the same
write path and is unbuilt.
3. Review writing from a signed-in surface, which is also how an offer gets
written by hand rather than by a seed.
4. The integration branch in `git-ci`, and the conflict matrix on real merges
rather than on path overlap.
5. Landing from the page.