diff --git a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md index 88ad1dd..e6e7f7e 100644 --- a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md +++ b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md @@ -273,16 +273,17 @@ description (e.g. "Manage your Hypercerts data"). Notes: ### Certified — shared lexicons -| Lexicon | NSID | Purpose | -| -------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| **Location** | `app.certified.location` | Geographic reference via [Location Protocol](https://spec.decentralizedgeo.org) | -| **Profile** | `app.certified.actor.profile` | User profile: display name, bio, avatar, banner | -| **Organization** | `app.certified.actor.organization` | Organization metadata: legal structure, URLs, location, founding date, optional long description, discoverability visibility | -| **Badge Definition** | `app.certified.badge.definition` | Defines a badge with type, title, icon, 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 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` | +| Lexicon | NSID | Purpose | +| -------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Location** | `app.certified.location` | Geographic reference via [Location Protocol](https://spec.decentralizedgeo.org) | +| **Profile** | `app.certified.actor.profile` | User profile: display name, bio, avatar, banner | +| **Organization** | `app.certified.actor.organization` | Organization metadata: legal structure, URLs, location, founding date, optional long description, discoverability visibility | +| **Badge Definition** | `app.certified.badge.definition` | Defines a badge with type, title, icon, 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 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` | Social-graph follow for non-account entities (e.g. a record, by AT-URI without a CID). Account (DID) follows stay in `app.certified.graph.follow`; `subject` is an open union for future non-DID entity kinds | ### 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 (by AT-URI) (non-account 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 a Non-Account Entity + +```typescript +import { GRAPH_ENTITY_FOLLOW_NSID } from "@hypercerts-org/lexicon"; + +const entityFollow = { + $type: GRAPH_ENTITY_FOLLOW_NSID, + // subject is a union — use $type to specify the variant + subject: { + $type: "app.certified.defs#recordSubject", + uri: "at://did:plc:alice/org.hypercerts.claim.activity/3k2abc", + }, + createdAt: new Date().toISOString(), + // Optional `via` strongRef — same semantics as on `app.certified.graph.follow`. + // via: { + // uri: "at://did:plc:alice/app.certified.graph.starterpack/3k2abc", + // cid: "bafyreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", + // }, +}; +``` + +`app.certified.graph.entityFollow` is the sibling of +`app.certified.graph.follow` for following anything that is not an +account — accounts (DIDs) stay in `app.certified.graph.follow`. The +only subject variant today is `app.certified.defs#recordSubject`, a +record referenced by AT-URI (DID form, no CID) so the follow survives +later updates to the record; `subject` is an open union so future +non-DID entity kinds can be added non-breakingly. + ### 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..0b53161 --- /dev/null +++ b/.changeset/add-graph-entity-follow.md @@ -0,0 +1,5 @@ +--- +"@hypercerts-org/lexicon": minor +--- + +Add `app.certified.graph.entityFollow`, a follow record for non-account entities. `subject` is an open union currently offering only `app.certified.defs#recordSubject` (a record referenced by AT-URI without a CID, so the follow survives updates to the referenced record); account follows remain in `app.certified.graph.follow`. The new collection is also added to the `app.certified.authWrite` permission set so it is grantable alongside the other Certified records. diff --git a/ERD.puml b/ERD.puml index 01304e9..17e239b 100644 --- a/ERD.puml +++ b/ERD.puml @@ -202,6 +202,26 @@ dataclass follow { !endif } +' app.certified.graph.entityFollow +' subject is an open union (currently just #recordSubject); DIDs are +' deliberately out of scope here and belong to app.certified.graph.follow. +dataclass entityFollow { + !if (SHOW_FIELDS == "true") + subject + via? + createdAt + !endif +} + +' Followed subjects that are records are referenced generically by +' AT-URI (DID form, no CID) rather than modelled as a specific lexicon, +' so the follow survives updates to the referenced record. +entity "record (any lexicon, by AT-URI)" as entityFollowSubject #FFD4A3 { + !if (SHOW_FIELDS == "true") + AT-URI (DID form, no CID) + !endif +} + ' org.hypercerts.workscope.cel dataclass celExpression { !if (SHOW_FIELDS == "true") @@ -422,6 +442,10 @@ 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 non-account record (open +' union; account DIDs are out of scope here, see app.certified.graph.follow) +entityFollow::subject --> entityFollowSubject : follows ' This screws up the layout 'badgeAward::subject --[norank]-> collection diff --git a/README.md b/README.md index ac55cf4..ea5dc38 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 (by AT-URI) (non-account follow) signature/defs (shared #list and #inline defs) signature/proof (remote attestation proof record) ``` @@ -269,16 +270,17 @@ 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). | -| **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`. | +| 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`. | +| **Entity Follow** | `app.certified.graph.entityFollow` | Social-graph follow relationship for non-account entities (e.g. a record, referenced by AT-URI without a CID so the follow survives updates). Account (DID) follows remain in `app.certified.graph.follow`; `subject` is an open union so future non-DID entity kinds can be added non-breakingly. | ### Signatures (`app.certified.signature.*`) @@ -759,6 +761,36 @@ 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 a non-account entity + +`app.certified.graph.entityFollow` is the sibling of +`app.certified.graph.follow` for following anything that is **not** an +account. Account (DID) follows are explicitly out of scope here — use +`app.certified.graph.follow` for those. Today the only subject variant +is `app.certified.defs#recordSubject`, a record referenced by AT-URI +(DID form, no CID), so the reference survives later updates to the +record. `subject` is an open union so additional non-DID entity kinds +can be added in the future without a breaking change. + +```typescript +import { GRAPH_ENTITY_FOLLOW_NSID } from "@hypercerts-org/lexicon"; + +const entityFollow = { + $type: GRAPH_ENTITY_FOLLOW_NSID, + // subject is a union — use $type to specify the variant + subject: { + $type: "app.certified.defs#recordSubject", + uri: "at://did:plc:alice/org.hypercerts.claim.activity/3k2abc", + }, + createdAt: new Date().toISOString(), + // Optional `via` strongRef — same semantics as on `app.certified.graph.follow`. + // via: { + // uri: "at://did:plc:alice/app.certified.graph.starterpack/3k2abc", + // cid: "bafyreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", + // }, +}; +``` + ### 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..ddb8767 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 social 'follow' relationship with a non-account entity, such as a specific record. Account (DID) follows are out of scope and belong to app.certified.graph.follow. The subject is an open union so additional non-DID entity kinds can be added in the future. Duplicate follows will be ignored by the AppView. + +**Key:** `tid` + +#### Properties + +| Property | Type | Required | Description | +| ------------ | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `subject` | `union` | ✅ | The entity being followed. Must never include account DIDs; use app.certified.graph.follow for accounts. | +| `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.certified.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. @@ -620,6 +637,14 @@ A Decentralized Identifier (DID) string. | -------- | -------- | -------- | --------------------- | -------------- | | `did` | `string` | ✅ | The DID string value. | maxLength: 256 | +##### `app.certified.defs#recordSubject` + +A subject that is a record, referenced by AT-URI. The URI must use the DID form (not handle form) and deliberately carries no CID: it references the record's identity, not a specific version, so references made against it survive updates to the record. + +| Property | Type | Required | Description | +| -------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `uri` | `string` | ✅ | AT-URI (DID form) of the record being followed. Schema validation enforces only general at-uri syntax; writers MUST supply a full record URI (did authority, collection, and rkey — not a handle authority or a partial URI), and consumers (AppView/indexer) are expected to reject or skip subjects that do not conform. | + --- ### `org.hypercerts.defs` 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/defs.json b/lexicons/app/certified/defs.json index a808ff5..2edef84 100644 --- a/lexicons/app/certified/defs.json +++ b/lexicons/app/certified/defs.json @@ -15,6 +15,18 @@ "maxLength": 256 } } + }, + "recordSubject": { + "type": "object", + "description": "A subject that is a record, referenced by AT-URI. The URI must use the DID form (not handle form) and deliberately carries no CID: it references the record's identity, not a specific version, so references made against it survive updates to the record.", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "AT-URI (DID form) of the record being followed. Schema validation enforces only general at-uri syntax; writers MUST supply a full record URI (did authority, collection, and rkey — not a handle authority or a partial URI), and consumers (AppView/indexer) are expected to reject or skip subjects that do not conform." + } + } } } } diff --git a/lexicons/app/certified/graph/entityFollow.json b/lexicons/app/certified/graph/entityFollow.json new file mode 100644 index 0000000..5bfc6b5 --- /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 social 'follow' relationship with a non-account entity, such as a specific record. Account (DID) follows are out of scope and belong to app.certified.graph.follow. The subject is an open union so additional non-DID entity kinds can be added in the future. Duplicate follows will be ignored by the AppView.", + "key": "tid", + "record": { + "type": "object", + "required": ["subject", "createdAt"], + "properties": { + "subject": { + "type": "union", + "refs": ["app.certified.defs#recordSubject"], + "description": "The entity being followed. Must never include account DIDs; use app.certified.graph.follow for accounts." + }, + "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.certified.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..e9bbf0f --- /dev/null +++ b/tests/validate-graph-entity-follow.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from "vitest"; +import { validate, ids } from "../generated/lexicons"; +import * as EntityFollow from "../generated/types/app/certified/graph/entityFollow"; + +const VALID_URI = + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/org.hypercerts.claim.activity/3k2abc"; +const VALID_CID = "bafyreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy"; + +describe("app.certified.graph.entityFollow", () => { + it("should accept a valid entityFollow record (subject + createdAt only)", () => { + const result = EntityFollow.validateMain({ + $type: ids.AppCertifiedGraphEntityFollow, + subject: { + $type: "app.certified.defs#recordSubject", + uri: VALID_URI, + }, + createdAt: "2024-01-01T00:00:00Z", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value.subject.uri).toBe(VALID_URI); + } + }); + + it("should accept an entityFollow record with optional via strongRef and signatures", () => { + const result = EntityFollow.validateMain({ + $type: ids.AppCertifiedGraphEntityFollow, + subject: { + $type: "app.certified.defs#recordSubject", + uri: VALID_URI, + }, + createdAt: "2024-01-01T00:00:00Z", + via: { + uri: "at://did:plc:alice/app.certified.graph.starterpack/3k2abc", + cid: VALID_CID, + }, + signatures: [], + }); + 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.AppCertifiedGraphEntityFollow, + "main", + false, + ); + expect(result.success).toBe(false); + }); + + it("should reject a record missing required createdAt", () => { + const result = validate( + { + subject: { + $type: "app.certified.defs#recordSubject", + uri: VALID_URI, + }, + }, + ids.AppCertifiedGraphEntityFollow, + "main", + false, + ); + expect(result.success).toBe(false); + }); + + it("should reject a subject given as a bare string instead of a union object", () => { + const result = validate( + { + subject: VALID_URI, + createdAt: "2024-01-01T00:00:00Z", + }, + ids.AppCertifiedGraphEntityFollow, + "main", + false, + ); + expect(result.success).toBe(false); + }); + + it("should reject a recordSubject missing its required uri", () => { + const result = validate( + { + subject: { $type: "app.certified.defs#recordSubject" }, + createdAt: "2024-01-01T00:00:00Z", + }, + ids.AppCertifiedGraphEntityFollow, + "main", + false, + ); + expect(result.success).toBe(false); + }); + + it("should reject a recordSubject uri that is not an AT-URI", () => { + const result = validate( + { + subject: { + $type: "app.certified.defs#recordSubject", + uri: "https://example.com/record", + }, + createdAt: "2024-01-01T00:00:00Z", + }, + ids.AppCertifiedGraphEntityFollow, + "main", + false, + ); + expect(result.success).toBe(false); + }); + + it("documents actual open-union behavior: an unrecognized subject $type currently passes validation", () => { + // The `subject` union only declares `#recordSubject` today, but it is an + // *open* union (no `closed: true`), by design, so future non-DID entity + // kinds can be added non-breakingly. Per @atproto/lexicon's union + // validator, an object whose `$type` doesn't match any known ref is + // passed through as-is rather than rejected. This test documents that + // real, current behavior rather than asserting a stricter contract the + // schema does not actually enforce. + const result = validate( + { + subject: { + $type: "app.certified.graph.entityFollow#someFutureSubject", + anything: "goes", + }, + createdAt: "2024-01-01T00:00:00Z", + }, + ids.AppCertifiedGraphEntityFollow, + "main", + false, + ); + expect(result.success).toBe(true); + }); + + it("should reject a subject with no $type at all", () => { + const result = validate( + { + subject: { uri: VALID_URI }, + 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: { + $type: "app.certified.defs#recordSubject", + uri: VALID_URI, + }, + createdAt: "not-a-datetime", + }, + ids.AppCertifiedGraphEntityFollow, + "main", + false, + ); + expect(result.success).toBe(false); + }); + + it("should require $type when requiredType is true", () => { + const result = validate( + { + subject: { + $type: "app.certified.defs#recordSubject", + uri: VALID_URI, + }, + createdAt: "2024-01-01T00:00:00Z", + }, + ids.AppCertifiedGraphEntityFollow, + "main", + true, + ); + expect(result.success).toBe(false); + }); +});