diff --git a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md index 3da701da..d1474df5 100644 --- a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md +++ b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md @@ -352,6 +352,7 @@ CERTIFIED actor/profile (user profile) actor/organization (org metadata) badge/response ──> badge/award ──> badge/definition + badge/award ────────────> custom typed data (inline or strongRef) graph/follow ───────────> account DID (social follow) graph/entityFollow ─────> record (by AT-URI) (non-account follow) feed/like ──────────────> record (by strongRef) (social like) @@ -368,6 +369,46 @@ Every arrow is a `strongRef` or union reference stored on AT Protocol. ## Common Patterns +### Badge extensions + +`app.certified.badge.award.extensions` is an optional array (maximum 20) +containing inline typed objects, `com.atproto.repo.strongRef` objects, or both. +Define custom data in your own lexicon, not as new shared badge fields. Inline +payloads avoid separate record writes and reads; references pin separately +stored records by URI and CID. Strong references in this union must include +`$type: "com.atproto.repo.strongRef"` as well as `uri` and `cid`. + +`app.certified.badge.definition.extensionTypes` declares the extension types +that belong to a badge: an optional array (maximum 20) of `{ type, required }` +entries. `type` is a payload type identifier (at most 512 bytes): a full +Lexicon reference such as `org.example.goodmarket.defs#approvalMetadata` for +an inline object, or a record NSID such as +`org.example.goodmarket.approvalMetadata` for a referenced record. `required` +defaults to false. `badgeType` still describes the badge's broad category, not +its extension type. + +Consumers apply two rules, using the definition version referenced by the +award. First, an award that lacks a `required: true` extension is not a valid +award of the badge; a payload that fails its own schema counts as missing. +Second, an extension whose type is not listed is outside the badge's contract: +the award stays valid, and the extension may be ignored but must not be +presented as data defined by the badge. Omitting `extensionTypes` or leaving +it empty declares no extensions. Match inline payloads by exact `$type` and +references by the collection NSID in the AT-URI, not the strongRef wrapper's +type. A required entry names one form (inline or referenced), so list only the +form you issue. + +The shared Lexicon validator checks the carrier structure, not this +cross-record contract or arbitrary third-party inline fields. Register and +validate your own extension schemas separately even when they are included in +the same validator registry. + +Good Market Approved can use a custom payload with just `sectors: string[]` +and `focus: string[]`. The placeholder identifiers above are illustrative, not +existing Good Market lexicons. See the repository's +[badge extension guide](https://github.com/hypercerts-org/hypercerts-lexicon/blob/main/docs/design/badge-extensions.md) +for complete inline and referenced examples. + ### Creating an Activity (Hypercert) ```typescript diff --git a/.changeset/badge-extension-contract.md b/.changeset/badge-extension-contract.md new file mode 100644 index 00000000..ff5b3d35 --- /dev/null +++ b/.changeset/badge-extension-contract.md @@ -0,0 +1,5 @@ +--- +"@hypercerts-org/lexicon": minor +--- + +Add optional `extensions` to badge awards as an open union of inline typed objects and strong references, and optional `extensionTypes` to badge definitions as a list of `{ type, required }` entries declaring which extension types belong to the badge. An award lacking a required extension is invalid; extensions of undeclared types are outside the badge's contract and do not invalidate the award. Integrators can attach their own schema-defined data without modifying the shared badge lexicons. Enforcing the contract and validating third-party payloads remains an application-level responsibility. diff --git a/ERD-with-fields.png b/ERD-with-fields.png index 85c6a1a4..c95bfbf3 100644 Binary files a/ERD-with-fields.png and b/ERD-with-fields.png differ diff --git a/ERD-with-fields.svg b/ERD-with-fields.svg index 4d8a6d06..7cc36288 100644 --- a/ERD-with-fields.svg +++ b/ERD-with-fields.svg @@ -1 +1 @@ -Hypercert ERDHypercert ERDorganizationorganizationType[]?urls[]?location?foundedDate?createdAtcontributor (DID/profile)DID or social profile URIactivitytitleshortDescriptiondescription?image?workScope?startDate?endDate?contributors[]?locations[]?rights?createdAtattachmentsubjects[]?contentType?content[]titleshortDescription?description?location?createdAtmeasurementsubjects[]?measurers[]?metricunitvaluestartDate?endDate?methodType?methodURI?evidenceURI[]?locations[]?comment?createdAtevaluationsubject?summaryevaluators[]content[]?measurements[]?scorelocation?createdAtcontributorInformationidentifier?displayName?image?createdAtcontributionrole?contributionDescription?startDate?endDate?createdAtlocationlpVersionsrslocationTypelocationname?description?createdAtbadgeDefinitionbadgeTypetitleicondescription?allowedIssuers[]?createdAtbadgeAwardbadgesubjectnote?createdAtbadgeResponsebadgeAwardresponseweight?createdAtcelExpressionexpressionusedTags[]versioncreatedAtworkScopeTagkeynamecategory?description?parent?status?supersededBy?aliases[]?sameAs[]?referenceDocument?createdAtrightsrightsNamerightsTyperightsDescriptionattachment?createdAtcollectiontype?titleshortDescription?items[]?location?createdAtdescription? (Leaflet ref)acknowledgementsubjectcontext?acknowledgedcomment?createdAtfunderDID or name/pseudonymfundingReceipttofromfor?amountcurrencypaymentRail?paymentNetwork?transactionId?notes?occurredAt?createdAttoken(on-chain only)  recursivenestingstrongRefhierarchydeprecationidentifiestokenizesfundshasfunds \ No newline at end of file +Hypercert ERDorganizationorganizationType[]?urls[]?location?foundedDate?longDescription?visibility?createdAtcontributor (DID/profile)DID or social profile URIactivitytitleshortDescriptiondescription?image?workScope?startDate?endDate?contributors[]?locations[]?rights?createdAtattachmentsubjects[]?contentType?content[]titleshortDescription?description?location?tags[]?createdAtmeasurementsubjects[]?measurers[]?metricunitvaluestartDate?endDate?methodType?methodURI?evidenceURI[]?locations[]?comment?createdAtevaluationsubject?summaryevaluators[]content[]?measurements[]?scorelocation?createdAtcontributorInformationidentifier?displayName?image?createdAtcontributionrole?contributionDescription?startDate?endDate?createdAtlocationlpVersionsrslocationTypelocationname?description?createdAtbadgeDefinitionbadgeTypetitleicon?description?allowedIssuers[]?extensionTypes[]?createdAtbadgeAwardbadgesubjectnote?url?extensions[]?createdAtbadgeResponsebadgeAwardresponseweight?createdAtExtensions are inline typed objects orstrong references to publisher-defined records.Payload type must match extensionTypeswhen declared by the referenced definition.followsubjectvia?createdAtentityFollowsubjectvia?createdAtlikesubjectvia?createdAtrepostsubjectvia?createdAtstrongRefuricidrecord (any lexicon, by AT-URI)AT-URI (DID form, no CID)celExpressionexpressionusedTags[]versioncreatedAtworkScopeTagkeynamecategory?description?parent?status?supersededBy?aliases[]?sameAs[]?referenceDocument?createdAtfeaturetype?titledescription?locations[]?tags[]?sameAs[]?createdAttagkeynamecategorydescription?broader[]?statussupersededBy?aliases[]?sameAs[]?referenceDocument?createdAtrightsrightsNamerightsTyperightsDescriptionattachment?createdAtlinkEvmaddressproofcreatedAtcollectiontype?titleshortDescription?description?items[]?location?tags[]?createdAtacknowledgementsubjectcontext?acknowledgedcomment?createdAtfunderDID or name/pseudonymfundingReceipttofrom?for?amountcurrencypaymentRail?paymentNetwork?transactionId?notes?occurredAt?createdAttoken(on-chain only)  recursivenestingstrongRefhierarchydeprecationidentifiestokenizesfundshasfundsfollowsfollowslikesrepostsviavia \ No newline at end of file diff --git a/ERD.png b/ERD.png index 219dd331..31cb8e0f 100644 Binary files a/ERD.png and b/ERD.png differ diff --git a/ERD.puml b/ERD.puml index c564a1a2..21fbfaf6 100644 --- a/ERD.puml +++ b/ERD.puml @@ -172,6 +172,7 @@ dataclass badgeDefinition { icon? description? allowedIssuers[]? + extensionTypes[]? createdAt !endif } @@ -181,6 +182,8 @@ dataclass badgeAward { badge subject note? + url? + extensions[]? createdAt !endif } @@ -194,6 +197,14 @@ dataclass badgeResponse { !endif } +note right of badgeAward + Extensions are inline typed objects or + strong references to publisher-defined records. + The referenced definition's extensionTypes + declares which types belong to the badge + and which are required. +end note + ' app.certified.graph.follow dataclass follow { !if (SHOW_FIELDS == "true") diff --git a/ERD.svg b/ERD.svg index ed38c6e5..fe49bedc 100644 --- a/ERD.svg +++ b/ERD.svg @@ -1 +1 @@ -Hypercert ERDHypercert ERDorganizationcontributor (DID/profile)activityattachmentmeasurementevaluationcontributorInformationcontributionlocationbadgeDefinitionbadgeAwardbadgeResponsefollowcelExpressionworkScopeTagrightscollectionacknowledgementfunderfundingReceipttoken recursivenestingstrongRefhierarchydeprecationidentifiestokenizesfundshasfundsfollows \ No newline at end of file +Hypercert ERDorganizationcontributor (DID/profile)activityattachmentmeasurementevaluationcontributorInformationcontributionlocationbadgeDefinitionbadgeAwardbadgeResponseExtensions are inline typed objects orstrong references to publisher-defined records.Payload type must match extensionTypeswhen declared by the referenced definition.followentityFollowlikerepoststrongRefrecord (any lexicon, by AT-URI)celExpressionworkScopeTagfeaturetagrightslinkEvmcollectionacknowledgementfunderfundingReceipttoken recursivenestingstrongRefhierarchydeprecationidentifiestokenizesfundshasfundsfollowsfollowslikesrepostsviavia \ No newline at end of file diff --git a/README.md b/README.md index 2ac9987f..1fa1dcc4 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ CERTIFIED ─ shared lexicons (certified.app) actor/profile (user profile) actor/organization (org metadata) badge/response ──► badge/award ──► badge/definition + badge/award ────────────► custom typed data (inline or strongRef) graph/follow ────────────► account DID (social follow) graph/entityFollow ──────► record (by AT-URI) (non-account follow) feed/like ───────────────► record (by strongRef) (social like) @@ -374,6 +375,43 @@ snapshot semantic, and it's what the lexicon uses across `badge.award.badge`, `badge.response.badgeAward`, `funding.receipt.for`, and similar fields where the historical content needs to stay stable. +### Badge extensions declare a contract on the definition + +Badge awards can carry optional `extensions`: an open-union array of inline +typed objects and `com.atproto.repo.strongRef` objects. Integrators define +their own extension lexicons; the shared badge schema does not need to +enumerate each project's types. Inline data avoids a separate record write +and read, while a strong reference allows a separately stored, version-pinned +payload. Both forms can appear in the same array (at most 20 entries). + +A badge definition is the type of its awards, so it also declares the +extension data that belongs to the badge. The optional `extensionTypes` array +holds up to 20 entries of the form `{ type, required }`. `type` is a fully +qualified payload type identifier (at most 512 bytes), such as +`org.example.goodmarket.defs#approvalMetadata` for an inline object or +`org.example.goodmarket.approvalMetadata` for a referenced record. `badgeType` +remains the broad category (for example, `certification`), not the extension +schema identifier. + +Two rules apply, using the definition version referenced by the award: + +1. An award that lacks an extension marked `required: true` is not a valid + award of the badge. A payload that fails its own schema counts as missing. +2. An extension whose type is not listed is outside the badge's contract. The + award stays valid; consumers may ignore the extension and should not present + it as data defined by the badge. + +Omitting `extensionTypes` or leaving it empty declares no extensions. For a +strong reference, the payload type is the collection NSID in its AT-URI, not +the wrapper's `com.atproto.repo.strongRef` type. + +**Lexicon validation alone does not enforce this cross-record contract or +validate third-party inline payloads admitted by the open union.** Consumers +must perform those checks separately. + +See [Badge extensions](docs/design/badge-extensions.md) for the Good Market +Approved example, publishing requirements, and consumer behavior. + ## Entity Relationship Diagram ![Hypercert ERD](ERD.svg) diff --git a/SCHEMAS.md b/SCHEMAS.md index 2c42fca9..64cd0538 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -444,15 +444,27 @@ A location represented as a string, e.g. coordinates or a small GeoJSON string. #### Properties -| Property | Type | Required | Description | Comments | -| ---------------- | -------- | -------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `badgeType` | `string` | ✅ | Category of the badge. Values beyond the known set are permitted. | maxLength: 100, Known values: `endorsement`, `verification`, `participation`, `certification`, `affiliation`, `recognition` | -| `title` | `string` | ✅ | Human-readable title of the badge. | maxLength: 256 | -| `icon` | `blob` | ❌ | Icon representing the badge, stored as a blob for compact visual display. | maxSize: 1048576, accepts: `image/png`, `image/jpeg`, `image/webp`, `image/svg+xml` | -| `description` | `string` | ❌ | Optional short statement describing what the badge represents. | maxLength: 5000, maxGraphemes: 500 | -| `allowedIssuers` | `ref[]` | ❌ | Optional allowlist of DIDs allowed to issue this badge. If omitted, anyone may issue it. | maxLength: 100 | -| `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created | | -| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | +| Property | Type | Required | Description | Comments | +| ---------------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `badgeType` | `string` | ✅ | Category of the badge. Values beyond the known set are permitted. | maxLength: 100, Known values: `endorsement`, `verification`, `participation`, `certification`, `affiliation`, `recognition` | +| `title` | `string` | ✅ | Human-readable title of the badge. | maxLength: 256 | +| `icon` | `blob` | ❌ | Icon representing the badge, stored as a blob for compact visual display. | maxSize: 1048576, accepts: `image/png`, `image/jpeg`, `image/webp`, `image/svg+xml` | +| `description` | `string` | ❌ | Optional short statement describing what the badge represents. | maxLength: 5000, maxGraphemes: 500 | +| `allowedIssuers` | `ref[]` | ❌ | Optional allowlist of DIDs allowed to issue this badge. If omitted, anyone may issue it. | maxLength: 100 | +| `extensionTypes` | `ref[]` | ❌ | Extension types that belong to this badge. Each entry names one payload type that awards of this badge carry in their extensions, and whether it is required. An award that lacks a required extension is not a valid award of this badge. Extensions whose type is not listed are outside this badge's contract: they do not invalidate the award, and consumers may ignore them. Omitting this field or leaving it empty declares no extensions. Consumers apply the definition version referenced by the award. This cross-record rule is not enforced by Lexicon validation alone. | maxLength: 20 | +| `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | + +#### Defs + +##### `app.certified.badge.definition#extensionType` + +Declares one extension payload type that belongs to a badge. + +| Property | Type | Required | Description | Comments | +| ---------- | --------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| `type` | `string` | ✅ | Fully qualified Lexicon type identifier of the payload: a full Lexicon reference for an inline object (e.g. org.example.goodmarket.defs#approvalMetadata), or a record NSID for a strongly referenced record (e.g. org.example.goodmarket.approvalMetadata). Matched exactly against the inline object's $type, or against the collection NSID in a strong reference's AT-URI. No wildcards or prefix matching; a main definition is written as the bare NSID, without #main. | maxLength: 512 | +| `required` | `boolean` | ❌ | If true, every award of this badge must include an extension of this type, and an award without one is invalid. A payload that does not conform to its own schema counts as missing. If omitted or false, the extension is optional. | | --- @@ -464,14 +476,15 @@ A location represented as a string, e.g. coordinates or a small GeoJSON string. #### Properties -| Property | Type | Required | Description | Comments | -| ------------ | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | -| `badge` | `ref` | ✅ | Strong reference to the badge definition at the time of award. The record referenced must conform with the lexicon app.certified.badge.definition. | | -| `subject` | `union` | ✅ | Entity the badge award is for (either an account DID or any specific AT Protocol record), e.g. a user, a project, or a specific activity claim. | | -| `note` | `string` | ❌ | Optional statement explaining the reason for this badge award. | maxLength: 500 | -| `url` | `string` | ❌ | Optional URL the badge award links to. | maxLength: 2048 | -| `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created | | -| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | +| Property | Type | Required | Description | Comments | +| ------------ | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------- | +| `badge` | `ref` | ✅ | Strong reference to the badge definition at the time of award. The record referenced must conform with the lexicon app.certified.badge.definition. | | +| `subject` | `union` | ✅ | Entity the badge award is for (either an account DID or any specific AT Protocol record), e.g. a user, a project, or a specific activity claim. | | +| `note` | `string` | ❌ | Optional statement explaining the reason for this badge award. | maxLength: 500 | +| `url` | `string` | ❌ | Optional URL the badge award links to. | maxLength: 2048 | +| `extensions` | `union[]` | ❌ | Publisher-defined supplementary data for this award. Each entry is an inline typed object with a fully qualified $type, or a strong reference to a separately stored extension record. The open union permits third-party Lexicons without adding them to the shared badge schema. The referenced badge definition's extensionTypes declares which types belong to the badge and which are required; an award lacking a required extension is invalid, and extensions of undeclared types are outside the badge's contract. Consumers must validate payloads against their own schemas. Unknown or undeclared extensions must not prevent displaying an otherwise valid award. A strong reference pins the extension to a particular record version; updates require a new reference to use the new version. | maxLength: 20 | +| `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created | | +| `signatures` | `ref` | ❌ | Optional cryptographic signatures attesting to this record's content. | | --- diff --git a/docs/design/badge-extensions.md b/docs/design/badge-extensions.md new file mode 100644 index 00000000..c551783e --- /dev/null +++ b/docs/design/badge-extensions.md @@ -0,0 +1,221 @@ +# Badge extensions + +Badge definitions declare what a badge means. Badge awards record individual +issuances. Publisher-defined extensions let integrating projects attach typed +data without adding project-specific fields to the shared Certified lexicons. + +## Good Market Approved: a predictable custom payload + +Consider Good Market Approved, with custom data containing exactly two fields: + +```ts +type ApprovalMetadata = { + sectors: string[]; + focus: string[]; +}; +``` + +The examples below are illustrative. `org.example.goodmarket.*` is a +placeholder namespace, not a published Good Market lexicon. DIDs, record +URIs, CIDs, and classification values are example values, not live records +or an agreed Good Market vocabulary. + +Good Market defines an inline object in its own lexicon: + +```json +{ + "lexicon": 1, + "id": "org.example.goodmarket.defs", + "defs": { + "approvalMetadata": { + "type": "object", + "description": "Good Market's classifications for an approved enterprise.", + "required": ["sectors", "focus"], + "properties": { + "sectors": { + "type": "array", + "description": "Good Market sector classifications.", + "maxLength": 100, + "items": { "type": "string", "maxLength": 256 } + }, + "focus": { + "type": "array", + "description": "Good Market focus classifications.", + "maxLength": 100, + "items": { "type": "string", "maxLength": 256 } + } + } + } + } +} +``` + +The bounds above are illustrative producer choices, not imposed by Certified. +The normal AT Protocol `$type` discriminator identifies the object; it is not +a third business field in the extension schema. + +### Define the badge and its extension types once + +```json +{ + "$type": "app.certified.badge.definition", + "badgeType": "certification", + "title": "Good Market Approved", + "allowedIssuers": [{ "did": "did:plc:ewvi7nxzyoun6zhxrhs64oiz" }], + "extensionTypes": [ + { + "type": "org.example.goodmarket.defs#approvalMetadata", + "required": true + } + ], + "createdAt": "2026-10-02T12:00:00Z" +} +``` + +Every Good Market Approved award must carry the sector and focus metadata, +so the entry is marked `required`. An award without it is not a valid Good +Market Approved award. + +### Issue an award with inline data + +```json +{ + "$type": "app.certified.badge.award", + "badge": { + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.certified.badge.definition/3k2abc", + "cid": "bafyreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy" + }, + "subject": { + "$type": "app.certified.defs#did", + "did": "did:plc:klldzjf4rzhskytomj64nvil" + }, + "extensions": [ + { + "$type": "org.example.goodmarket.defs#approvalMetadata", + "sectors": ["Agriculture", "Food"], + "focus": ["Regenerative Agriculture", "Fair Trade"] + } + ], + "createdAt": "2026-10-02T12:05:00Z" +} +``` + +Small custom payloads can travel with the award. They need no separate +extension-record write, permissions, or fetch. + +### Or reference a separately stored payload + +A definition that uses separately stored records lists the record NSID +instead: `{ "type": "org.example.goodmarket.approvalMetadata", "required": true }`. +This variant requires its own `type: "record"` lexicon with the two array +properties on `defs.main.record`. A required entry names one form, so a +definition should list only the form its issuers use. + +Replace the award's `extensions` value with: + +```json +[ + { + "$type": "com.atproto.repo.strongRef", + "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/org.example.goodmarket.approvalMetadata/3k2def", + "cid": "bafyreie5737gdxlw5i64vngml6xvqeatqy3a4erphoqtso54z2eooh4zae" + } +] +``` + +The referenced record contains: + +```json +{ + "$type": "org.example.goodmarket.approvalMetadata", + "sectors": ["Agriculture", "Food"], + "focus": ["Regenerative Agriculture", "Fair Trade"] +} +``` + +The URI and CID identify a particular record version. Updating that record +does not retarget the award automatically; update the reference to use the new +version. A strong reference is not a guarantee that historical content remains +retrievable from the PDS. Do not silently substitute a newer version. + +Both approaches need a project-owned schema. For network discovery, publish +the schema in an AT Protocol repository and use DNS TXT records to identify +the namespace authority's DID. The schema is not stored in DNS, and DNS setup +is per authority, not per award. Network publication is currently strongly +recommended rather than universally required by AT Protocol. Separately stored +records also require write authorization for the project's collection; the +Certified permission set does not grant access to third-party collections. + +## Why put `extensionTypes` on the definition? + +A badge definition is the type of its awards. Every Good Market Approved +award shares the definition's title, icon, description, and allowed issuers. +Extension data belongs to that type in the same way: if the badge carries +sector and focus classifications, that is a property of the badge, not of one +issuance. + +Open unions such as post embeds are open by design, because the author of +each post decides what to attach. A badge is different: the definition author +decides what the badge is, and issuers only instantiate it. Without a +declaration, two awards of the same badge could carry unrelated payloads, and +a consumer could only learn the badge's data shape by sampling awards. + +With `extensionTypes`, issuing tools know what data to collect and consumers +know what to expect and render, before any award exists. This does not put +Good Market's fields into the common protocol or require other projects to +use Good Market's vocabulary. + +## Contract and validation + +Two rules define the contract. Apply them with the definition version +identified by `award.badge`, not an unverified latest definition. + +1. **An award that lacks a required extension is invalid.** It is not a valid + award of the badge and should not be presented as one. +2. **An extension whose type is not listed is outside the contract.** The + award stays valid. Consumers may ignore the extension or render it, but + should not present it as data defined by the badge. + +| Definition declares | Award carries | Award | Extension | +| ------------------- | ----------------- | ------- | ------------------------- | +| Nothing | Some extension | Valid | Outside the contract | +| X, optional | Nothing | Valid | n/a | +| X, optional | X | Valid | Part of the badge | +| X, optional | Y | Valid | Y is outside the contract | +| X, required | X | Valid | Part of the badge | +| X, required | X and Y | Valid | Y is outside the contract | +| X, required | Nothing or only Y | Invalid | n/a | + +Details: + +- Both fields are optional, so existing records remain valid. Omitting + `extensionTypes` or leaving it empty declares no extensions. +- `extensions` contains at most 20 entries. Each is a typed inline object or + a strongRef object; references in this union require their `$type` wrapper. +- `extensionTypes` contains at most 20 `{ type, required }` entries. `type` + is at most 512 bytes and identifies a record NSID or a full inline Lexicon + reference including its named definition fragment. `required` defaults to + false. Matching is exact, with no prefixes or wildcards, and a main + definition is written as the bare NSID without `#main`. +- An inline payload matches by its `$type`. A strong reference matches by the + collection NSID in its AT-URI, not the wrapper's type. +- A required payload that is present but does not conform to its own schema + counts as missing. A consumer without that schema can only check presence + by type and should treat the content as unchecked. +- A required referenced record that cannot be retrieved is unverified, not + invalid: the issuer supplied what the definition asked for. Verify the CID + when the record is available, and do not silently substitute a newer + version. +- Check issuer authorization separately. A matching type is not proof of an + authorized issuer, trusted extension publisher, or accurate data. + Cross-repo references do not inherit trust merely by matching a schema. +- The generic badge validator checks the carrier structure only. It does not + validate third-party fields, enforce required extensions, or resolve strong + references. Applications and indexers enforce the contract. +- Consumers should retain unknown fields when forwarding or storing the + original record. + +This follows the existing inline-or-reference pattern used for descriptions, +contributor details, and signatures. A same-repository, same-key sidecar remains +an alternative for independently mutable companion data, but this field is an +explicit, discoverable inclusion in an award's payload. diff --git a/lexicons/app/certified/badge/award.json b/lexicons/app/certified/badge/award.json index ed6c08dd..dc36132f 100644 --- a/lexicons/app/certified/badge/award.json +++ b/lexicons/app/certified/badge/award.json @@ -31,6 +31,16 @@ "description": "Optional URL the badge award links to.", "maxLength": 2048 }, + "extensions": { + "type": "array", + "description": "Publisher-defined supplementary data for this award. Each entry is an inline typed object with a fully qualified $type, or a strong reference to a separately stored extension record. The open union permits third-party Lexicons without adding them to the shared badge schema. The referenced badge definition's extensionTypes declares which types belong to the badge and which are required; an award lacking a required extension is invalid, and extensions of undeclared types are outside the badge's contract. Consumers must validate payloads against their own schemas. Unknown or undeclared extensions must not prevent displaying an otherwise valid award. A strong reference pins the extension to a particular record version; updates require a new reference to use the new version.", + "maxLength": 20, + "items": { + "type": "union", + "description": "An inline third-party extension object, or a strong reference to an extension record of any Lexicon. For a strong reference, the payload type is the referenced record's type, which is the collection NSID in its AT-URI.", + "refs": ["com.atproto.repo.strongRef"] + } + }, "createdAt": { "type": "string", "format": "datetime", diff --git a/lexicons/app/certified/badge/definition.json b/lexicons/app/certified/badge/definition.json index 86592429..08285cbe 100644 --- a/lexicons/app/certified/badge/definition.json +++ b/lexicons/app/certified/badge/definition.json @@ -54,6 +54,15 @@ "ref": "app.certified.defs#did" } }, + "extensionTypes": { + "type": "array", + "description": "Extension types that belong to this badge. Each entry names one payload type that awards of this badge carry in their extensions, and whether it is required. An award that lacks a required extension is not a valid award of this badge. Extensions whose type is not listed are outside this badge's contract: they do not invalidate the award, and consumers may ignore them. Omitting this field or leaving it empty declares no extensions. Consumers apply the definition version referenced by the award. This cross-record rule is not enforced by Lexicon validation alone.", + "maxLength": 20, + "items": { + "type": "ref", + "ref": "#extensionType" + } + }, "createdAt": { "type": "string", "format": "datetime", @@ -66,6 +75,22 @@ } } } + }, + "extensionType": { + "type": "object", + "description": "Declares one extension payload type that belongs to a badge.", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "description": "Fully qualified Lexicon type identifier of the payload: a full Lexicon reference for an inline object (e.g. org.example.goodmarket.defs#approvalMetadata), or a record NSID for a strongly referenced record (e.g. org.example.goodmarket.approvalMetadata). Matched exactly against the inline object's $type, or against the collection NSID in a strong reference's AT-URI. No wildcards or prefix matching; a main definition is written as the bare NSID, without #main.", + "maxLength": 512 + }, + "required": { + "type": "boolean", + "description": "If true, every award of this badge must include an extension of this type, and an award without one is invalid. A payload that does not conform to its own schema counts as missing. If omitted or false, the extension is optional." + } + } } } } diff --git a/tests/validate-badge-extensions.test.ts b/tests/validate-badge-extensions.test.ts new file mode 100644 index 00000000..2e1f3aaa --- /dev/null +++ b/tests/validate-badge-extensions.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect } from "vitest"; +import { Lexicons, type LexiconDoc } from "@atproto/lexicon"; +import { ids, schemas, validate } from "../generated/lexicons.js"; +import * as Definition from "../generated/types/app/certified/badge/definition.js"; +import * as Award from "../generated/types/app/certified/badge/award.js"; + +const INLINE_TYPE = "org.example.goodmarket.defs#approvalMetadata"; +const RECORD_TYPE = "org.example.goodmarket.approvalMetadata"; +const VALID_CID = "bafyreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy"; +const definition = { + $type: ids.AppCertifiedBadgeDefinition, + badgeType: "certification", + title: "Good Market Approved", + allowedIssuers: [{ did: "did:plc:ewvi7nxzyoun6zhxrhs64oiz" }], + createdAt: "2026-10-02T12:00:00Z", +}; +const award = { + $type: ids.AppCertifiedBadgeAward, + badge: { + uri: "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.certified.badge.definition/3k2abc", + cid: VALID_CID, + }, + subject: { + $type: "app.certified.defs#did" as const, + did: "did:plc:klldzjf4rzhskytomj64nvil", + }, + createdAt: "2026-10-02T12:05:00Z", +}; +const inlineExtension = { + $type: INLINE_TYPE, + sectors: ["Agriculture", "Food"], + focus: ["Regenerative Agriculture", "Fair Trade"], +}; +const referenceExtension = { + $type: "com.atproto.repo.strongRef" as const, + uri: `at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/${RECORD_TYPE}/3k2def`, + cid: VALID_CID, +}; + +describe("app.certified.badge.definition extensionTypes", () => { + it("accepts omitted or empty extensionTypes", () => { + expect(Definition.validateMain(definition).success).toBe(true); + expect( + Definition.validateMain({ ...definition, extensionTypes: [] }).success, + ).toBe(true); + }); + + it("accepts required and optional entries for inline identifiers and record NSIDs", () => { + const extensionTypes = [ + { type: INLINE_TYPE, required: true }, + { type: RECORD_TYPE, required: false }, + { type: "org.example.other.metadata" }, + ]; + const result = Definition.validateMain({ ...definition, extensionTypes }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value.extensionTypes).toEqual(extensionTypes); + expect(result.value.badgeType).toBe("certification"); + } + }); + + it("enforces the 20-entry array limit", () => { + for (const [count, success] of [ + [20, true], + [21, false], + ] as const) { + expect( + validate( + { + ...definition, + extensionTypes: Array(count).fill({ type: INLINE_TYPE }), + }, + ids.AppCertifiedBadgeDefinition, + "main", + false, + ).success, + ).toBe(success); + } + }); + + it("bounds identifier strings at 512 bytes independently of type resolution", () => { + for (const [length, success] of [ + [512, true], + [513, false], + ] as const) { + expect( + validate( + { ...definition, extensionTypes: [{ type: "a".repeat(length) }] }, + ids.AppCertifiedBadgeDefinition, + "main", + false, + ).success, + ).toBe(success); + } + }); + + it.each( + [ + INLINE_TYPE, + [INLINE_TYPE], + [{ required: true }], + [{ type: 42 }], + [{ type: INLINE_TYPE, required: "yes" }], + ].map((extensionTypes) => ({ extensionTypes })), + )("rejects a malformed extensionTypes value: %j", ({ extensionTypes }) => { + const result = validate( + { ...definition, extensionTypes }, + ids.AppCertifiedBadgeDefinition, + "main", + false, + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("extensionTypes"); + } + }); +}); + +describe("app.certified.badge.award extensions", () => { + it("accepts omitted or empty extensions", () => { + expect(Award.validateMain(award).success).toBe(true); + expect(Award.validateMain({ ...award, extensions: [] }).success).toBe(true); + }); + + it.each([ + ["inline", [inlineExtension]], + ["referenced", [referenceExtension]], + ["mixed", [inlineExtension, referenceExtension]], + ])( + "accepts %s extensions and preserves the original data", + (_, extensions) => { + const result = Award.validateMain({ ...award, extensions }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value.extensions).toEqual(extensions); + } + }, + ); + + it("admits a future third-party type without adding it to the shared schema", () => { + const extensions = [ + { $type: "org.example.future.customData", customField: "preserved" }, + ]; + const result = Award.validateMain({ ...award, extensions }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value.extensions).toEqual(extensions); + } + }); + + it("enforces the 20-extension array limit", () => { + for (const [count, success] of [ + [20, true], + [21, false], + ] as const) { + expect( + validate( + { ...award, extensions: Array(count).fill(inlineExtension) }, + ids.AppCertifiedBadgeAward, + "main", + false, + ).success, + ).toBe(success); + } + }); + + it.each( + [ + [{ sectors: [], focus: [] }], + [{ $type: "com.atproto.repo.strongRef", uri: referenceExtension.uri }], + ].map((extensions) => ({ extensions })), + )("rejects malformed extensions: %j", ({ extensions }) => { + const result = validate( + { ...award, extensions }, + ids.AppCertifiedBadgeAward, + "main", + false, + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("extensions"); + } + }); +}); + +describe("Good Market extension validation boundaries", () => { + it("requires separate payload validation, even with a registered schema", () => { + // Test-only third-party schema, not a new Certified lexicon. + const inlineSchema: LexiconDoc = { + lexicon: 1, + id: "org.example.goodmarket.defs", + defs: { + approvalMetadata: { + type: "object", + required: ["sectors", "focus"], + properties: { + sectors: { type: "array", items: { type: "string" } }, + focus: { type: "array", items: { type: "string" } }, + }, + }, + }, + }; + const registry = new Lexicons([...schemas, inlineSchema]); + const invalidPayload = { ...inlineExtension, sectors: [42] }; + expect(registry.validate(INLINE_TYPE, inlineExtension).success).toBe(true); + expect( + registry.validate(ids.AppCertifiedBadgeAward, { + ...award, + extensions: [invalidPayload], + }).success, + ).toBe(true); + expect(registry.validate(INLINE_TYPE, invalidPayload).success).toBe(false); + }); + + it("does not mistake carrier validation for enforcement of the definition's contract", () => { + const declared = { + ...definition, + extensionTypes: [{ type: INLINE_TYPE, required: true }], + }; + const otherPayload = { $type: "org.example.other.metadata" }; + expect(Definition.validateMain(declared).success).toBe(true); + // Neither award carries the required extension, yet both pass carrier + // validation. An application must perform the cross-record check. + for (const extensions of [undefined, [otherPayload]]) { + expect(Award.validateMain({ ...award, extensions }).success).toBe(true); + const present = new Set((extensions ?? []).map((e) => e.$type)); + const missing = declared.extensionTypes.filter( + (entry) => entry.required && !present.has(entry.type), + ); + expect(missing).toHaveLength(1); + } + }); +});