Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion .agents/skills/building-with-hypercerts-lexicons/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ description (e.g. "Manage your Hypercerts data"). Notes:
| **Badge Response** | `app.certified.badge.response` | Recipient accepts or rejects a badge award |
| **EVM Link** | `app.certified.link.evm` | Verifiable ATProto DID to EVM wallet link via EIP-712 signature. Can additionally carry `signatures[]` for record provenance |
| **Follow** | `app.certified.graph.follow` | Social-graph follow: declares the author follows another account by DID. Schema-compatible with `app.bsky.graph.follow` |
| **Entity Follow** | `app.certified.graph.entityFollow` | Entity follow: declares the author follows a record by AT-URI rather than an account by DID |

### Signatures — cryptographic attestation

Expand Down Expand Up @@ -329,6 +330,7 @@ CERTIFIED
actor/organization (org metadata)
badge/response ──> badge/award ──> badge/definition
graph/follow ───────────> account DID (social follow)
graph/entityFollow ─────> record AT-URI (entity follow)
signature/defs (shared #list and #inline defs)
signature/proof (remote attestation proof record)

Expand All @@ -337,7 +339,9 @@ CERTIFIED
arrow would fan out identically from every record).
```

Every arrow is a `strongRef` or union reference stored on AT Protocol.
Every arrow is a reference stored on AT Protocol — usually a
`strongRef` or union reference, but sometimes a scalar identifier such
as the `did` on `graph/follow` or the `at-uri` on `graph/entityFollow`.

## Common Patterns

Expand Down Expand Up @@ -463,6 +467,35 @@ key strategy and fields including the optional `via` strongRef), so
feed-builders and view services can index it with the same logic
they already use for Bluesky follows.

### Following an Entity

Use `app.certified.graph.entityFollow` to follow a **record** instead
of an **account**. It is identical to `app.certified.graph.follow`
except that `subject` is an `at-uri` referencing the followed record
rather than a `did` identifying an account.

```typescript
import { GRAPH_ENTITY_FOLLOW_NSID } from "@hypercerts-org/lexicon";

const entityFollow = {
$type: GRAPH_ENTITY_FOLLOW_NSID,
subject:
"at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/org.hypercerts.activity/3k2abc",
createdAt: new Date().toISOString(),
// Optional `via` strongRef — set when the follow was mediated by another
// record (e.g. a starter-pack-style curated list). Omit for direct follows.
// via: {
// uri: "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.certified.graph.starterpack/3k2abc",
// cid: "bafyreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy",
// },
};
```

`subject` is an unconstrained `at-uri`, so an entity follow may point
at a record in any collection — including one governed by a lexicon
outside this repository. Resolve the referenced record before assuming
a particular shape.

### Linking an EVM Wallet

```typescript
Expand Down
13 changes: 13 additions & 0 deletions .changeset/add-graph-entity-follow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@hypercerts-org/lexicon": minor
---

Add `app.certified.graph.entityFollow` lexicon — a follow record that targets a **record** rather than an **account**.

It is structurally identical to `app.certified.graph.follow` (same `key: tid`, same `createdAt`, optional `via` strongRef, and optional `signatures` fields). The only difference is `subject`, which is a `string` with `format: at-uri` referencing the record being followed, instead of a `string` with `format: did` identifying an account. This lets clients express "follow this activity/claim/collection" alongside the existing "follow this account" relationship, without overloading the meaning of `app.certified.graph.follow`.

Because `subject` is an unconstrained `at-uri`, an entity follow may reference a record in any collection — including one governed by a lexicon outside this repository. Consumers should resolve the referenced record before assuming a particular shape, and treat the collection portion of the URI as untrusted input.

The new NSID is added to the `app.certified.authWrite` permission set, so apps already requesting `include:app.certified.authWrite` gain create/update/delete on the new collection when the updated set is published. Note that this widens the effective grant of an already-consented set; see `docs/design/permission-sets.md` for the set-growth semantics.

Exports new `GRAPH_ENTITY_FOLLOW_NSID`, `GRAPH_ENTITY_FOLLOW_LEXICON_JSON`, `GRAPH_ENTITY_FOLLOW_LEXICON_DOC`, and the `AppCertifiedGraphEntityFollow` type namespace.
15 changes: 15 additions & 0 deletions ERD.puml
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,15 @@ dataclass follow {
!endif
}

' app.certified.graph.entityFollow
dataclass entityFollow {
!if (SHOW_FIELDS == "true")
subject
via?
createdAt
!endif
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
' org.hypercerts.workscope.cel
dataclass celExpression {
!if (SHOW_FIELDS == "true")
Expand Down Expand Up @@ -422,6 +431,12 @@ badgeAward::subject --> activity

' app.certified.graph.follow points at an account DID (the entity being followed)
follow::subject --> contributorEntity : follows

' app.certified.graph.entityFollow points at a record AT-URI rather than an
' account DID. The subject is an unconstrained at-uri so it may reference any
' record; activity is drawn as the representative target to keep the diagram
' readable rather than fanning an arrow out to every record type.
entityFollow::subject --> activity : follows
' This screws up the layout
'badgeAward::subject --[norank]-> collection

Expand Down
41 changes: 39 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,15 @@ CERTIFIED ─ shared lexicons (certified.app)
actor/organization (org metadata)
badge/response ──► badge/award ──► badge/definition
graph/follow ────────────► account DID (social follow)
graph/entityFollow ──────► record AT-URI (entity follow)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
signature/defs (shared #list and #inline defs)
signature/proof (remote attestation proof record)
```

Every arrow (`►`) is a `strongRef` or union reference stored on the
AT Protocol network. Full field-level documentation is in
Every arrow (`►`) is a reference stored on the AT Protocol network —
usually a `strongRef` or union reference, but sometimes a scalar
identifier such as the `did` on `graph/follow` or the `at-uri` on
`graph/entityFollow`. Full field-level documentation is in
[SCHEMAS.md](SCHEMAS.md).

## Consuming These Lexicons
Expand Down Expand Up @@ -279,6 +282,7 @@ await agent.api.com.atproto.repo.createRecord({
| **Badge Response** | `app.certified.badge.response` | Recipient accepts or rejects a badge award. |
| **EVM Link** | `app.certified.link.evm` | Verifiable ATProto DID ↔ EVM wallet link via EIP-712 signature. Extensible for future proof methods (e.g. ERC-1271, ERC-6492). |
| **Follow** | `app.certified.graph.follow` | Social-graph follow relationship — declares that the author follows another account by DID. Schema-compatible with `app.bsky.graph.follow`. |
| **Entity Follow** | `app.certified.graph.entityFollow` | Follow relationship targeting a record rather than an account — declares that the author follows an entity by AT-URI. |

### Signatures (`app.certified.signature.*`)

Expand Down Expand Up @@ -759,6 +763,39 @@ The optional `via` field is a `com.atproto.repo.strongRef` to any
record that mediated the follow (e.g. a starter pack or other curated
list), mirroring the equivalent field on `app.bsky.graph.follow`.

### Following an entity

The `app.certified.graph.entityFollow` record is the counterpart to
`app.certified.graph.follow` for following a **record** rather than an
**account**. The two are structurally identical (same `key: tid`, same
`createdAt` / optional `via` / optional `signatures` fields); the only
difference is `subject`, which is an `at-uri` pointing at the record
being followed instead of a `did` identifying an account.

```typescript
import { GRAPH_ENTITY_FOLLOW_NSID } from "@hypercerts-org/lexicon";

const entityFollow = {
$type: GRAPH_ENTITY_FOLLOW_NSID,
// AT-URI of the record being followed (any collection, not just Certified).
subject:
"at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/org.hypercerts.activity/3k2abc",
createdAt: new Date().toISOString(),
// Optional `via` strongRef — set when the follow was mediated by another
// record (e.g. a starter-pack-style curated list). Omit for direct follows.
// via: {
// uri: "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.certified.graph.starterpack/3k2abc",
// cid: "bafyreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy",
// },
};
```

Because `subject` is an unconstrained `at-uri`, an entity follow may
target a record in any collection — an activity, a claim, a collection,
or a record governed by a lexicon outside this repository. Consumers
should therefore treat the referenced collection as untrusted input and
resolve it before assuming a particular record shape.

### Linking ATProto Identity to EVM Wallets

The `app.certified.link.evm` record enables verifiable linking between
Expand Down
19 changes: 18 additions & 1 deletion SCHEMAS.md
Original file line number Diff line number Diff line change
Expand Up @@ -506,12 +506,29 @@ A labeled URL reference.

**Resource:** `repo`

**Collections:** `app.certified.actor.organization`, `app.certified.actor.profile`, `app.certified.badge.award`, `app.certified.badge.definition`, `app.certified.badge.response`, `app.certified.graph.follow`, `app.certified.link.evm`, `app.certified.location`, `app.certified.signature.proof`
**Collections:** `app.certified.actor.organization`, `app.certified.actor.profile`, `app.certified.badge.award`, `app.certified.badge.definition`, `app.certified.badge.response`, `app.certified.graph.entityFollow`, `app.certified.graph.follow`, `app.certified.link.evm`, `app.certified.location`, `app.certified.signature.proof`

**Actions:** `create`, `update`, `delete`

---

### `app.certified.graph.entityFollow`

**Description:** Record declaring a 'follow' relationship of an entity record rather than an account. Duplicate follows will be ignored by the AppView.

**Key:** `tid`

#### Properties

| Property | Type | Required | Description |
| ------------ | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `subject` | `string` | ✅ | AT-URI of the entity record being followed. |
| `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created. |
| `via` | `ref` | ❌ | Optional strong reference to a record that mediated this follow (e.g. a starter pack or other curated list). Mirrors the optional `via` field on app.bsky.graph.follow; the referenced record may conform with any lexicon. |
| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. |

---

### `app.certified.graph.follow`

**Description:** Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView.
Expand Down
1 change: 1 addition & 0 deletions lexicons/app/certified/authWrite.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"app.certified.badge.award",
"app.certified.badge.definition",
"app.certified.badge.response",
"app.certified.graph.entityFollow",
"app.certified.graph.follow",
"app.certified.link.evm",
"app.certified.location",
Expand Down
37 changes: 37 additions & 0 deletions lexicons/app/certified/graph/entityFollow.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"lexicon": 1,
"id": "app.certified.graph.entityFollow",
"defs": {
"main": {
"type": "record",
"description": "Record declaring a 'follow' relationship of an entity record rather than an account. Duplicate follows will be ignored by the AppView.",
"key": "tid",
"record": {
"type": "object",
"required": ["subject", "createdAt"],
"properties": {
"subject": {
"type": "string",
"format": "at-uri",
"description": "AT-URI of the entity record being followed."
},
Comment on lines +13 to +17

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other way of doing this is an open union, as proposed by @Ashex in #236. That would allow follows to be not just weak refs but also strongRefs and anything else people want. That may or may not be practically useful - I'm not sure at this point, but I guess the point is that with an open union we don't have to know or care right now because it's always expandable later. The only minor downside is that it then needs to be wrapped in an object.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ashex I'm personally fine with either but think I marginally prefer #236. So this could be closed in favour of that if that's the general consensus.

"createdAt": {
"type": "string",
"format": "datetime",
"description": "Client-declared timestamp when this record was originally created."
},
"via": {
"type": "ref",
"ref": "com.atproto.repo.strongRef",
"description": "Optional strong reference to a record that mediated this follow (e.g. a starter pack or other curated list). Mirrors the optional `via` field on app.bsky.graph.follow; the referenced record may conform with any lexicon."
},
"signatures": {
"type": "ref",
"ref": "app.certified.signature.defs#list",
"description": "Optional cryptographic signatures attesting to this record's content."
}
}
}
}
}
}
139 changes: 139 additions & 0 deletions tests/validate-graph-entity-follow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, it, expect } from "vitest";
import { validate, ids } from "../generated/lexicons";
import * as EntityFollow from "../generated/types/app/certified/graph/entityFollow";

const VALID_AT_URI =
"at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/org.hypercerts.activity/3k2abc";
const VALID_DID = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
const VALID_CID = "bafyreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy";

describe("app.certified.graph.entityFollow", () => {
it("should accept a valid entity follow record (subject + createdAt only)", () => {
const result = EntityFollow.validateMain({
$type: ids.AppCertifiedGraphEntityFollow,
subject: VALID_AT_URI,
createdAt: "2024-01-01T00:00:00Z",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.value.subject).toBe(VALID_AT_URI);
}
});

it("should accept an entity follow record with optional via strongRef", () => {
const result = EntityFollow.validateMain({
$type: ids.AppCertifiedGraphEntityFollow,
subject: VALID_AT_URI,
createdAt: "2024-01-01T00:00:00Z",
via: {
uri: "at://did:plc:alice/app.certified.graph.starterpack/3k2abc",
cid: VALID_CID,
},
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.value.via?.uri).toBe(
"at://did:plc:alice/app.certified.graph.starterpack/3k2abc",
);
}
});

// subject is an unconstrained at-uri, so a follow may target a record in
// any collection -- including lexicons defined outside this repository.
it("should accept a subject in a collection outside this repository", () => {
const result = EntityFollow.validateMain({
$type: ids.AppCertifiedGraphEntityFollow,
subject: "at://did:plc:alice/app.bsky.feed.post/3k2abc",
createdAt: "2024-01-01T00:00:00Z",
});
expect(result.success).toBe(true);
});

it("should reject a record missing required subject", () => {
const result = validate(
{ createdAt: "2024-01-01T00:00:00Z" },
ids.AppCertifiedGraphEntityFollow,
"main",
false,
);
expect(result.success).toBe(false);
});

it("should reject a record missing required createdAt", () => {
const result = validate(
{ subject: VALID_AT_URI },
ids.AppCertifiedGraphEntityFollow,
"main",
false,
);
expect(result.success).toBe(false);
});

it("should reject a subject that is not a valid AT-URI", () => {
const result = validate(
{
subject: "not-an-at-uri",
createdAt: "2024-01-01T00:00:00Z",
},
ids.AppCertifiedGraphEntityFollow,
"main",
false,
);
expect(result.success).toBe(false);
});

// The distinguishing constraint vs app.certified.graph.follow: a bare DID is
// a valid subject there, but not here.
it("should reject a bare DID as subject", () => {
const result = validate(
{
subject: VALID_DID,
createdAt: "2024-01-01T00:00:00Z",
},
ids.AppCertifiedGraphEntityFollow,
"main",
false,
);
expect(result.success).toBe(false);
});

it("should reject an invalid datetime", () => {
const result = validate(
{
subject: VALID_AT_URI,
createdAt: "not-a-datetime",
},
ids.AppCertifiedGraphEntityFollow,
"main",
false,
);
expect(result.success).toBe(false);
});

it("should reject a via that is not a valid strongRef", () => {
const result = validate(
{
subject: VALID_AT_URI,
createdAt: "2024-01-01T00:00:00Z",
via: { uri: "at://did:plc:alice/app.certified.graph.starterpack/x" }, // missing cid
},
ids.AppCertifiedGraphEntityFollow,
"main",
false,
);
expect(result.success).toBe(false);
});

it("should require $type when requiredType is true", () => {
const result = validate(
{
subject: VALID_AT_URI,
createdAt: "2024-01-01T00:00:00Z",
},
ids.AppCertifiedGraphEntityFollow,
"main",
true,
);
expect(result.success).toBe(false);
});
});