Many:Many Entity Relation Lexicon

Hey folks, I’m going to start asking for feedback on lexicon shapes for @openmusicevent.app over the next few weeks. I’ll start with a higher-level one that should be useful outside of my app too.

I think we should discuss a community lexicon primitive for adding public, typed edges to the social graph

ATProto makes ownership of a record very clear. But there may be many people or records that semantically contributed to a record, even if that record does not live in their PDS.

In OpenMusicEvent, I have a need for this shape across a lot of domains.

A concert or event may have dozens of contributors: leads, media managers, promoters, sound techs, stage hands, etc.

A performance may have many contributors. There’s not only the primary musician or band, but also dancers, visual artists, or multiple primary musicians, like a b2b DJ set.

Outside of that domain, this is still broadly applicable. An ATProto app has developers, designers, marketing contributors, maintainers, sponsors, and so on.

So that leads me toward a broadly applicable lexicon-level shape for this. Bikeshedding welcome.

A record owner can publish this edge record between two entities:

@LexiconRecord
public struct Relationship: Equatable, Sendable {
    // The record author is the asserting authority.
    public var subject: Entity
    public var role: RelationshipRole
    public var object: Entity
    public var createdAt: Date

    @OpenUnion
    public enum Entity: Equatable, Sendable {
        case identity(DID)
        case record(RecordRef)
    }
}

This means the record owner’s repo can say:

  • woody.fm is a role.developer for openmusicevent.app
  • overgrow.fm was a role.dj for an app.openmusicevent.performance record
  • A painting was role.displayed at wickedwoods.ca/2026

If a record or identity crosses repo/PDS boundaries, we’ll want a two-way confirmation shape:

@LexiconRecord
public struct RelationshipConfirmation: Equatable, Sendable {
    // Published by the subject / contributor.
    public var relationship: StrongRef<Relationship>
    public var status: ConfirmationStatus
    public var createdAt: Date
}

So the relationship is asserted by the owner/authority, and the subject can separately confirm, reject, or revoke confirmation.

Prior Art

Open questions:

  • Should role be a plain open string with knownValues, or token refs?
  • Should object always be required, or can it default to the record author’s identity?
  • Should confirmation only support accepted/rejected/revoked, or is there a better vocabulary?
  • Is Relationship too broad a name for a community lexicon?
1 Like

Watch @brittanyellich.com’s talk on her Open Social Who owns the group chat? Building collaborative spaces on ATProto — ATmosphereConf 2026

And also you’re going to want to look over a bunch of the Permissioned Data / community discussions happening here → Working Groups > WG Private Data Working Groups > WG Community Standards

I’m familiar, but I’ll dig in again. Anything I should pay close attention to? I’m expecting this composes well with Permissioned Data and shares a lot of similar shapes with opensocial.community

I was intending this to be a lightweight link between records, adding an edge in the graph that represents their semantic relationship.

Maybe that’s enough of a core primitive to layer opensocial.community top of, but this focuses more on filling in the public graph with useful links between important entities that can describe their relationship.

opensocial has this interesting link structure where either side can “withdraw” that I thought it would be interesting for you to look at.

I think that either this is compatible with other “group” (and thus, potentially as well spaces, although they are distinct) where you have roles. If it’s not that, I think this is going to be pretty application specific and don’t know if others would re-use something generic.

Your app example in particular sounds like a group with named roles - but this is very much coming at it from a group perspective.

Basically, if you want to make something that’s shared … you have to find others who might re-use such a shape!

Yeah, I think this is adjacent to community.opensocial.membership, but not quite the same shape.

My read of the group shape is: this set of identities share or participate in an organizational identity. That is useful, and OpenMusicEvent has places where I want exactly that: organizer teams, shared publishing authority, community membership, etc.

The thing I’m trying to model here is lower-level than that.

Not every record should be upgraded into an identity, and not every credit/collaboration needs group or access-control tooling. Some things are just public typed relationships between entities.

A performance is like that in my domain. A movie credit is probably the more obvious general example.

An atproto-pilled movie production might have an OpenSocial group while the movie is being made. That group can handle collaboration, membership, permissions, etc. But once the final movie record exists, the credit list is not really “membership in the production group” anymore.

Paramount can own the canonical movie record and publicly assert that Villeneuve was the director. Denis does not need to participate in that assertion for the movie to display its credits.

The part I still want from the dual-record/proof shape is that the other side can control their own listing. Paramount’s assertion should not force something onto Denis’s profile forever. Denis can publish a separate confirmation/rejection/revocation record in his own repo, and clients/AppViews can choose how to display owner-asserted vs confirmed credits.

So I’m thinking about a generic relationship/attribution edge, not a group primitive.

@LexiconRecord
public struct Relationship: Equatable, Sendable {
    // The record author is the asserting authority.
    // In these examples, subject is the credited entity,
    // and object is the thing it is credited against.
    public var subject: Entity
    public var role: RelationshipRole
    public var object: Entity
    public var createdAt: Date

    @OpenUnion
    public enum Entity: Equatable, Sendable {
        case identity(DID)
        case record(RecordRef)
    }
}

That lets an authority repo say things like:

  • woody.fm was role.developer for openmusicevent.app
  • overgrow.fm was role.dj for an app.openmusicevent.performance record
  • an artwork record was role.displayed at wickedwoods.ca/2026
  • Villeneuve.did was role.director for a movie record
  • an ARRI camera record was role.primaryCamera for a movie record

And then the other side can publish its own stance:

@LexiconRecord
public struct RelationshipConfirmation: Equatable, Sendable {
    // Published by the repo owner for one side of the relationship,
    // usually the credited subject/contributor.
    public var relationship: StrongRef<Relationship>
    public var status: ConfirmationStatus
    public var createdAt: Date
}

My current bias is that role should be an open string with knownValues, not a closed enum. There are going to be hundreds of domain-specific roles over time, and clients should be able to understand common ones without rejecting new ones.

Open questions I’m still chewing on:

  • Is Relationship too broad a name, or is Attribution closer?
  • Should the direction be contributor → work, or work → contributor?
  • Should record(Entity) be a strong ref by default, or should that depend on the relationship type?
  • Is confirmed/rejected/revoked enough for the confirmation vocabulary?
1 Like

The re 3: section of this comment by @gui.do implies this same shape in context of sifa.id

I was toying around with “Claim” as the language here as well.

1 Like

Hey thx for the mention.

Yeah I build a similar shape for Sifa ID and it’s running in alpha. Built it early (Q1, with whatever was around at the time), so treat it as one shape that’s held up, not THE shape :sweat_smile:

Two lexicons cover Sifa’s version of a Relationship + Confirmation:

  • id.sifa.endorsement lives in the asserter’s PDS (author implicit = repo owner). Carries the credited DID, a ref to the exact record being credited, and a captured copy of the credited value at assertion time.
  • id.sifa.endorsement.confirmation lives in the credited party’s PDS, refs the endorsement. No confirmation = the edge exists but we don’t render it.

Same pattern one level up for teams: project.member (owner invites) + project.membership (invitee accepts). Both records present = confirmed. Either side deletes theirs = revoked.

Re: your open questions:

  • confirmed/rejected/revoked vocab: I didn’t model reject or revoke as statuses at all. State is just presence/absence of the two records: unconfirmed edges don’t display, revoke = delete your own record. Simpler, and each party only ever writes to their own repo. Cost: absence can’t tell “hasn’t seen it yet” from “actively refused”. If that distinction matters for you (disputes, moderation) you need an explicit negative record. If it doesn’t, I’d skip it.
  • strongRef vs at-uri: I pin with strongRef (AT-URI + CID) here on purpose, because an endorsement attests to a specific version of a claim. If the credited record mutates later, the pinned CID lets the AppView spot the drift. Sifa also stashes a denormalized copy of the credited field in the asserter’s repo, so the credited party can’t silently edit the record to change what was attested to. For a credit/attribution edge you probably want that anchor too. Where I DIDN’T pin: links between two first-party records meant to follow live updates, there a plain at-uri is right. So imho “does RecordRef need a strong ref” comes down to: pin when you’re attesting to a moment, don’t when you’re pointing at a living thing. Does that make sense?
  • direction: I put asserter as author, credited entity as subject, so it reads “author credits subject with role on object”. Matches your Post 5 framing. Worth defining in the lexicon itself so implementers don’t flip it.

@bmann.ca’s reusable-vs-app-specific point: happy to converge rather than have Sifa and this ship parallel attribution primitives. Happy to treat id.sifa.endorsement as one reference impl and work toward a shared community.lexicon.* shape if we want.

1 Like

(coming here from woody’s invite on Bluesky — we ship place reviews at skyphone.social and hit exactly the off-protocol-entity question above, so documenting our convention as a data point)

We write place reviews as records in users’ own repos (social.skyphone.spot.review, in production since June). The subject of a review is a real-world place — an entity that has no DID and no rkey, and never will. So we needed a stable key for the “off protocol” side.

How we key places:

"place": { "source": "osm", "id": "way/123456789", "name": "Some Cafe" }
  • Identity is the (source, id) pair: two records with the same pair are about the same place. source is an open string namespace (osm, google, skyphone for our own registry), so new registries can join without a schema change.
  • name and optional coordinates are self-description, so the record still means something if the registry is unreachable or the id rots.
  • We considered atproto-native place records (a place = a record in someone’s repo) and rejected it for now: every review would then depend on someone else’s repo existing forever, and place-identity disputes become a moderation problem living inside the schema. Namespaced external ids are boring and survivable.

Where this meets the ER lexicon: our place ref is, in the Relationship.Subject terms upthread, a third Entity case — externalId(source, id). If the community lexicon standardized just the shape of that case ({source: string, id: string}) while leaving the namespace values to convention, apps like ours could emit Relationship edges to off-protocol entities today, and AppViews could coalesce keys as usage centralizes. The drift woody mentioned is real and we’re already exposed to it: nothing stops another app from keying the same OSM way differently, and then two review sets about the same cafe never join. A documented convention is the only defense until usage picks a winner — which is half the reason this post exists.

Happy to share the full schema (privacy notes on visit timestamps and photo EXIF included) if it’s useful here. Very open to bikeshedding on the ref shape — converging early beats migrating five incompatible shapes later.

1 Like

Follow-up on the source+id place convention from my earlier post: it’s now formally published. social.skyphone.spot.review resolves through the standard lexicon mechanism (a DNS ownership record plus a schema record in the authority repo). The place reference shape — source + id, plus optional self-describing name and coordinates — is exactly what we run in production. Feedback welcome, especially from anyone building place-adjacent apps who’d want to read or write these records.