Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
19 changes: 19 additions & 0 deletions .agents/skills/building-with-hypercerts-lexicons/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -387,6 +389,23 @@ const location = {
};
```

### Following another account

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

const follow = {
$type: GRAPH_FOLLOW_NSID,
subject: "did:plc:bob", // DID of the account being followed
Comment thread
s-adamantine marked this conversation as resolved.
Outdated
createdAt: new Date().toISOString(),
};
```

`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
Expand Down
5 changes: 5 additions & 0 deletions .changeset/add-graph-follow-lexicon.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
s-adamantine marked this conversation as resolved.
Binary file modified ERD.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions ERD.puml
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion ERD.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 33 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -576,6 +578,28 @@ 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,
subject: "did:plc:bob", // DID of the account being followed
createdAt: new Date().toISOString(),
};
```

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
Expand Down
16 changes: 16 additions & 0 deletions SCHEMAS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions lexicons/app/certified/graph/follow.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
}
}
}
111 changes: 111 additions & 0 deletions tests/validate-graph-follow.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});