From bd9932ae7c25579d4374b77a11b6714cb9326c57 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 22 Jul 2026 16:31:02 +0100 Subject: [PATCH 1/2] feat(lexicons): add app.certified.graph.entityFollow Add a follow record that targets a record rather than an account. The new lexicon 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 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. Register the NSID in the app.certified.authWrite permission set. Note this widens the effective grant of an already-consented set; see docs/design/permission-sets.md for the set-growth semantics. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SKILL.md | 31 ++++ .changeset/add-graph-entity-follow.md | 13 ++ ERD.puml | 15 ++ README.md | 35 +++++ SCHEMAS.md | 19 ++- lexicons/app/certified/authWrite.json | 1 + .../app/certified/graph/entityFollow.json | 37 +++++ tests/validate-graph-entity-follow.test.ts | 139 ++++++++++++++++++ 8 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 .changeset/add-graph-entity-follow.md create mode 100644 lexicons/app/certified/graph/entityFollow.json create mode 100644 tests/validate-graph-entity-follow.test.ts diff --git a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md index 88ad1dd..461a80b 100644 --- a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md +++ b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md @@ -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 @@ -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) @@ -463,6 +465,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 diff --git a/.changeset/add-graph-entity-follow.md b/.changeset/add-graph-entity-follow.md new file mode 100644 index 0000000..71b5282 --- /dev/null +++ b/.changeset/add-graph-entity-follow.md @@ -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. diff --git a/ERD.puml b/ERD.puml index 01304e9..59839e1 100644 --- a/ERD.puml +++ b/ERD.puml @@ -202,6 +202,15 @@ dataclass follow { !endif } +' app.certified.graph.entityFollow +dataclass entityFollow { + !if (SHOW_FIELDS == "true") + subject + via? + createdAt + !endif +} + ' org.hypercerts.workscope.cel dataclass celExpression { !if (SHOW_FIELDS == "true") @@ -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 diff --git a/README.md b/README.md index ac55cf4..ea4706e 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ 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) signature/defs (shared #list and #inline defs) signature/proof (remote attestation proof record) ``` @@ -279,6 +280,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.*`) @@ -759,6 +761,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 diff --git a/SCHEMAS.md b/SCHEMAS.md index 3249f53..8417849 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -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. diff --git a/lexicons/app/certified/authWrite.json b/lexicons/app/certified/authWrite.json index 7db7150..9bda9a7 100644 --- a/lexicons/app/certified/authWrite.json +++ b/lexicons/app/certified/authWrite.json @@ -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", diff --git a/lexicons/app/certified/graph/entityFollow.json b/lexicons/app/certified/graph/entityFollow.json new file mode 100644 index 0000000..c0eb6fb --- /dev/null +++ b/lexicons/app/certified/graph/entityFollow.json @@ -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." + }, + "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." + } + } + } + } + } +} diff --git a/tests/validate-graph-entity-follow.test.ts b/tests/validate-graph-entity-follow.test.ts new file mode 100644 index 0000000..3b2f6e0 --- /dev/null +++ b/tests/validate-graph-entity-follow.test.ts @@ -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); + }); +}); From 3835a1a8e97c3d98d7917334ea00f1c3c0e0f4ff Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 22 Jul 2026 16:53:51 +0100 Subject: [PATCH 2/2] docs: correct relationship-map legend for scalar references The lexicon-map legend claimed every arrow is a `strongRef` or union reference, but two arrows are scalar identifiers: `graph/follow` points at an account via a `did` string, and the new `graph/entityFollow` points at a record via an `at-uri` string. Adding the entityFollow arrow turned a latent inaccuracy into a pattern, so correct the claim in both README.md and SKILL.md rather than describing only the new arrow. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/skills/building-with-hypercerts-lexicons/SKILL.md | 4 +++- README.md | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md index 461a80b..0254931 100644 --- a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md +++ b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md @@ -339,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 diff --git a/README.md b/README.md index ea4706e..909b176 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,10 @@ CERTIFIED ─ shared lexicons (certified.app) 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