# Collaboration lives in a space **Status:** first phase built, 2026-08-27; second phase design. Successor to `docs/plans/2026-08-24-pull-requests.md`. That plan settled what a pull request is: a branch in one account's copy, named by `(DID, ref)`, with open and merged read from the object graph rather than stored. This plan keeps all of that and replaces the half that said where the copies live and how a reader finds them. The runner's discovery indexes and its push log retire. Two phases. The first serves the pull request list from the owner's server with a hosted reverse index finding the records, and changes nothing about where anything is stored. The second moves the collaboration into the repository's space, which does the finding itself. The first is most of the deletion and none of the risk; the second is what makes private repositories work. ## What the page costs The pull request list for one repository with five contributors makes 89 requests and reads five servers. Measured against the live demo stack, the list alone is 60 calls before the browser adds its own blob probes: | n | what | | --- | --- | | 22 | ranged `sync.getBlob`, one per bundle chunk of every copy | | 20 | `repo.getRecord`, the copy's record re-read before every operation | | 10 | `repo.listRecords`, two per account for pulls, reviews and config | | 6 | DID documents, one per contributor | | 2 | `app.bsky.actor.getProfiles` | Most of that is inherent to the layout: a copy is on its author's server, so reading five copies is five servers. What is not inherent is that the browser does it. Every read of code went behind a `dev.pdsjs.git` method when the extension seam landed. The collaboration reads never made that move, and they are the only reads that still fan out from the page. The runner has grown a second job on the way. It indexes pull records, issue records and every ref that moves, because atproto has no reverse index and the repository owner has no inbound address. Those three modules are an appview by another name, kept warm by a firehose consumer that can miss. ## The space model is the pull request model The permissioned data proposal (0016) and its implementation in `@pdsjs/spaces` already hold the shape this branch built. Each fact below is in the code today. **One repo per writer, on the writer's own PDS.** `packages/git/README.md`, under private repositories: *"Every member writes their own copy. The space model permits no other write: a member's push goes to a `dev.pdsjs.git.repo` record in their own repo on their own PDS, under the same space and repository name."* Every other member's branches appear as `origin//`. That is `(DID, ref)`, shipped. **The authority knows every writer and its rev.** After a space write, the writer's PDS runs `fireNotifyWrite` (`packages/spaces/src/handlers/write.js`) and posts `{space, repo, rev, hash}` to the authority. The authority records the writer with `putSpaceWriter` (`packages/spaces/src/admin.js`). Discovery, the thing the runner's index exists for, is a property of the protocol. **A public space exists.** `policy: 'public'` authorizes any requester (`packages/spaces/src/handlers/auth.js`). A stranger's push materialises their copy and notifies the authority. A fork is found the same way a collaborator's branch is. **The browser talks to one address.** The git extension reads a space through `host.spaceStorage`, which is gated by collection scope, not by credential. A visitor calls a `dev.pdsjs.git` method anonymously, as for every other read. The space's policy governs third-party syncers only. **Cloudflare implements the port.** `packages/cloudflare/src/space.js` holds `commitSpaceWrite`, so the production deployment is not blocked. So the appview is the git extension on the repository owner's server, acting as the syncer the proposal describes. It is the authority, so the notices arrive natively. One service, because the protocol puts it there. ## One topology, public and private One mechanism, two configurations. Which one a repository gets is a fact about the repository, not about the design. **A private repository is entirely in a space**, under `member-list` or `managing-app` policy. This is what `@pdsjs/git` does today when the remote URL names a space. The canonical copy, every contributor's copy, the pull records, the reviews, the issues, and the checks are all space records. Nothing is on the firehose. Nothing is served to a request with no credential. There is no anonymous clone, which is what private means. **A public repository is in a space too**, under `policy: 'public'`, and the server projects its canonical record into the public repo. Anonymous `git clone` and the firehose read the projection. The projection is the same record value with the same blob references, written to the public repo on every canonical write, so the bundle blobs are referenced from both and `sync.getBlob` serves them. The projection is needed because the space alone cannot serve an anonymous reader. A credential for any space, public policy or not, is minted from a delegation token whose `iss` is a user DID (`auth.js`), so a reader with no account cannot obtain one. And smart HTTP has no space path at all (`packages/git/src/http.js`). A public repository is one anybody can clone, and the projection is what they clone. One canonical, in the space, for both kinds. The alternative, a public repository whose canonical is the public record, gives the owner one remote and the contributors another, and makes going private a change of remote for the owner. See the migration section. | | private repository | public repository | | --- | --- | --- | | canonical copy, `main` | space | space, projected to a public record | | anonymous clone, firehose | none | the projection | | contributors' copies | space | space | | `dev.pdsjs.git.pull`, the author's title and status | space | space | | reviews and issues | space | space | | checks and their logs | space | runner's public records | Reviews and issues go in the space for a public repository too. Left public, a stranger's issue still needs a runner to find. On a public space all of it is readable by any DID, so nothing is hidden; it is pulled rather than broadcast. Checks follow the repository. For a public one they stay the runner's own public records, which `listChecks` already serves. For a private one they must not: a check record names the repository, its branches and its commits, and the log is a blob. The runner writes them into its own writer repo in the space. Today `requestCheck` refuses a repository in a space outright, because a runner has no way to clone it. A runner holding a space credential does, so CI for private repositories is new capability under this plan rather than a thing that moves. Merging is unchanged in shape. `mergePull` writes to the canonical copy in the space, and the projection follows. Reachability is computed against the canonical `main`. Owner pushes go through the space write path, which takes an OAuth `space:` scope or the account password and never an app password. That is the existing rule for space writes, now applying to public repositories as well. ## Migrating between public and private Because the canonical copy is in the space either way, a migration is a policy flip and a projection switch. Nobody changes a remote, the owner included. The blobs never move; which of them the public endpoints serve follows from which records reference them. **Public to private.** 1. Set the policy to `member-list` and add the members. A stranger who forked keeps their copy on their own PDS, which nobody can revoke, but they leave the writer set and their notices are refused. 2. Stop projecting and delete the public record. The bundle blobs are then referenced only from space records, and `sync.getBlob` refuses them from that moment. 3. The runner deletes its public check records for the repository. Its later checks go in the space. What this cannot do is unpublish. The firehose broadcast the history, and the edge cache holds public blobs until they expire. Going private stops disclosure from here on. **Private to public.** 1. Set the policy to `public`. 2. Start projecting. The public record appears with the existing blobs, now referenced from both sides and served. Checks from the private period stay in the space. Later ones are public. ## Discovery is structural Every push into a copy tells the authority who and which rev. That is better than the runner's index in three ways: - It is push, not poll. The pusher's PDS reports at write time. - It self-heals. Delivery is best-effort, and correctness rests on the set hash, so a dropped notice is caught by the next one or a sweep. A runner that misses a firehose event has lost the pull request. - It is attributed. The writer set is the authority's own record of who wrote, not a third party's claim to union across runners. In the `(DID, ref)` model there is no act of opening a pull request. A branch in a copy that `main` does not reach is open. A push is the discoverable event. ## Derived state stays derived Open, merged, ahead, behind and drift are computed on read, as before. The server memoises them in memory keyed by the writers' revs, which every notice hands it. Nothing derived is written to the space. A branch is open because `main` does not reach it, never because a row says so. The space is a store for what was said, not for what the server concluded. ## The push log The UI decision is already made: version numbers and copy counts came out (`7e247e6`, a force-push is an event, not a version number). The runner's `versions.js` outlived that decision because two things still read it: `replacedTips`, which marks a check or a comment as being against a commit the branch has replaced; and the force-push line on the timeline. `versions.js` retires. The server fetches a copy's record on every notice anyway, so it appends `{rev, refs}` as it goes. That is the same log, fed by the protocol instead of a firehose consumer. The stale mark reads from it. The op log (`listSpaceOps`) carries `{rev, cid, prev}` and not old refs, so it is not relied on for history. Whether the force-push timeline line stays is a UI question, left open here. It is one line with no number on it. The stale mark is a correctness signal and stays. ## The runner is CI only What retires from `@pdsjs/git-ci`: `pulls.js`, `issues.js`, `versions.js`, `PDSJS_CI_PULL_SOURCES`, and the `dev.pdsjs.git.pulls`, `.issues` and `.versions` records. 401 lines and an env var. What the runner does instead: it registers with the space as a syncer (`registerNotify`), takes a credential under the public policy, and checks a copy when its notice arrives. This is the pattern `docs/permissioned-data.md` describes for any service following a space. ## What the server serves Two methods, beside the sixteen that exist: - `dev.pdsjs.git.listPulls`: every copy in the writer set, each compared to canonical `main`, with the author's pull record, the reviews against it, the latest checks, and the drift. Open and merged in one answer. - `dev.pdsjs.git.listIssues`: every issue statement in the space, threaded. The browser makes three calls for the pull request list where it made 89. The `(DID, ref)` reader in `collab.js` moves server-side largely as it is; what it loses is `forkedPulls`, the runner-index union, and the five-server fan-out. ## Discovery is a port Every reader of this repository's collaboration asks one question first: which records, in which accounts, are about this repository? The answer is a list of `(did, collection, rkey)`, and the reader follows each entry to the author's own PDS and reads the record there. Whoever answers the question can add noise or omit; they cannot forge a word, because the word is read from its author. That question is a port on the server, with two providers: - **A hosted reverse index over the public firehose**, for a public repository. Constellation (`constellation.microcosm.blue`) walks every record on the firehose and indexes each link by its target, the linking record's collection, and the JSON path to the link. One query per collection answers the question for `dev.pdsjs.git.pull`, `.issue` and `.review`, each of which names the repository at `subject.uri`. - **The space's writer set**, for a repository in a space. Every writer's copy and statement is in their writer repo, and the authority holds the set. This is the second phase. The browser never sees which provider answered. It calls `listPulls`. ## The first phase: a public repository, found by Constellation Proven against production rather than read from the documentation. For `at://did:plc:ye5hhvya5o2fwvouv3wttkkn/dev.pdsjs.git.repo/pds.js`, the source `dev.pdsjs.git.check:subject.uri` answers 248 backlinks, each as the runner's DID, the collection and the record key. The index walks a lexicon it has never seen and a link two fields deep, which were the two open questions. Pull, issue and review counts are zero there only because nobody has filed one on production; the demo stack is local and off the firehose. What this phase keeps: every record where it is today. Copies are public records on their authors' PDSes, and the `(DID, ref)` reader in `collab.js` moves to the server largely as it is. Contributors may be on any PDS. What it skips: the device grant, the projection, and the authority reading a copy across servers under a credential. All three belong to the second phase. What it costs: - **Public repositories only.** A space's writes never reach the firehose, so the index knows nothing of a private repository. That is the second phase's job. - **A dependency on a hosted service with no stated SLA.** The reader's existing fallback for an unreachable runner applies unchanged: the config's collaborators are listed directly, and only strangers go unfound. The index answers which accounts hold records, and that answer caches. What does not cache is the object-graph read below: for each copy the index names, the server reads its refs, walks its branches against main, and derives open, merged, ahead, behind, drift and conflicts. That derivation is per request in the first phase. The memo that caches it needs an invalidation signal, which the second phase's notices are. - **A stranger surfaces only through a statement.** A copy's record carries no link to what it was copied from, so a bare push into a fork is invisible to any index, as it is to the runner's today. An `upstream` field on `dev.pdsjs.git.repo`, written by the helper on push and naming the canonical record, makes the push itself the discoverable event. One optional string; a record without it reads as it does now. - **No push log.** The stale mark falls back on the tips the checks and the reviews name, which is the fallback the last plan already provided for a reader that cannot reach the runner. Production is behind `main`: both deployed servers answer `501 MethodNotImplemented` for every `dev.pdsjs.git` method. Deploying is the prerequisite for either phase reaching a real repository. On a local stack the index sees nothing, since nothing local reaches the public firehose. A stranger's fork is absent from the local demo until the second phase, and present in production from the first. ## The unbuilt piece The authority reading a writer's copy on the writer's PDS. Today `browser.js` opens a space repo with `spaceReader.getRecord(space, repoName)`, which names no writer and reads only the local authority record. `space-remote.test.js` covers the owner's push and nothing across servers. The credential chain exists for the CLI helper: authenticate to one's own PDS, obtain a delegation token, exchange it at the authority for a credential bound to a key, present it with a DPoP proof. The server walks the same chain as its own authority. Blobs come the same way: a copy's bundle chunks are read through `com.atproto.space.getBlob` on the writer's PDS under that credential. This is the core of the work. The projection needs one hook beside it. The git extension has to learn of the authority's own writes into a public-policy space, to write the public record. Today only an external syncer can register for notices (`registerNotify`); the authority's notify handler is where the write is known, and an in-process subscriber is the missing piece. Everything else in this plan is deletion. ## What this accepts **A contributor needs a PDS that implements 0016.** Today that is pds.js. The reference PDS carries it as a moving PR. Say it in the README as a position: this is the permissioned-data-native forge. The accounts contributing to pds.js-hosted repositories today are all on pds.js; the contributor on a host without spaces arrives when that host ships them, and needs nothing built for them in the meantime. **0016 is a proposal.** If it moves, `@pdsjs/spaces` tracks it and this moves with it. The surface this plan stands on is small and the most settled part of the proposal: per-writer repos, the writer set, `notifyWrite`, credentials. ## Signing in without a browser A space write takes an OAuth session with a `space:` scope or the account password, and never an app password (`packages/git/README.md`, `isAppPasswordScope` in `packages/core/src/scope.js`). With every canonical copy in a space, that rule reaches every push. Two clients cannot meet it today. The remote helper authorizes as a loopback client (`packages/git/src/oauth.js`): it opens a local HTTP server and needs a browser on the same machine. A push from an SSH session, a container or an agent has none. Its other path is an app password through `git credential`, which the space refuses. The runner signs in with `PDSJS_CI_PASSWORD`, an app password (`packages/git-ci/src/cli.js`). As a space syncer it needs a credential, which takes a delegation token, which takes a session with space access. An app password session has none. The account password in an environment variable is not the answer. What closes it is the device authorization grant (RFC 8628), which this server can add because it is its own authorization server: - a device authorization endpoint that issues a device code, a user code and a verification address; - the `urn:ietf:params:oauth:grant-type:device_code` grant at the token endpoint, answering `authorization_pending` until the user consents; - a page under `/account` that takes the user code and shows the consent screen the authorization-code flow already has. The helper gains `login --device`: print the address and the code, poll, store the DPoP-bound session the way it stores one today. The runner does the same once at first start and refreshes from then on; the refresh token and the DPoP key it is bound to already travel together in the session file. The loopback flow stays for a machine with a browser. ## A read reaches where a DID says A pull request or an issue names an account by DID, and the server resolves the DID document and reads its `serviceEndpoint` to fetch the records. A `did:web` the caller controls names any endpoint, so the server makes a request to a URL a request chose. It is bounded: a GET of `com.atproto.repo.*` and `dev.pdsjs.git.*` paths, the answer parsed as records. On the open internet that reaches only what any client could. Behind a network where an internal host answers on those paths, a deployment should resolve DIDs through a directory it trusts and hold a resolved endpoint to public hosts. This is the ordinary shape of a service that follows atproto identities, and the note is here so a private deployment reads it before it ships. ## Order to build in **First phase.** Each step lands green on its own; the last three are deletions and nothing in them is a move mixed with a change. 1. Deploy `main` to production, so the git methods answer at all. 2. The discovery port, with the Constellation provider. `listPulls` and `listIssues` over it, reading each copy and statement from its author's PDS as the browser does now. The index answer caches; the object-graph derivation over each copy does not, until the second phase gives it an invalidation signal. 3. `@pdsjs/git-ui` reads the two methods. The fan-out, `forkedPulls` and the runner-index union go. 4. `@pdsjs/git-ci` loses `pulls.js`, `issues.js`, `versions.js` and `PDSJS_CI_PULL_SOURCES`. 5. `upstream` on the repo record, written by the helper on push. **Second phase.** 6. The device grant, on the server and in the helper and the runner. Every later step pushes into a space from somewhere without a browser. 7. The authority reads a writer's copy across PDSes, records and blobs, with a test that pushes from a second server. This is the risk of the phase. 8. A companion space per public repository, `policy: 'public'`, named in the repository's config. The contributor's remote URL names it. 9. The projection: the authority's canonical record in a public-policy space written to the public repo on every change, through an in-process notice. Anonymous clone reads it; the demo's five servers prove it. 10. The writer-set provider behind the discovery port. The ref snapshot per notice. The memo keyed by rev. Private repositories list for the first time. 11. `@pdsjs/git-ci` registers as a syncer and checks repositories in spaces. 12. The demo seed pushes every copy, the owner's included, to the space remote. 13. The README position statement.