diff --git a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md
index aafe387..7f5228f 100644
--- a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md
+++ b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md
@@ -247,6 +247,7 @@ if (result.success) {
| **Badge Award** | `app.certified.badge.award` | Awards a badge to a user, project, or activity |
| **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 |
+| **Follow** | `app.certified.graph.follow` | Social-graph follow: declares the author follows another account by DID. Schema-compatible with `app.bsky.graph.follow` |
## Relationship Map
@@ -283,6 +284,7 @@ CERTIFIED
actor/profile (user profile)
actor/organization (org metadata)
badge/response ──> badge/award ──> badge/definition
+ graph/follow ───────────> account DID (social follow)
```
Every arrow is a `strongRef` or union reference stored on AT Protocol.
@@ -387,6 +389,30 @@ const location = {
};
```
+### Following another account
+
+```typescript
+import { GRAPH_FOLLOW_NSID } from "@hypercerts-org/lexicon";
+
+const follow = {
+ $type: GRAPH_FOLLOW_NSID,
+ // DID of the account being followed (any valid DID — did:plc, did:web, etc.)
+ subject: "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
+ 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",
+ // },
+};
+```
+
+`app.certified.graph.follow` mirrors `app.bsky.graph.follow` (same
+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.
+
### Linking an EVM Wallet
```typescript
diff --git a/.changeset/add-graph-follow-lexicon.md b/.changeset/add-graph-follow-lexicon.md
new file mode 100644
index 0000000..5f86d74
--- /dev/null
+++ b/.changeset/add-graph-follow-lexicon.md
@@ -0,0 +1,5 @@
+---
+"@hypercerts-org/lexicon": minor
+---
+
+Add `app.certified.graph.follow` lexicon — a social-graph follow record schema-compatible with `app.bsky.graph.follow` (same `tid` key, same `subject` / `createdAt` / optional `via` strongRef fields). Exports new `GRAPH_FOLLOW_NSID`, `GRAPH_FOLLOW_LEXICON_JSON`, `GRAPH_FOLLOW_LEXICON_DOC`, and `AppCertifiedGraphFollow` type namespace.
diff --git a/ERD.png b/ERD.png
index ad44743..219dd33 100644
Binary files a/ERD.png and b/ERD.png differ
diff --git a/ERD.puml b/ERD.puml
index b8c0533..130db6c 100644
--- a/ERD.puml
+++ b/ERD.puml
@@ -193,6 +193,15 @@ dataclass badgeResponse {
!endif
}
+' app.certified.graph.follow
+dataclass follow {
+ !if (SHOW_FIELDS == "true")
+ subject
+ via?
+ createdAt
+ !endif
+}
+
' org.hypercerts.workscope.cel
dataclass celExpression {
!if (SHOW_FIELDS == "true")
@@ -390,6 +399,9 @@ badgeAward::badge --> badgeDefinition
badgeResponse::badgeAward --> badgeAward
badgeAward::subject --> contributorInformation
badgeAward::subject --> activity
+
+' app.certified.graph.follow points at an account DID (the entity being followed)
+follow::subject --> contributorEntity : follows
' This screws up the layout
'badgeAward::subject --[norank]-> collection
diff --git a/ERD.svg b/ERD.svg
index faecaff..ed38c6e 100644
--- a/ERD.svg
+++ b/ERD.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 0f114eb..831dd34 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,7 @@ CERTIFIED ─ shared lexicons (certified.app)
actor/profile (user profile)
actor/organization (org metadata)
badge/response ──► badge/award ──► badge/definition
+ graph/follow ────────────► account DID (social follow)
```
Every arrow (`►`) is a `strongRef` or union reference stored on the
@@ -266,15 +267,16 @@ await agent.api.com.atproto.repo.createRecord({
### Certified (`app.certified.*`)
-| Lexicon | NSID | Description |
-| -------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
-| **Location** | `app.certified.location` | Geographic reference using the [Location Protocol](https://spec.decentralizedgeo.org) (coordinates, GeoJSON, H3, WKT, etc.). |
-| **Profile** | `app.certified.actor.profile` | User account profile with display name, bio, avatar, and banner. |
-| **Organization** | `app.certified.actor.organization` | Organization metadata: legal structure, URLs, location, founding date, optional long description, and discoverability visibility. |
-| **Badge Definition** | `app.certified.badge.definition` | Defines a badge type with title, icon, and optional issuer allowlist. |
-| **Badge Award** | `app.certified.badge.award` | Awards a badge to a user, project, or activity. |
-| **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). |
+| Lexicon | NSID | Description |
+| -------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Location** | `app.certified.location` | Geographic reference using the [Location Protocol](https://spec.decentralizedgeo.org) (coordinates, GeoJSON, H3, WKT, etc.). |
+| **Profile** | `app.certified.actor.profile` | User account profile with display name, bio, avatar, and banner. |
+| **Organization** | `app.certified.actor.organization` | Organization metadata: legal structure, URLs, location, founding date, optional long description, and discoverability visibility. |
+| **Badge Definition** | `app.certified.badge.definition` | Defines a badge type with title, icon, and optional issuer allowlist. |
+| **Badge Award** | `app.certified.badge.award` | Awards a badge to a user, project, or activity. |
+| **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`. |
> **Full property tables** → [SCHEMAS.md](SCHEMAS.md)
@@ -576,6 +578,35 @@ npm run gen-schemas-md # Regenerate SCHEMAS.md
npm run test # Run tests
```
+### Following another account
+
+The `app.certified.graph.follow` record is the social-graph primitive
+for `certified.app`. Its shape is identical to `app.bsky.graph.follow`
+(same `key: tid`, same `subject` / `createdAt` / optional `via`
+fields), so feed-builders and view services can index it with the
+same logic they already use for Bluesky follows.
+
+```typescript
+import { GRAPH_FOLLOW_NSID } from "@hypercerts-org/lexicon";
+
+const follow = {
+ $type: GRAPH_FOLLOW_NSID,
+ // DID of the account being followed (any valid DID — did:plc, did:web, etc.)
+ subject: "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
+ 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",
+ // },
+};
+```
+
+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`.
+
### Linking ATProto Identity to EVM Wallets
The `app.certified.link.evm` record enables verifiable linking between
diff --git a/SCHEMAS.md b/SCHEMAS.md
index 9de403d..6e4f150 100644
--- a/SCHEMAS.md
+++ b/SCHEMAS.md
@@ -467,6 +467,22 @@ A labeled URL reference.
---
+### `app.certified.graph.follow`
+
+**Description:** Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView.
+
+**Key:** `tid`
+
+#### Properties
+
+| Property | Type | Required | Description |
+| ----------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `subject` | `string` | ✅ | DID of the account 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. |
+
+---
+
### `app.certified.link.evm`
**Description:** A verifiable link between an ATProto DID and an EVM wallet address, proven via a cryptographic signature. Currently supports EOA wallets via EIP-712 typed data signatures; the proof field is an open union to allow future signature methods.
diff --git a/lexicons/app/certified/graph/follow.json b/lexicons/app/certified/graph/follow.json
new file mode 100644
index 0000000..7bfc41f
--- /dev/null
+++ b/lexicons/app/certified/graph/follow.json
@@ -0,0 +1,32 @@
+{
+ "lexicon": 1,
+ "id": "app.certified.graph.follow",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["subject", "createdAt"],
+ "properties": {
+ "subject": {
+ "type": "string",
+ "format": "did",
+ "description": "DID of the account being followed."
+ },
+ "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."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tests/validate-graph-follow.test.ts b/tests/validate-graph-follow.test.ts
new file mode 100644
index 0000000..d75b9c9
--- /dev/null
+++ b/tests/validate-graph-follow.test.ts
@@ -0,0 +1,111 @@
+import { describe, it, expect } from "vitest";
+import { validate, ids } from "../generated/lexicons";
+import * as Follow from "../generated/types/app/certified/graph/follow";
+
+const VALID_DID = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
+const VALID_CID = "bafyreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy";
+
+describe("app.certified.graph.follow", () => {
+ it("should accept a valid follow record (subject + createdAt only)", () => {
+ const result = Follow.validateMain({
+ $type: ids.AppCertifiedGraphFollow,
+ subject: VALID_DID,
+ createdAt: "2024-01-01T00:00:00Z",
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.value.subject).toBe(VALID_DID);
+ }
+ });
+
+ it("should accept a follow record with optional via strongRef", () => {
+ const result = Follow.validateMain({
+ $type: ids.AppCertifiedGraphFollow,
+ subject: VALID_DID,
+ 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",
+ );
+ }
+ });
+
+ it("should reject a record missing required subject", () => {
+ const result = validate(
+ { createdAt: "2024-01-01T00:00:00Z" },
+ ids.AppCertifiedGraphFollow,
+ "main",
+ false,
+ );
+ expect(result.success).toBe(false);
+ });
+
+ it("should reject a record missing required createdAt", () => {
+ const result = validate(
+ { subject: VALID_DID },
+ ids.AppCertifiedGraphFollow,
+ "main",
+ false,
+ );
+ expect(result.success).toBe(false);
+ });
+
+ it("should reject a subject that is not a valid DID", () => {
+ const result = validate(
+ {
+ subject: "not-a-did",
+ createdAt: "2024-01-01T00:00:00Z",
+ },
+ ids.AppCertifiedGraphFollow,
+ "main",
+ false,
+ );
+ expect(result.success).toBe(false);
+ });
+
+ it("should reject an invalid datetime", () => {
+ const result = validate(
+ {
+ subject: VALID_DID,
+ createdAt: "not-a-datetime",
+ },
+ ids.AppCertifiedGraphFollow,
+ "main",
+ false,
+ );
+ expect(result.success).toBe(false);
+ });
+
+ it("should reject a via that is not a valid strongRef", () => {
+ const result = validate(
+ {
+ subject: VALID_DID,
+ createdAt: "2024-01-01T00:00:00Z",
+ via: { uri: "at://did:plc:alice/app.certified.graph.starterpack/x" }, // missing cid
+ },
+ ids.AppCertifiedGraphFollow,
+ "main",
+ false,
+ );
+ expect(result.success).toBe(false);
+ });
+
+ it("should require $type when requiredType is true", () => {
+ const result = validate(
+ {
+ subject: VALID_DID,
+ createdAt: "2024-01-01T00:00:00Z",
+ },
+ ids.AppCertifiedGraphFollow,
+ "main",
+ true,
+ );
+ expect(result.success).toBe(false);
+ });
+});