Cool stuff! This has a lot of similarity to my early idea for the arbiter’s permission design with spaces being able to delegate access to other spaces and with monotonic access levels.
How is this different than the Arbiter?
From Zicklag’s most recent post, the arbiter is more focused on, well, arbiting reads and writes to a shared community or org PDS.
This is not quite true.
With the new design I realized that customizable policies over XRPC calls allows you to implement fine-grained, per-space, record-dependent access control generically, without locking anybody into a specific permission setup. That was the big change from previous designs.
But the full intention is still to allow for fine grained access to spaces. This is exactly what we will need in Roomy, where we have similar document editing, sharing, and commenting needs for our wiki pages.
Putting the arbiter’s policy engine in front of a generic space host would allow it to implement the earlier arbiter’s access level system, simplespace, opensocial, and Habitat’s ReBAC just by providing a different policy.
They can all be expressed as a policy over XRPC calls.
One major open question we have is around space host interoperability: if two space hosts both implement this API, how might they interoperate to support relationships like “readers of space A on space host A now has access to space B on space host B”?
This is something that I thought about quite a lot while designing the access level system for the old arbiter. I have a Quint specification for the old system that helped reveal some gotchas.
You probably won’t run into this particular gotcha, and took a long explanation so here’s it in a “details” block to avoid a huge wall. 
The biggest gotcha was related to the fact that the ReBAC delegation is recursive and can contain cycles. When implementing this in the old Arbiter I handled it by tracking the set of sub-groups that I had visited to avoid re-visiting so that I didn’t recurse infinitely if there was a cycle. This is apparently what OpenFGA does, too.
This works fine when you are resolving all the groups on the same host. The gotcha had to do when resolving members from remote hosts.
When resolving members from remote hosts I was using the equivalent to your relationship.resolveRelations endpoint, which was expected to return a flattened list of members.
So if host A had a document A/example.doc/1 and it said that everybody with read access to another doc, B/example.doc/2, had read access in A/example.doc/1, then host A would try to call resolveRelations to get the member list of host B’s document.
Because host B is expected to return a flattened member list, it may, in turn, go and call resolveRelations on another host, which might eventually try to resolve the member list of A/example.doc/1 again, creating a loop where the servers all just keep calling each-other’s endpoints in a loop until they run out of memory and crash.
The issue is that there is no recursion state that is passed along when you call another host. It can’t tell if you’re asking for the member list because you’re in a loop, or just because you need it again.
My solution at the time was to pass a “max resolution depth” when making resolveRelations requests. Each host would decrement that when sending triggered resolutions to other servers, and a server that got a request with a resolutionDepth of 0 would return an indication that the user list was possibly incomplete because it needed to resolve another host’s list but it wasn’t allowed to. This would break multi-host cycles without needing a lot of state.
Another possible solution would be to resolve the non-flattened member list from remote hosts, and then you can do the recursion and break cycles with a visited list, just like we do locally.
That seems like possibly a better option. I previously liked the simplicity of getting a flattened list from remote endpoints because it allowed the remote service to more easily customize the response without needing to justify why someone was on the list. My impression was that it would make it easier to make external integrations, such as one that returns all the users in a GitHub team as a member of a space.
The point was to not need a lexicon that defines who is a member of the space, so that the actual member list could be computed in a space-host-specific way.
If you commit to using ATProto records for the member lists ( which is a good idea generally, for portability like you point out ), then I think it’s pretty reasonable just to sync those member lists and handle recursion on top of those instead of trying to break recursion with cross-server state.
Anyway, this seems pretty cool!
It doesn’t seem like there are a lot of barriers to turning this in to a spec others could implement.
I think maybe the biggest question is more broadly about how we can, or if it’s possible to, have communities, which can only have one space host, somehow interoperate with groups / roles defined on other kinds of space hosts.
It seems almost unfortunate that a community would necessarily have to pick one kind of space host, and that it excludes it from properly integrating with roles from other communities, but I think that is unavoidable.
I tried to lay a foundation in the arbiter for “policy composition”, such that we can represent a combination of different policy systems for different apps / standards.
I think this might allow one community to simultaneously implement simplespace, opensocial, and habitat relations in the same community, but it’s untested.
I think opensocial sets a good goal of trying to be the simplest thing that we can get a wide audience to agree on. This excludes ReBAC as a goal most-likely.
But I do really like delegating access to other space memberships, which is why the old arbiter did that.
Maybe opensocial could add the ability to make spaces members of roles, as well as DIDs, even though it doesn’t mandate that affecting the actual permissions directly? 
Maybe that’s just a bad idea. What I’m thinking is that it would be nice if we could have the same role / group membership lexicon or something between opensocial and habitat.
That way hierarchichal roles are portable, even if they are not totally respected by all space hosts.
i.e. that would result in some space hosts not granting all of the access, but it would never result in someone getting more access than they all have.
This idea is half-baked, so not really sure about it at this point. Maybe it’s unnecessary to share a lexicon because the very fact that both standards have public lexicons means that you can migrate from one to the other if you really need to.