diff --git a/CLAUDE.md b/CLAUDE.md index 88cc34c..d33f27d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,5 +89,6 @@ When the user's question matches a skill, invoke it via the `Skill` tool rather - `product-sdk-contracts` — contract calls (queries, txs). - `product-sdk-cloud-storage` — cloud-storage chain client. - `product-sdk-statement-store` — statement store. +- `product-sdk-individuality` — personhood / membership state reads. - `product-sdk-utilities` — address, crypto, logger, local-storage, utils. - `migrating-to-product-sdk` — porting from legacy stacks. diff --git a/README.md b/README.md index c833a53..587d087 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ TypeScript SDK for building products in the Polkadot ecosystem. Provides typed A | `@parity/product-sdk-contracts` | Typed contract interactions on Polkadot Asset Hub | | `@parity/product-sdk-cloud-storage` | Upload and retrieve data via Cloud Storage (currently backed by the Polkadot Bulletin Chain) | | `@parity/product-sdk-statement-store` | Publish/subscribe client for the Polkadot Statement Store | +| `@parity/product-sdk-individuality` | Read a person's personhood state on the individuality chain, as of one pinned finalized block | | `@parity/product-sdk-keys` | Hierarchical key derivation, session keys, and sr25519 product-account derivation | | `@parity/product-sdk-local-storage` | Key-value local storage with automatic host/browser backend detection | | `@parity/product-sdk-host` | Host container detection and storage access for Desktop/Mobile | @@ -65,6 +66,7 @@ Or open a new Claude Code session and ask "build me a Polkadot app" — the `pro | `product-sdk-contracts` | Smart contract calls on Asset Hub (PolkaVM/Solidity) | | `product-sdk-cloud-storage` | CID-based upload/retrieve via Cloud Storage | | `product-sdk-statement-store` | Publish/subscribe on the Polkadot Statement Store | +| `product-sdk-individuality` | Personhood / membership state reads for a DotNS username | | `product-sdk-utilities` | Addresses, crypto, encoding, token formatting, logging | | `migrating-to-product-sdk` | Porting an existing codebase from legacy stacks | diff --git a/product-sdk/README.md b/product-sdk/README.md index 6ce3e00..9760834 100644 --- a/product-sdk/README.md +++ b/product-sdk/README.md @@ -13,6 +13,7 @@ TypeScript SDK for building products in the Polkadot ecosystem. Provides typed A | `@parity/product-sdk-contracts` | Typed contract interactions on Polkadot Asset Hub | | `@parity/product-sdk-cloud-storage` | Upload and retrieve data via Cloud Storage (currently backed by the Polkadot Bulletin Chain) | | `@parity/product-sdk-statement-store` | Publish/subscribe client for the Polkadot Statement Store | +| `@parity/product-sdk-individuality` | Read a person's personhood state on the individuality chain, as of one pinned finalized block | | `@parity/product-sdk-keys` | Hierarchical key derivation, session keys, and sr25519 product-account derivation | | `@parity/product-sdk-local-storage` | Key-value local storage with automatic host/browser backend detection | | `@parity/product-sdk-host` | Host container detection and storage access for Desktop/Mobile | diff --git a/product-sdk/packages/individuality/package.json b/product-sdk/packages/individuality/package.json new file mode 100644 index 0000000..a6ce66f --- /dev/null +++ b/product-sdk/packages/individuality/package.json @@ -0,0 +1,36 @@ +{ + "name": "@parity/product-sdk-individuality", + "description": "Read the personhood state of a DotNS username from the individuality chain, as of one pinned finalized block", + "version": "0.0.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": ["dist"], + "sideEffects": false, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup", + "clean": "rm -rf dist", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.typecheck.json" + }, + "dependencies": { + "@parity/product-sdk-errors": "workspace:*", + "@parity/result": "workspace:*", + "polkadot-api": "catalog:" + }, + "devDependencies": { + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "license": "Apache-2.0" +} diff --git a/product-sdk/packages/individuality/src/decode.ts b/product-sdk/packages/individuality/src/decode.ts new file mode 100644 index 0000000..384ff42 --- /dev/null +++ b/product-sdk/packages/individuality/src/decode.ts @@ -0,0 +1,286 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * Raw PAPI storage values → domain shapes. + * + * Chain data is untrusted here, even though a descriptor typed it. Two traps + * live in this file and neither is visible to the compiler: + * + * 1. **`Score.AbsenceGraceRatio` byte order is `(allowed_misses, window)`.** The + * metadata tuple is anonymous, so the order comes from the pallet's doc + * comment, not from the type. Getting it backwards produces plausible + * numbers and a wrong answer. + * 2. **`Score.PersonhoodThreshold` is a `u8`.** PAPI maps both `u8` and `u32` to + * `number`, so a width mistake typechecks *and* passes tests. It is read + * straight through rather than decoded here, so the note lives on + * `PersonhoodInputs.personhoodThreshold` in `types.ts` and at the read + * site — but it belongs to the same class of trap as the one above. + * + * Unknown enum variants throw {@link IndividualityDecodeError} rather than + * mapping to something plausible: the pallet is under active development, and a + * variant added by a runtime upgrade must fail loudly. + */ +import { IndividualityDecodeError } from "./errors.js"; +import type { AbsenceGracePolicy, PersonhoodParticipant } from "./types.js"; + +/** The attendance history is one byte, so the runtime caps the grace window at 8. */ +const GRACE_WINDOW_MAX = 8; + +/** `Score.AbsenceGraceRatio` serialized as `SizedHex<2>`: `0x` + four hex digits. */ +const GRACE_RATIO_PATTERN = /^0x[0-9a-fA-F]{4}$/; + +/** + * Decode the grace policy from its strict `0x` + four-hex-digit serialization. + * + * Any other encoding — missing prefix, wrong length, non-hex digit — throws. + * The value is validated rather than trusted because a `SizedHex<2>` arriving + * malformed means the descriptor and the chain disagree. + */ +export function decodeAbsenceGracePolicy(value: string): AbsenceGracePolicy { + if (!GRACE_RATIO_PATTERN.test(value)) { + throw new IndividualityDecodeError("invalid absence grace policy encoding"); + } + // Byte order is (allowed_misses, window). The metadata tuple is anonymous, + // so the order comes from the pallet doc comment, not the type. + const allowedMisses = Number.parseInt(value.slice(2, 4), 16); + const window = Number.parseInt(value.slice(4, 6), 16); + + // The runtime guarantees `window <= 8` and `allowed_misses < window` for every + // grace tier, so both checks below reject impossible chain data rather than + // trusting it. They exist because the byte order above is unverifiable from + // the type: were the halves ever swapped, the decode would still succeed and + // return plausible numbers, and since the projection can return at most + // `window`, `misses > allowedMisses` would become unreachable and `Caution` + // would silently never fire. That turns the one trap this file cannot see + // into a loud failure. + if (window > GRACE_WINDOW_MAX) { + throw new IndividualityDecodeError("absence grace window exceeds the attendance history"); + } + // `window === 0` is the legitimate no-grace tier and carries no useful + // allowance, so it is exempt from the ordering rule. + if (window !== 0 && allowedMisses >= window) { + throw new IndividualityDecodeError("absence grace policy violates allowed_misses < window"); + } + + return { allowedMisses, window }; +} + +/** The raw `streak` enum as PAPI decodes it: `Enum<{ Attended: u32; Absent: u32 }>`. */ +export interface RawStreak { + type: string; + value: number; +} + +/** + * The raw `recognition` enum as PAPI decodes it: + * `Enum<{ ExternallyRecognized; NotRecognized; Suspended: bigint; Recognized: bigint }>`. + * + * The payload is a revision id on two of the four variants and absent on the + * other two. The domain does not carry it. + */ +export interface RawRecognition { + type: string; + value?: bigint; +} + +/** + * The raw `Score.Participants` value, narrowed to the fields the domain reads. + * + * The chain also sends `credit`, `cashed_out` and `has_ever_reached_personhood`. + * All three are deliberately absent: the first two are game-economy fields with + * no bearing on membership, and the state machine never reads the third. Extra + * fields on the actual value are accepted — this is a structural type, not an + * exhaustive record of the storage entry. + */ +export interface RawParticipant { + score: number; + streak: RawStreak; + attendance_history: number; + reached_personhood: boolean; + recognition: RawRecognition; + last_attended_game?: number | undefined; +} + +/** + * Validate a raw streak variant. + * + * The domain tags match the chain's variant names exactly, so this narrows + * rather than translates. + */ +function streakTag(type: string): PersonhoodParticipant["streak"]["tag"] { + switch (type) { + case "Attended": + case "Absent": + return type; + default: + // A variant added by a runtime upgrade must fail loudly, never + // silently map to a wrong streak. Fixed message: never echo chain data. + throw new IndividualityDecodeError("unknown streak variant"); + } +} + +/** Validate a raw recognition variant. Same pass-through and same policy. */ +function recognitionTag(type: string): PersonhoodParticipant["recognition"] { + switch (type) { + case "ExternallyRecognized": + case "NotRecognized": + case "Suspended": + case "Recognized": + return type; + default: + throw new IndividualityDecodeError("unknown recognition variant"); + } +} + +/** + * Map a raw PAPI participant to the domain shape the derivation consumes. + * + * The recognition payload (a revision id) is discarded, the game-economy fields + * are dropped, and a missing `last_attended_game` becomes `null` rather than + * `undefined` so the domain type has one absent value, not two. + */ +export function toPersonhoodParticipant(raw: RawParticipant): PersonhoodParticipant { + // Deliberate: unknown enum variants are detectable and validated, while the + // numerics are descriptor-typed with no wrong value this layer could catch. + return { + score: raw.score, + streak: { tag: streakTag(raw.streak.type), count: raw.streak.value }, + attendanceHistory: raw.attendance_history, + reachedPersonhood: raw.reached_personhood, + recognition: recognitionTag(raw.recognition.type), + lastAttendedGame: raw.last_attended_game ?? null, + }; +} + +if (import.meta.vitest) { + const { describe, test, expect } = import.meta.vitest; + + /** A full PAPI-shaped participant; override any field per test. */ + const rawParticipant = (overrides: Partial = {}): RawParticipant => ({ + score: 42, + streak: { type: "Attended", value: 3 }, + attendance_history: 0b1101, + reached_personhood: true, + recognition: { type: "Recognized", value: 5n }, + last_attended_game: 7_777, + ...overrides, + }); + + describe("decodeAbsenceGracePolicy", () => { + test("decodes the serialized [allowed_misses, window] pair", () => { + expect(decodeAbsenceGracePolicy("0x0506")).toEqual({ + allowedMisses: 5, + window: 6, + }); + }); + + test("preserves zeroes in 0x0000", () => { + expect(decodeAbsenceGracePolicy("0x0000")).toEqual({ + allowedMisses: 0, + window: 0, + }); + }); + + test.each([ + ["0x", "empty value"], + ["0x05", "one byte"], + ["0x050", "odd length"], + ["0x050607", "three bytes"], + ["0x0g06", "non-hex digit"], + ["0506", "missing 0x prefix"], + ])("throws on %s (%s)", (value) => { + expect(() => decodeAbsenceGracePolicy(value)).toThrow(IndividualityDecodeError); + }); + + test("rejects a window wider than the attendance history", () => { + // Impossible chain data: the runtime caps the window at 8. + expect(() => decodeAbsenceGracePolicy("0x01c8")).toThrow(IndividualityDecodeError); + }); + + test("rejects allowedMisses >= window, which is what a swapped byte order looks like", () => { + // 0x0801 read in the wrong order: 8 allowed misses over a window of 1. + // Without this check the decode succeeds and Caution never fires again. + expect(() => decodeAbsenceGracePolicy("0x0801")).toThrow(IndividualityDecodeError); + expect(() => decodeAbsenceGracePolicy("0x0808")).toThrow(IndividualityDecodeError); + }); + + test("still allows the no-grace tier, where the allowance is meaningless", () => { + expect(decodeAbsenceGracePolicy("0x0000")).toEqual({ allowedMisses: 0, window: 0 }); + expect(decodeAbsenceGracePolicy("0x0500")).toEqual({ allowedMisses: 5, window: 0 }); + }); + + test("the two bytes are not interchangeable", () => { + // Guards the byte order specifically: if the halves were swapped the + // decode would still succeed and return plausible numbers. + expect(decodeAbsenceGracePolicy("0x0108")).toEqual({ + allowedMisses: 1, + window: 8, + }); + }); + }); + + describe("toPersonhoodParticipant", () => { + test("maps every field of a representative participant", () => { + expect(toPersonhoodParticipant(rawParticipant())).toEqual({ + score: 42, + streak: { tag: "Attended", count: 3 }, + attendanceHistory: 0b1101, + reachedPersonhood: true, + recognition: "Recognized", + lastAttendedGame: 7_777, + }); + }); + + test("maps both streak variants to their PascalCase tags", () => { + const cases: Array<[RawStreak, "Attended" | "Absent"]> = [ + [{ type: "Attended", value: 3 }, "Attended"], + [{ type: "Absent", value: 2 }, "Absent"], + ]; + for (const [streak, expected] of cases) { + expect(toPersonhoodParticipant(rawParticipant({ streak }))).toEqual( + expect.objectContaining({ + streak: { tag: expected, count: streak.value }, + }), + ); + } + }); + + test("maps all four recognition variants through unchanged", () => { + const cases: Array<[RawRecognition, PersonhoodParticipant["recognition"]]> = [ + [{ type: "ExternallyRecognized" }, "ExternallyRecognized"], + [{ type: "NotRecognized" }, "NotRecognized"], + [{ type: "Suspended", value: 9n }, "Suspended"], + [{ type: "Recognized", value: 5n }, "Recognized"], + ]; + for (const [recognition, expected] of cases) { + expect(toPersonhoodParticipant(rawParticipant({ recognition }))).toEqual( + expect.objectContaining({ recognition: expected }), + ); + } + }); + + test("maps a missing last_attended_game to null", () => { + expect( + toPersonhoodParticipant(rawParticipant({ last_attended_game: undefined })), + ).toEqual(expect.objectContaining({ lastAttendedGame: null })); + }); + + test("throws on an unknown streak variant", () => { + const raw = rawParticipant(); + raw.streak = { type: "Maybe", value: 1 }; + expect(() => toPersonhoodParticipant(raw)).toThrow(IndividualityDecodeError); + }); + + test("throws on an unknown recognition variant", () => { + const raw = rawParticipant(); + raw.recognition = { type: "Provisional" }; + expect(() => toPersonhoodParticipant(raw)).toThrow(IndividualityDecodeError); + }); + + test("never interpolates chain data into a decode error message", () => { + const raw = rawParticipant(); + raw.recognition = { type: "Provisional", value: 123_456_789n }; + expect(() => toPersonhoodParticipant(raw)).toThrow(/^unknown recognition variant$/); + }); + }); +} diff --git a/product-sdk/packages/individuality/src/derive.ts b/product-sdk/packages/individuality/src/derive.ts new file mode 100644 index 0000000..05b36fd --- /dev/null +++ b/product-sdk/packages/individuality/src/derive.ts @@ -0,0 +1,375 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * The pure personhood derivation: chain facts in, one {@link PersonhoodState} + * out. + * + * This module performs no I/O and holds no chain types. It is exported + * separately from the read so callers can derive against a snapshot they + * already hold, with no chain client, and for that reason must never import + * from `read.ts`. + * + * A participant record always beats Lite personhood, external recognition is + * permanent, and `Caution` is a *projection* of the next absence rather than a + * count of past ones. See the package skill for the plain-language version. + */ +import type { PersonhoodInputs, PersonhoodParticipant, PersonhoodState } from "./types.js"; + +/** Attendance history is one byte, so a window wider than 8 games is capped. */ +const HISTORY_BITS = 8; + +/** Count set bits without allocating (Kernighan). */ +function countSetBits(value: number): number { + let count = 0; + let remaining = value; + while (remaining !== 0) { + remaining &= remaining - 1; + count += 1; + } + return count; +} + +/** + * Misses a next-game absence would leave inside the window, mirroring the + * runtime: `store_attendance(false)` (shift left, so bit 0 becomes the miss) + * followed by `misses_in_window(window)`. + * + * The window is clamped to the one-byte history width; a JS number is not a + * `u8`, so the shift is masked back down explicitly. + */ +function projectedMisses(history: number, window: number): number { + const next = (history << 1) & 0xff; + const clamped = window >= HISTORY_BITS ? HISTORY_BITS : Math.max(window, 0); + const mask = (1 << clamped) - 1; + return clamped - countSetBits(next & mask); +} + +/** + * Derive a person's membership standing from one pinned snapshot. + * + * Never throws: an inconsistent record degrades to `Suspended` rather than + * failing the caller's render. + */ +export function derivePersonhoodState(snapshot: PersonhoodInputs): PersonhoodState { + const { participant } = snapshot; + + // A participant record always wins over Lite personhood. + if (participant === null) { + return snapshot.isLitePerson ? { tag: "Lite" } : { tag: "NotEnrolled" }; + } + + const activeWeeks = participant.streak.tag === "Attended" ? participant.streak.count : 0; + + switch (participant.recognition) { + case "ExternallyRecognized": + // External recognition is permanent: personhood is never lost, so this + // stays a plain member even when the personhood flag is unset. Testing + // the flag before the recognition variant gets this wrong. + return { tag: "Member", activeWeeks, lastAttendedGame: participant.lastAttendedGame }; + + case "Suspended": + return { tag: "Suspended" }; + + case "Recognized": { + if (!participant.reachedPersonhood) { + // Fail-safe: recognized without personhood is inconsistent state. + return { tag: "Suspended" }; + } + const misses = projectedMisses(participant.attendanceHistory, snapshot.policy.window); + // Window 0 means no grace (the runtime suspends immediately): the next + // absence suspends regardless of the counted misses. + if (snapshot.policy.window === 0 || misses > snapshot.policy.allowedMisses) { + return { + tag: "Caution", + misses, + allowedMisses: snapshot.policy.allowedMisses, + window: snapshot.policy.window, + lastAttendedGame: participant.lastAttendedGame, + }; + } + return { tag: "Member", activeWeeks, lastAttendedGame: participant.lastAttendedGame }; + } + + case "NotRecognized": + // `score` is reported, not compared: the chain owns `reachedPersonhood`, + // and re-deriving it from the threshold here would drift from it. + return participant.reachedPersonhood + ? { tag: "MembershipReady" } + : { + tag: "Candidate", + score: participant.score, + personhoodThreshold: snapshot.personhoodThreshold, + }; + + default: + // A public export, documented as usable against a snapshot you + // already hold, so inputs need not have met the decoder. A + // never-throwing module must not fall off the end and return + // `undefined`. `satisfies never` keeps the exhaustiveness check a + // bare `default` would remove. + participant.recognition satisfies never; + return { tag: "Suspended" }; + } +} + +if (import.meta.vitest) { + const { describe, test, expect } = import.meta.vitest; + + /** Recognized, at personhood, fully attended for the last 8 games. */ + const participant = ( + overrides: Partial = {}, + ): PersonhoodParticipant => ({ + score: 7, + streak: { tag: "Attended", count: 4 }, + attendanceHistory: 0b11111111, + reachedPersonhood: true, + recognition: "Recognized", + lastAttendedGame: 42, + ...overrides, + }); + + /** Snapshot around a recognized member; override per test. */ + const snapshot = (overrides: Partial = {}): PersonhoodInputs => ({ + isLitePerson: false, + participant: participant(), + personhoodThreshold: 5, + policy: { allowedMisses: 1, window: 8 }, + ...overrides, + }); + + describe("derivePersonhoodState", () => { + test("is NotEnrolled without a participant or Lite personhood", () => { + expect( + derivePersonhoodState(snapshot({ participant: null, isLitePerson: false })), + ).toEqual({ tag: "NotEnrolled" }); + }); + + test("is Lite for a Lite person who has no participant record", () => { + expect( + derivePersonhoodState(snapshot({ participant: null, isLitePerson: true })), + ).toEqual({ tag: "Lite" }); + }); + + test("prefers the participant over Lite personhood", () => { + expect( + derivePersonhoodState( + snapshot({ + isLitePerson: true, + participant: participant({ + recognition: "NotRecognized", + reachedPersonhood: false, + }), + }), + ), + ).toEqual({ tag: "Candidate", score: 7, personhoodThreshold: 5 }); + }); + + test("reports Candidate with the participant's score against the snapshot threshold", () => { + expect( + derivePersonhoodState( + snapshot({ + personhoodThreshold: 5, + participant: participant({ + recognition: "NotRecognized", + reachedPersonhood: false, + score: 3, + }), + }), + ), + ).toEqual({ tag: "Candidate", score: 3, personhoodThreshold: 5 }); + }); + + test("is MembershipReady once personhood is reached before recognition", () => { + expect( + derivePersonhoodState( + snapshot({ + participant: participant({ + recognition: "NotRecognized", + reachedPersonhood: true, + }), + }), + ), + ).toEqual({ tag: "MembershipReady" }); + }); + + test("keeps a recognized person a Member while the window is clean", () => { + expect(derivePersonhoodState(snapshot())).toEqual({ + tag: "Member", + activeWeeks: 4, + lastAttendedGame: 42, + }); + }); + + test("reports the attended streak as active weeks and zero for an absent streak", () => { + expect( + derivePersonhoodState( + snapshot({ + participant: participant({ streak: { tag: "Attended", count: 2 } }), + }), + ), + ).toEqual({ tag: "Member", activeWeeks: 2, lastAttendedGame: 42 }); + expect( + derivePersonhoodState( + snapshot({ + participant: participant({ streak: { tag: "Absent", count: 3 } }), + }), + ), + ).toEqual({ tag: "Member", activeWeeks: 0, lastAttendedGame: 42 }); + }); + + test("never cautions an externally recognized member", () => { + // 0b11001111: two misses inside the window, so a Recognized member here + // would be cautioned — external recognition stays a plain member. + const risky = participant({ + recognition: "ExternallyRecognized", + attendanceHistory: 0b11001111, + }); + expect(derivePersonhoodState(snapshot({ participant: risky }))).toEqual({ + tag: "Member", + activeWeeks: 4, + lastAttendedGame: 42, + }); + // External recognition holds even when the personhood flag is unset. + expect( + derivePersonhoodState( + snapshot({ participant: { ...risky, reachedPersonhood: false } }), + ), + ).toEqual({ tag: "Member", activeWeeks: 4, lastAttendedGame: 42 }); + }); + + test("cautions exactly when the next absence crosses the grace policy", () => { + // 0b11111110: one live miss at the newest bit; a new absence shifts it to + // bit 1 and lands a second miss, so `misses` is the projected 2 (> 1). + expect( + derivePersonhoodState( + snapshot({ participant: participant({ attendanceHistory: 0b11111110 }) }), + ), + ).toEqual({ + tag: "Caution", + misses: 2, + allowedMisses: 1, + window: 8, + lastAttendedGame: 42, + }); + // Zero allowed misses: even a clean window crosses on the next absence. + expect( + derivePersonhoodState(snapshot({ policy: { allowedMisses: 0, window: 8 } })), + ).toEqual({ + tag: "Caution", + misses: 1, + allowedMisses: 0, + window: 8, + lastAttendedGame: 42, + }); + // A clean window with one allowed miss stays a member: 1 is not > 1. + expect(derivePersonhoodState(snapshot())).toEqual({ + tag: "Member", + activeWeeks: 4, + lastAttendedGame: 42, + }); + }); + + test("stays a Member when an old miss shifts out of the window", () => { + // 0b01111111: the only miss is bit 7 (oldest); shifting in a new absence + // evicts it, so the projected window still holds exactly one miss. + expect( + derivePersonhoodState( + snapshot({ participant: participant({ attendanceHistory: 0b01111111 }) }), + ), + ).toEqual({ tag: "Member", activeWeeks: 4, lastAttendedGame: 42 }); + }); + + test("suspends a Suspended participant even with personhood reached", () => { + expect( + derivePersonhoodState( + snapshot({ participant: participant({ recognition: "Suspended" }) }), + ), + ).toEqual({ tag: "Suspended" }); + }); + + test("fail-safes to Suspended when recognized without personhood", () => { + expect( + derivePersonhoodState( + snapshot({ + participant: participant({ + recognition: "Recognized", + reachedPersonhood: false, + }), + }), + ), + ).toEqual({ tag: "Suspended" }); + }); + + // --- Added here, not present in the humanity-spa suite ----------------- + + test("window 0 cautions regardless of the projected miss count", () => { + // No grace at all: the next absence suspends whatever the window holds. + // The projection over a zero-width window is 0, which is *below* + // allowedMisses — so reaching Caution here proves the short-circuit + // runs before the comparison. + expect( + derivePersonhoodState(snapshot({ policy: { allowedMisses: 1, window: 0 } })), + ).toEqual({ + tag: "Caution", + misses: 0, + allowedMisses: 1, + window: 0, + lastAttendedGame: 42, + }); + }); + + test("fail-safes to Suspended on a recognition variant it does not know", () => { + // Callers may hand-build inputs that never met the decoder. Without + // the default this returns `undefined`, not a PersonhoodState. + expect( + derivePersonhoodState( + snapshot({ + participant: participant({ + recognition: + "Revoked" as unknown as PersonhoodParticipant["recognition"], + }), + }), + ), + ).toEqual({ tag: "Suspended" }); + }); + + test("score exactly at the threshold is still Candidate", () => { + // The chain owns `reachedPersonhood`; the derivation must not infer + // personhood from `score >= personhoodThreshold`. + expect( + derivePersonhoodState( + snapshot({ + personhoodThreshold: 5, + participant: participant({ + recognition: "NotRecognized", + reachedPersonhood: false, + score: 5, + }), + }), + ), + ).toEqual({ tag: "Candidate", score: 5, personhoodThreshold: 5 }); + }); + + test("handles the attendance-history byte boundaries", () => { + // 0xff, perfect attendance: the shift leaves exactly one projected + // miss, which is not > 1, so the member holds. + expect( + derivePersonhoodState( + snapshot({ participant: participant({ attendanceHistory: 0xff }) }), + ), + ).toEqual({ tag: "Member", activeWeeks: 4, lastAttendedGame: 42 }); + // 0x00, never attended: the whole window is misses. + expect( + derivePersonhoodState( + snapshot({ participant: participant({ attendanceHistory: 0x00 }) }), + ), + ).toEqual({ + tag: "Caution", + misses: 8, + allowedMisses: 1, + window: 8, + lastAttendedGame: 42, + }); + }); + }); +} diff --git a/product-sdk/packages/individuality/src/errors.ts b/product-sdk/packages/individuality/src/errors.ts new file mode 100644 index 0000000..5c38b6a --- /dev/null +++ b/product-sdk/packages/individuality/src/errors.ts @@ -0,0 +1,88 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * Errors raised by `@parity/product-sdk-individuality`. + * + * `readPersonhoodState` returns a `Result`, so these arrive on the `err` + * channel rather than as throws. Two kinds reach it: + * + * - {@link IndividualityDecodeError}, when the chain returns a shape the + * descriptor says is impossible. + * - {@link ProductIndividualityError} itself, carrying any other failure as its + * `cause`: an unreachable node, an aborted signal, or the pinned block leaving + * the follower's window mid-read. + * + * A username nobody owns is neither. It is a successful answer and travels on + * the `ok` channel as a `PersonhoodResult`. + * + * Narrow with `isErrorOf(e, IndividualityDecodeError)` from `@parity/result`, or + * recognise any SDK error with `isSdkError(e)` from + * `@parity/product-sdk-errors`. + */ +import type { SdkError } from "@parity/product-sdk-errors"; + +/** + * Base class for errors raised by `@parity/product-sdk-individuality`. + * + * Implements the cross-package {@link SdkError} marker so `isSdkError(e)` also + * recognizes it. + */ +export class ProductIndividualityError extends Error implements SdkError { + readonly isSdkError = true as const; + readonly source = "individuality"; + + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "ProductIndividualityError"; + } +} + +/** + * A raw storage value did not match the shape the descriptor promised — an + * unknown `streak` or `recognition` variant, or a malformed grace ratio. + * + * **Messages must be fixed strings.** Never interpolate a decoded value into + * one: the values here describe a person's chain state, and an error message + * is the least controlled place they can end up. The variant that failed is + * identifiable from the message text alone. + */ +export class IndividualityDecodeError extends ProductIndividualityError { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "IndividualityDecodeError"; + } +} + +if (import.meta.vitest) { + const { describe, test, expect } = import.meta.vitest; + + // Asserted structurally rather than through `isSdkError` from + // `@parity/product-sdk-errors`: importing it would make this package's fast + // test loop depend on that package being built first. `isSdkError` is + // `e instanceof Error && e.isSdkError === true`, and its own suite covers + // the predicate — so the two assertions below are the whole contract. + describe("error hierarchy", () => { + test("ProductIndividualityError carries the SdkError marker", () => { + const err = new ProductIndividualityError("boom"); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("ProductIndividualityError"); + expect(err.isSdkError).toBe(true); + expect(err.source).toBe("individuality"); + }); + + test("IndividualityDecodeError extends the package base", () => { + const err = new IndividualityDecodeError("unknown recognition variant"); + expect(err).toBeInstanceOf(ProductIndividualityError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("IndividualityDecodeError"); + expect(err.isSdkError).toBe(true); + expect(err.source).toBe("individuality"); + }); + + test("carries a cause when given one", () => { + const cause = new Error("underlying"); + const err = new IndividualityDecodeError("malformed grace ratio", { cause }); + expect(err.cause).toBe(cause); + }); + }); +} diff --git a/product-sdk/packages/individuality/src/index.ts b/product-sdk/packages/individuality/src/index.ts new file mode 100644 index 0000000..f652530 --- /dev/null +++ b/product-sdk/packages/individuality/src/index.ts @@ -0,0 +1,59 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * @parity/product-sdk-individuality — read a person's standing on the + * individuality chain. + * + * One question, one answer: for a DotNS username, what is that person's + * personhood state, as of one pinned finalized block? + * + * ```ts + * import { getChainAPI } from "@parity/product-sdk-chain-client"; + * import { readPersonhoodState } from "@parity/product-sdk-individuality"; + * + * const chain = await getChainAPI("paseo"); + * const result = await readPersonhoodState(chain, { username: "alice.dot" }); + * if (!result.ok) { + * console.error(result.error); + * } else if (result.value.tag === "Resolved" && result.value.state.tag === "Member") { + * console.log(`member for ${result.value.state.activeWeeks} weeks`); + * } + * ``` + * + * Failures arrive on the `err` channel as a `ProductIndividualityError`, per the + * SDK-wide error model. A username nobody owns is not a failure: it is + * `ok({ tag: "UsernameUnowned", ... })`. + * + * The derivation is exported separately from the read, so the pure state + * machine can be used against a snapshot you already hold, with no chain client + * and no host container. + * + * **Not an authorization oracle.** This is a client-side read in a client-side + * library, and a backend that trusts "the SDK said `Member`" is trivially + * spoofed. Anything that gates value must verify on chain itself. + */ + +// The seven-state union, its wrappers, and the pinned-block coordinates. +export type { + AbsenceGracePolicy, + FinalizedSnapshot, + PersonhoodInputs, + PersonhoodParticipant, + PersonhoodResult, + PersonhoodState, +} from "./types.js"; + +// The pure derivation, for a snapshot the caller already holds. +export { derivePersonhoodState } from "./derive.js"; + +// Raw storage values to domain shapes, for callers doing their own reads. +export { decodeAbsenceGracePolicy, toPersonhoodParticipant } from "./decode.js"; +export type { RawParticipant, RawRecognition, RawStreak } from "./decode.js"; + +// The pinned batched read. +export { readPersonhoodState } from "./read.js"; +export type { IndividualityChain, RawAccountAlias, ReadPersonhoodStateOptions } from "./read.js"; + +// Errors. `UsernameUnowned` is not one of them — it travels on the success +// channel as a `PersonhoodResult`. +export { IndividualityDecodeError, ProductIndividualityError } from "./errors.js"; diff --git a/product-sdk/packages/individuality/src/read.ts b/product-sdk/packages/individuality/src/read.ts new file mode 100644 index 0000000..1e93521 --- /dev/null +++ b/product-sdk/packages/individuality/src/read.ts @@ -0,0 +1,641 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * The pinned read: one username in, one {@link PersonhoodResult} out. + * + * **Every read shares one finalized block.** Two of the six values move on a + * session cadence (`Score.PersonhoodThreshold` and `Score.AbsenceGraceRatio` + * both have schedules behind them), so reading them at different blocks would + * silently mix eras. The block is pinned once and reported on the result. + * + * This module resolves no chain of its own. It takes an already-connected + * client, which is what keeps chain selection — and the environment question — + * with the caller: + * + * ```ts + * const chain = await getChainAPI("paseo"); + * const state = await readPersonhoodState(chain, { username: "alice.dot" }); + * ``` + */ +import { Enum } from "polkadot-api"; +import { err, normalizeError, ok, type Result } from "@parity/result"; +import { derivePersonhoodState } from "./derive.js"; +import { + decodeAbsenceGracePolicy, + toPersonhoodParticipant, + type RawParticipant, +} from "./decode.js"; +import { ProductIndividualityError } from "./errors.js"; +import type { FinalizedSnapshot, PersonhoodParticipant, PersonhoodResult } from "./types.js"; + +/** Options every storage read is given, so all six agree on one block. */ +interface ReadAt { + at: string; + signal?: AbortSignal; +} + +/** `AccountToAlias`, narrowed to the contextual alias the read keys on. */ +export interface RawAccountAlias { + ca: { alias: string }; +} + +/** + * The chain surface this read needs — deliberately structural, not a pinned + * descriptor. + * + * Anything exposing these six entries satisfies it: a real + * `ChainClient<{ individuality: … }>` from `getChainAPI`, a future People Lite + * deployment, or a hand-rolled test double. The same approach as + * `PeopleUsernameQueryApi` in `@parity/product-sdk`'s `identity/dotns.ts`, and + * for the same reason — the SDK should not pin a genesis hash to read a + * username. + * + * Written with method shorthand on purpose: the parameter bivariance that gives + * is what lets the real PAPI signatures satisfy the loosened key types below. + * + * **Fidelity is checked at compile time, from the umbrella package.** + * `packages/sdk/src/individuality/contract.test.ts` asserts that a real + * `getChainAPI` client still satisfies this type, so a descriptor regeneration + * that changes an entry fails `pnpm typecheck`. + * + * The guard has to live there rather than here, which is worth recording because + * it is not obvious. Inside this package the same assertion is *vacuous*: it + * passes even against a contract demanding a pallet the chain does not have, + * because the descriptor types do not fully resolve through this package's + * dependency graph. From `packages/sdk`, which depends on both `chain-client` and + * this package, the identical assertion correctly rejects a bogus contract. Both + * halves were verified before choosing the placement. + * + * The entries were also matched by hand on 2026-08-17 against + * `descriptors/chains/paseo-individuality/generated/dist/paseo_individuality.d.ts`: + * + * ``` + * UsernameOwnerOf: StorageDescriptor<[Key: Uint8Array], SS58String, true, never> + * Participants: key AnonymousEnum<{ Account: SS58String; Person: SizedHex<32> }> + * PersonhoodThreshold: StorageDescriptor<[], number, false, never> + * AbsenceGraceRatio: StorageDescriptor<[], SizedHex<2>, false, never> + * AccountToAlias: value { revision, ring, ca: { alias, context } } + * LitePeople: value { ring_vrf_key, method } (presence is the signal) + * ``` + * + * A descriptor regeneration that changes any of them now fails `pnpm typecheck` + * rather than passing silently. + */ +export interface IndividualityChain { + individuality: { + query: { + Resources: { + UsernameOwnerOf: { + getValue(key: Uint8Array, options: ReadAt): Promise; + }; + }; + Score: { + Participants: { + getValue( + key: { type: string; value: unknown }, + options: ReadAt, + ): Promise; + }; + PersonhoodThreshold: { + getValue(options: ReadAt): Promise; + }; + AbsenceGraceRatio: { + getValue(options: ReadAt): Promise; + }; + }; + People: { + AccountToAlias: { + getValue(key: string, options: ReadAt): Promise; + }; + }; + PeopleLite: { + LitePeople: { + getValue(key: string, options: ReadAt): Promise; + }; + /** + * Read as well as `People.AccountToAlias`: a Lite person's alias + * lives here, and without it the alias-keyed participant lookup + * never runs for them. + */ + AccountToAlias: { + getValue(key: string, options: ReadAt): Promise; + }; + }; + }; + }; + raw: { + individuality: { + getFinalizedBlock(): Promise<{ hash: string; number: number }>; + }; + }; +} + +/** Options for {@link readPersonhoodState}. */ +export interface ReadPersonhoodStateOptions { + /** + * The DotNS username, UTF-8 encoded as-is with no normalization. Pass the + * exact byte string the chain stores, `.dot` suffix included. + */ + username: string; + /** + * Forwarded into every underlying pull, so an aborted caller stops the + * whole batch. No deadline is applied here — that belongs to the caller, or + * eventually to `chain-client`. + */ + signal?: AbortSignal; +} + +/** + * Read a DotNS username's personhood state from one pinned finalized block. + * + * Returns a `Result`, per the SDK-wide error model: `ok` carries the answer, + * `err` carries a {@link ProductIndividualityError}. Everything that can go + * wrong arrives on the `err` channel, not only decode failures. That includes an + * unreachable node, an aborted signal, and the pinned block leaving the + * follower's window mid-read, each normalized into the package's error type with + * the original cause attached. + * + * A username nobody owns is **not** a failure. It resolves to + * `ok({ tag: "UsernameUnowned", ... })`, because the chain was asked and + * answered. + * + * **Not an authorization oracle.** This is a client-side read in a client-side + * library, and a backend that trusts "the SDK said `Member`" is trivially + * spoofed. + */ +export async function readPersonhoodState( + chain: IndividualityChain, + options: ReadPersonhoodStateOptions, +): Promise> { + try { + return ok(await runRead(chain, options)); + } catch (cause) { + // Every failure lands here: decode errors thrown by the mappers, the + // early abort in runRead, and any transport rejection from a pull. + // normalizeError passes an existing package error through unchanged, so + // callers can still narrow with isErrorOf. + return err(normalizeError(cause, ProductIndividualityError)); + } +} + +/** The read itself. Throws; {@link readPersonhoodState} owns the Result boundary. */ +async function runRead( + chain: IndividualityChain, + options: ReadPersonhoodStateOptions, +): Promise { + const { username, signal } = options; + const query = chain.individuality.query; + + // A caller who already cancelled should cost no round trip at all. The block + // fetch below takes no options, so it cannot carry the signal itself. + signal?.throwIfAborted(); + + // Pin one finalized block: every read below must agree on it. + const block = await chain.raw.individuality.getFinalizedBlock(); + const at: ReadAt = { at: block.hash, signal }; + const snapshot: FinalizedSnapshot = { blockHash: block.hash, blockNumber: block.number }; + + // The entry point is `Resources.UsernameOwnerOf` on the individuality + // chain's resources pallet — not `pallet_identity` on the fellows People + // chain. Those are unrelated username systems. + const owner = await query.Resources.UsernameOwnerOf.getValue( + new TextEncoder().encode(username), + at, + ); + if (owner == null) { + return { tag: "UsernameUnowned", at: snapshot }; + } + + const [ + accountParticipant, + peopleAlias, + liteAlias, + litePerson, + personhoodThreshold, + absenceGraceRatio, + ] = await Promise.all([ + query.Score.Participants.getValue(Enum("Account", owner), at), + query.People.AccountToAlias.getValue(owner, at), + query.PeopleLite.AccountToAlias.getValue(owner, at), + query.PeopleLite.LitePeople.getValue(owner, at), + // `PersonhoodThreshold` is a u8. PAPI types both u8 and u32 as + // number, so a width mistake typechecks and passes tests. + query.Score.PersonhoodThreshold.getValue(at), + query.Score.AbsenceGraceRatio.getValue(at), + ]); + + // Both pallets carry an `AccountToAlias` with the same value shape. The Lite + // signal comes from `PeopleLite`, so its alias has to be consulted too, or a + // Lite person's alias-keyed record is invisible. `People` wins when both hold + // one, since a full person's alias is the more specific answer. + const alias = peopleAlias ?? liteAlias; + + // No account-keyed record: fall back to the contextual alias key. The + // account key is tried first because `Score.Participants` is keyed by an + // enum, so one lookup cannot cover both. + const rawParticipant = + accountParticipant ?? + (alias == null + ? null + : await query.Score.Participants.getValue(Enum("Person", alias.ca.alias), at)); + + const participant: PersonhoodParticipant | null = + rawParticipant == null ? null : toPersonhoodParticipant(rawParticipant); + + return { + tag: "Resolved", + at: snapshot, + accountAddress: owner, + // The Person key is the contextual alias, never the DotNS text. + alias: alias?.ca.alias ?? null, + state: derivePersonhoodState({ + // Presence is the Lite signal, not `Resources.Consumers().credibility`. + isLitePerson: litePerson != null, + participant, + personhoodThreshold, + policy: decodeAbsenceGracePolicy(absenceGraceRatio), + }), + }; +} + +if (import.meta.vitest) { + const { describe, expect, test } = import.meta.vitest; + const { unwrapOk, unwrapErr, isErrorOf } = await import("@parity/result"); + const { IndividualityDecodeError } = await import("./errors.js"); + + const ALICE = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; + const ALIAS = `0x${"ab".repeat(32)}`; + const LITE_ALIAS = `0x${"cd".repeat(32)}`; + const BLOCK = { hash: `0x${"11".repeat(32)}`, number: 5_000 }; + + const raw = (overrides: Partial = {}): RawParticipant => ({ + score: 7, + streak: { type: "Attended", value: 4 }, + attendance_history: 0xff, + reached_personhood: true, + recognition: { type: "Recognized", value: 5n }, + last_attended_game: 42, + ...overrides, + }); + + interface FakeState { + owner?: string; + accountParticipant?: RawParticipant; + personParticipant?: RawParticipant; + alias?: RawAccountAlias; + liteAlias?: RawAccountAlias; + lite?: unknown; + threshold?: number; + grace?: string; + } + + /** + * A chain double that records the key and the options every read was given. + * + * The key is recorded deliberately: without it, a read addressed with the + * wrong key still satisfies every other assertion here. + */ + function fakeChain(state: FakeState) { + const calls: Array<{ + entry: string; + key: unknown; + at: string; + signal?: AbortSignal; + }> = []; + const record = (entry: string, key: unknown, options: ReadAt) => { + calls.push({ entry, key, at: options.at, signal: options.signal }); + }; + const keyOf = (entry: string) => calls.find((c) => c.entry === entry)?.key; + const chain: IndividualityChain = { + individuality: { + query: { + Resources: { + UsernameOwnerOf: { + async getValue(key, options) { + record("UsernameOwnerOf", key, options); + return state.owner; + }, + }, + }, + Score: { + Participants: { + async getValue(key, options) { + record(`Participants:${key.type}`, key, options); + return key.type === "Account" + ? state.accountParticipant + : state.personParticipant; + }, + }, + PersonhoodThreshold: { + async getValue(options) { + record("PersonhoodThreshold", undefined, options); + return state.threshold ?? 5; + }, + }, + AbsenceGraceRatio: { + async getValue(options) { + record("AbsenceGraceRatio", undefined, options); + return state.grace ?? "0x0108"; + }, + }, + }, + People: { + AccountToAlias: { + async getValue(key, options) { + record("AccountToAlias", key, options); + return state.alias; + }, + }, + }, + PeopleLite: { + LitePeople: { + async getValue(key, options) { + record("LitePeople", key, options); + return state.lite; + }, + }, + AccountToAlias: { + async getValue(key, options) { + record("LiteAccountToAlias", key, options); + return state.liteAlias; + }, + }, + }, + }, + }, + raw: { + individuality: { + async getFinalizedBlock() { + return BLOCK; + }, + }, + }, + }; + return { chain, calls, keyOf }; + } + + describe("readPersonhoodState", () => { + test("an unowned username is a success value, and stops after one read", async () => { + const { chain, calls } = fakeChain({ owner: undefined }); + const result = await readPersonhoodState(chain, { username: "nobody.dot" }); + expect(unwrapOk(result)).toEqual({ + tag: "UsernameUnowned", + at: { blockHash: BLOCK.hash, blockNumber: BLOCK.number }, + }); + // The batch must not run once the username is unowned. + expect(calls.map((c) => c.entry)).toEqual(["UsernameOwnerOf"]); + }); + + test("the username is UTF-8 encoded, not passed through", async () => { + const { chain, keyOf } = fakeChain({ owner: ALICE }); + await readPersonhoodState(chain, { username: "alice.dot" }); + expect(keyOf("UsernameOwnerOf")).toEqual(new TextEncoder().encode("alice.dot")); + }); + + test("every account-keyed read uses the owner, not the username", async () => { + const { chain, keyOf } = fakeChain({ owner: ALICE }); + await readPersonhoodState(chain, { username: "alice.dot" }); + expect(keyOf("Participants:Account")).toEqual(Enum("Account", ALICE)); + expect(keyOf("AccountToAlias")).toBe(ALICE); + expect(keyOf("LiteAccountToAlias")).toBe(ALICE); + expect(keyOf("LitePeople")).toBe(ALICE); + }); + + test("resolves an account-keyed participant without the alias fallback", async () => { + // The alias is present on purpose: skipping the Person read must be + // because the account key hit, not because there was nothing to fall + // back to. + const { chain, calls } = fakeChain({ + owner: ALICE, + accountParticipant: raw(), + alias: { ca: { alias: ALIAS } }, + }); + const result = await readPersonhoodState(chain, { username: "alice.dot" }); + expect(unwrapOk(result)).toMatchObject({ + tag: "Resolved", + accountAddress: ALICE, + state: { tag: "Member", activeWeeks: 4, lastAttendedGame: 42 }, + }); + expect(calls.filter((c) => c.entry.startsWith("Participants:"))).toHaveLength(1); + expect(calls.some((c) => c.entry === "Participants:Person")).toBe(false); + }); + + test("the account-keyed record wins when both keys hold one", async () => { + // Reversing the ?? chain changes which record wins, and nothing else + // here notices. Score 7 is the account record, 99 the alias one. + const { chain } = fakeChain({ + owner: ALICE, + accountParticipant: raw({ + score: 7, + recognition: { type: "NotRecognized" }, + reached_personhood: false, + }), + alias: { ca: { alias: ALIAS } }, + personParticipant: raw({ + score: 99, + recognition: { type: "NotRecognized" }, + reached_personhood: false, + }), + }); + const result = await readPersonhoodState(chain, { username: "alice.dot" }); + expect(unwrapOk(result)).toMatchObject({ + state: { tag: "Candidate", score: 7 }, + }); + }); + + test("falls back to the alias key when no account-keyed record exists", async () => { + const { chain, calls, keyOf } = fakeChain({ + owner: ALICE, + accountParticipant: undefined, + alias: { ca: { alias: ALIAS } }, + personParticipant: raw({ score: 3, recognition: { type: "NotRecognized" } }), + }); + const result = await readPersonhoodState(chain, { username: "alice.dot" }); + expect(unwrapOk(result)).toMatchObject({ + tag: "Resolved", + alias: ALIAS, + state: { tag: "MembershipReady" }, + }); + // Account key first, then the Person key. Order matters. + expect( + calls.filter((c) => c.entry.startsWith("Participants:")).map((c) => c.entry), + ).toEqual(["Participants:Account", "Participants:Person"]); + // The Person key is the contextual alias, never the owner or the username. + expect(keyOf("Participants:Person")).toEqual(Enum("Person", ALIAS)); + }); + + test("a Lite person's alias comes from PeopleLite when People has none", async () => { + // Without reading PeopleLite.AccountToAlias this person reports Lite, + // because the alias-keyed lookup never runs. + const { chain, keyOf } = fakeChain({ + owner: ALICE, + alias: undefined, + liteAlias: { ca: { alias: LITE_ALIAS } }, + lite: { ring_vrf_key: "0x00" }, + personParticipant: raw({ score: 4, recognition: { type: "NotRecognized" } }), + }); + const result = await readPersonhoodState(chain, { username: "alice.dot" }); + expect(unwrapOk(result)).toMatchObject({ + alias: LITE_ALIAS, + state: { tag: "MembershipReady" }, + }); + expect(keyOf("Participants:Person")).toEqual(Enum("Person", LITE_ALIAS)); + }); + + test("the People alias wins over the PeopleLite one", async () => { + const { chain } = fakeChain({ + owner: ALICE, + alias: { ca: { alias: ALIAS } }, + liteAlias: { ca: { alias: LITE_ALIAS } }, + }); + const result = await readPersonhoodState(chain, { username: "alice.dot" }); + expect(unwrapOk(result)).toMatchObject({ alias: ALIAS }); + }); + + test("skips the fallback entirely when neither pallet has an alias", async () => { + const { chain, calls } = fakeChain({ owner: ALICE, alias: undefined }); + const result = await readPersonhoodState(chain, { username: "alice.dot" }); + expect(unwrapOk(result)).toMatchObject({ + tag: "Resolved", + alias: null, + state: { tag: "NotEnrolled" }, + }); + expect(calls.some((c) => c.entry === "Participants:Person")).toBe(false); + }); + + test("all reads share one pinned finalized block", async () => { + // The whole point of the function. Two of the values move on a session + // cadence, so a second block here would mix eras silently. + const { chain, calls } = fakeChain({ + owner: ALICE, + alias: { ca: { alias: ALIAS } }, + personParticipant: raw(), + }); + await readPersonhoodState(chain, { username: "alice.dot" }); + expect(calls).toHaveLength(8); // 1 owner + 6 batch + 1 alias fallback + expect(new Set(calls.map((c) => c.at))).toEqual(new Set([BLOCK.hash])); + }); + + test("forwards the abort signal into every read", async () => { + const controller = new AbortController(); + const { chain, calls } = fakeChain({ owner: ALICE, accountParticipant: raw() }); + await readPersonhoodState(chain, { + username: "alice.dot", + signal: controller.signal, + }); + expect(calls).toHaveLength(7); + expect(calls.every((c) => c.signal === controller.signal)).toBe(true); + }); + + test("an already cancelled read costs no round trip", async () => { + const controller = new AbortController(); + controller.abort(); + const { chain, calls } = fakeChain({ owner: ALICE, accountParticipant: raw() }); + const result = await readPersonhoodState(chain, { + username: "alice.dot", + signal: controller.signal, + }); + expect(result.ok).toBe(false); + expect(calls).toHaveLength(0); + }); + + test("a malformed grace ratio arrives on the err channel", async () => { + const { chain } = fakeChain({ owner: ALICE, grace: "0xZZ" }); + const result = await readPersonhoodState(chain, { username: "alice.dot" }); + expect(result.ok).toBe(false); + expect(isErrorOf(unwrapErr(result), IndividualityDecodeError)).toBe(true); + }); + + test("a transport failure arrives on the err channel, typed", async () => { + const { chain } = fakeChain({ owner: ALICE }); + chain.raw.individuality.getFinalizedBlock = async () => { + throw new Error("websocket closed"); + }; + const result = await readPersonhoodState(chain, { username: "alice.dot" }); + expect(result.ok).toBe(false); + const error = unwrapErr(result); + expect(error.source).toBe("individuality"); + expect((error.cause as Error).message).toBe("websocket closed"); + }); + + test("a rejection inside the parallel batch arrives on the err channel, typed", async () => { + // The test above fails before the batch is reached; this one fails + // inside it. The alias makes the fallback reachable, so asserting it + // never ran proves no partial state is published. + const { chain, calls } = fakeChain({ + owner: ALICE, + alias: { ca: { alias: ALIAS } }, + personParticipant: raw(), + }); + chain.individuality.query.Score.AbsenceGraceRatio.getValue = async () => { + throw new Error("storage read failed"); + }; + const result = await readPersonhoodState(chain, { username: "alice.dot" }); + expect(result.ok).toBe(false); + const error = unwrapErr(result); + expect(error.source).toBe("individuality"); + expect((error.cause as Error).message).toBe("storage read failed"); + expect(calls.some((c) => c.entry === "Participants:Person")).toBe(false); + }); + + // --- inherited from humanity-spa's toHumanityCardResult suite ---------- + + test("keys the result by the username-owner account and contextual alias", async () => { + const { chain } = fakeChain({ + owner: ALICE, + accountParticipant: raw(), + alias: { ca: { alias: ALIAS } }, + }); + const result = await readPersonhoodState(chain, { username: "alice.dot" }); + expect(unwrapOk(result)).toMatchObject({ accountAddress: ALICE, alias: ALIAS }); + }); + + test("keeps a missing contextual alias null, never the DotNS text", async () => { + const { chain } = fakeChain({ owner: ALICE, accountParticipant: raw() }); + const result = await readPersonhoodState(chain, { username: "alice.dot" }); + expect(unwrapOk(result)).toMatchObject({ alias: null }); + expect(JSON.stringify(result)).not.toContain("alice.dot"); + }); + + test("derives Lite and NotEnrolled when no participant record exists", async () => { + const lite = await readPersonhoodState( + fakeChain({ owner: ALICE, lite: { ring_vrf_key: "0x00" } }).chain, + { username: "alice.dot" }, + ); + expect(unwrapOk(lite)).toMatchObject({ state: { tag: "Lite" } }); + + const notEnrolled = await readPersonhoodState( + fakeChain({ owner: ALICE, lite: undefined }).chain, + { username: "alice.dot" }, + ); + expect(unwrapOk(notEnrolled)).toMatchObject({ state: { tag: "NotEnrolled" } }); + }); + + test("wires the personhood threshold and grace policy into the state", async () => { + const candidate = await readPersonhoodState( + fakeChain({ + owner: ALICE, + threshold: 11, + accountParticipant: raw({ + score: 4, + recognition: { type: "NotRecognized" }, + reached_personhood: false, + }), + }).chain, + { username: "alice.dot" }, + ); + expect(unwrapOk(candidate)).toMatchObject({ + state: { tag: "Candidate", score: 4, personhoodThreshold: 11 }, + }); + + // 0x0008 -> allowedMisses 0, window 8: a clean history still cautions. + const cautioned = await readPersonhoodState( + fakeChain({ owner: ALICE, grace: "0x0008", accountParticipant: raw() }).chain, + { username: "alice.dot" }, + ); + expect(unwrapOk(cautioned)).toMatchObject({ + state: { tag: "Caution", allowedMisses: 0, window: 8 }, + }); + }); + }); +} diff --git a/product-sdk/packages/individuality/src/types.ts b/product-sdk/packages/individuality/src/types.ts new file mode 100644 index 0000000..baaeca2 --- /dev/null +++ b/product-sdk/packages/individuality/src/types.ts @@ -0,0 +1,140 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * The public shapes of the personhood read layer. + * + * Two closed unions, both discriminated by `tag`: + * + * - {@link PersonhoodState} — what a person's standing on the individuality + * chain is, once a username has resolved to an account. + * - {@link PersonhoodResult} — the outcome of the read itself, which may find + * the username unowned. That is a success value, not an error. + * + * Every result carries the {@link FinalizedSnapshot} it was read at. The + * personhood threshold and the absence-grace ratio are session-updated values, + * so two of the six underlying reads move on a session cadence — which is why + * the block is pinned and reported rather than left implicit. + */ + +/** + * A person's membership standing, derived from one pinned snapshot. + * + * Ordered here roughly by progression, not by precedence. The derivation rules + * are the authority on precedence — in particular a participant record always + * beats Lite personhood, and external recognition is permanent. + */ +export type PersonhoodState = + /** No participant record and not a Lite person: unknown to both pallets. */ + | { tag: "NotEnrolled" } + /** Present in `PeopleLite.LitePeople` with no participant record. */ + | { tag: "Lite" } + /** + * Enrolled and accruing score, but not yet recognized and personhood not + * yet reached. + */ + | { tag: "Candidate"; score: number; personhoodThreshold: number } + /** Personhood reached, but recognition has not been granted yet. */ + | { tag: "MembershipReady" } + /** + * A full member in good standing. + * + * @param activeWeeks - consecutive attended games, `0` when the current + * streak is an absence. + */ + | { tag: "Member"; activeWeeks: number; lastAttendedGame: number | null } + /** + * A member whose next absence would breach the grace policy. + * + * `misses` is a *projection*, not a reading: it is what the window would + * hold after one more absence. `window === 0` means there is no grace at + * all, and lands here regardless of `misses`. + */ + | { + tag: "Caution"; + misses: number; + allowedMisses: number; + window: number; + lastAttendedGame: number | null; + } + /** + * Suspended by the chain, or recognized without personhood — an + * inconsistent state the derivation fails safe into rather than throwing. + */ + | { tag: "Suspended" }; + +/** The finalized block every read in a result was pinned to. */ +export interface FinalizedSnapshot { + blockHash: string; + blockNumber: number; +} + +/** + * The absence-grace policy currently in force, decoded from + * `Score.AbsenceGraceRatio`. + * + * `window` is a count of recent games; `allowedMisses` is how many of them may + * be absences before the next one suspends. A `window` of `0` means no grace at + * all. + */ +export interface AbsenceGracePolicy { + allowedMisses: number; + window: number; +} + +/** + * A participant's game record, as read from `Score.Participants` and decoded. + * + * `attendanceHistory` is a rolling byte: bit 0 is the most recent game, `1` + * means attended and `0` means absent. + */ +export interface PersonhoodParticipant { + score: number; + streak: { tag: "Attended" | "Absent"; count: number }; + attendanceHistory: number; + reachedPersonhood: boolean; + recognition: "ExternallyRecognized" | "NotRecognized" | "Suspended" | "Recognized"; + lastAttendedGame: number | null; +} + +/** + * Everything {@link PersonhoodState} is derived from, resolved for one account at + * one block. + * + * Named inputs rather than a snapshot on purpose: {@link FinalizedSnapshot} is + * the block this was read at, and two exported types called Snapshot meaning + * different things is a trap. + */ +export interface PersonhoodInputs { + isLitePerson: boolean; + participant: PersonhoodParticipant | null; + /** + * `Score.PersonhoodThreshold`. **This is a `u8` on chain**, but PAPI types + * both `u8` and `u32` as `number`, so a width mistake here typechecks and + * passes tests. Verified against the metadata blob on 2026-08-16. + */ + personhoodThreshold: number; + policy: AbsenceGracePolicy; +} + +/** + * The outcome of a personhood read. + * + * `UsernameUnowned` is a first-class success value: the chain was queried and + * answered that nobody owns that username. It is not an error channel. + */ +export type PersonhoodResult = + /** `Resources.UsernameOwnerOf` held no owner for the username. */ + | { tag: "UsernameUnowned"; at: FinalizedSnapshot } + /** + * The username resolved to an account, and its standing was derived. + * + * @param alias - the contextual alias from `People.AccountToAlias`, or + * `null` when the account has none. + */ + | { + tag: "Resolved"; + at: FinalizedSnapshot; + accountAddress: string; + alias: string | null; + state: PersonhoodState; + }; diff --git a/product-sdk/packages/individuality/tsconfig.json b/product-sdk/packages/individuality/tsconfig.json new file mode 100644 index 0000000..754c8d4 --- /dev/null +++ b/product-sdk/packages/individuality/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["vitest/globals", "vitest/importMeta"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/product-sdk/packages/individuality/tsconfig.typecheck.json b/product-sdk/packages/individuality/tsconfig.typecheck.json new file mode 100644 index 0000000..60a870a --- /dev/null +++ b/product-sdk/packages/individuality/tsconfig.typecheck.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/product-sdk/packages/individuality/tsup.config.ts b/product-sdk/packages/individuality/tsup.config.ts new file mode 100644 index 0000000..98d877f --- /dev/null +++ b/product-sdk/packages/individuality/tsup.config.ts @@ -0,0 +1,16 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + clean: true, + target: "es2022", + treeshake: true, + define: { + "import.meta.vitest": "undefined", + }, +}); diff --git a/product-sdk/packages/individuality/vitest.config.ts b/product-sdk/packages/individuality/vitest.config.ts new file mode 100644 index 0000000..9e4226f --- /dev/null +++ b/product-sdk/packages/individuality/vitest.config.ts @@ -0,0 +1,12 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + includeSource: ["src/**/*.ts"], + }, + define: { + "import.meta.vitest": "undefined", + }, +}); diff --git a/product-sdk/packages/sdk/package.json b/product-sdk/packages/sdk/package.json index 1ad17aa..72264ef 100644 --- a/product-sdk/packages/sdk/package.json +++ b/product-sdk/packages/sdk/package.json @@ -44,6 +44,10 @@ "import": "./dist/identity/index.js", "types": "./dist/identity/index.d.ts" }, + "./individuality": { + "import": "./dist/individuality/index.js", + "types": "./dist/individuality/index.d.ts" + }, "./react": { "import": "./dist/react/index.js", "types": "./dist/react/index.d.ts" @@ -75,6 +79,7 @@ "@parity/product-sdk-contracts": "workspace:*", "@parity/product-sdk-crypto": "workspace:*", "@parity/product-sdk-host": "workspace:*", + "@parity/product-sdk-individuality": "workspace:*", "@parity/product-sdk-keys": "workspace:*", "@parity/product-sdk-logger": "workspace:*", "@parity/product-sdk-errors": "workspace:*", diff --git a/product-sdk/packages/sdk/src/individuality/contract.test.ts b/product-sdk/packages/sdk/src/individuality/contract.test.ts new file mode 100644 index 0000000..574a100 --- /dev/null +++ b/product-sdk/packages/sdk/src/individuality/contract.test.ts @@ -0,0 +1,56 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * Compile-time guard for the chain contract in `@parity/product-sdk-individuality`. + * + * That package types its chain parameter structurally, listing the storage + * entries it needs rather than naming a descriptor. That keeps it free of a + * chain-client dependency and lets its tests use a plain double, at the cost that + * the contract can drift from the real chain silently, since the double satisfies + * it by construction. + * + * This file closes that gap: if a descriptor regeneration changes a key or a + * value type, `pnpm typecheck` fails here. + * + * **It lives in the umbrella package deliberately.** Inside the individuality + * package the same assertion is vacuous, passing even against a contract that + * demands a pallet the chain does not have, because the descriptor types do not + * fully resolve through that package's dependency graph. Here, where both + * `chain-client` and the individuality package are direct dependencies, it + * correctly rejects a bogus contract. The negative control below keeps that + * property honest: if `IndividualityChain` ever stopped constraining, the + * `@ts-expect-error` would report as unused and this file would fail. + */ +import type { getChainAPI } from "@parity/product-sdk-chain-client"; +import type { IndividualityChain } from "@parity/product-sdk-individuality"; +import { expect, test } from "vitest"; + +// The false branch must be `false`, not `never`. `never` is assignable to +// `true`, so a `never` branch would make the assertion unfalsifiable. +type Assert = T; + +type PaseoClient = Awaited>>; +type DevnetClient = Awaited>>; + +// These four aliases are the test. Each fails to typecheck if its condition +// breaks, so they need no export and no runtime reference. +type PaseoSatisfiesContract = Assert; +type DevnetSatisfiesContract = Assert; + +// Negative control, kept type-only so it emits no runtime code. If +// `IndividualityChain` ever stopped constraining, this flips to `false` and the +// file fails to typecheck, which is what keeps the assertions above honest. +type ClientWithoutIndividuality = { + assetHub: unknown; + raw: { assetHub: unknown }; + destroy(): void; +}; +type RejectsBogusClient = Assert< + ClientWithoutIndividuality extends IndividualityChain ? false : true +>; + +test("the individuality chain contract is asserted at compile time", () => { + // The type assertions above are the test. This keeps vitest from reporting + // the file as an empty suite. + expect(true).toBe(true); +}); diff --git a/product-sdk/packages/sdk/src/individuality/index.ts b/product-sdk/packages/sdk/src/individuality/index.ts new file mode 100644 index 0000000..6200ae2 --- /dev/null +++ b/product-sdk/packages/sdk/src/individuality/index.ts @@ -0,0 +1,8 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * @parity/product-sdk/individuality + * + * Re-exports from @parity/product-sdk-individuality. + */ +export * from "@parity/product-sdk-individuality"; diff --git a/product-sdk/packages/sdk/tsup.config.ts b/product-sdk/packages/sdk/tsup.config.ts index 045a24f..4285534 100644 --- a/product-sdk/packages/sdk/tsup.config.ts +++ b/product-sdk/packages/sdk/tsup.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ "crypto/index": "src/crypto/index.ts", "host/index": "src/host/index.ts", "identity/index": "src/identity/index.ts", + "individuality/index": "src/individuality/index.ts", "react/index": "src/react/index.ts", "local-storage/index": "src/local-storage/index.ts", "wallet/index": "src/wallet/index.ts", diff --git a/product-sdk/pending-changesets/individuality-read-layer.md b/product-sdk/pending-changesets/individuality-read-layer.md new file mode 100644 index 0000000..315f503 --- /dev/null +++ b/product-sdk/pending-changesets/individuality-read-layer.md @@ -0,0 +1,86 @@ +--- +"@parity/product-sdk-individuality": minor +"@parity/product-sdk": minor +--- + +**New package `@parity/product-sdk-individuality`: read a person's personhood state from the individuality chain (#287).** + +`readPersonhoodState(chain, { username })` answers one question — for a DotNS username, what +is that person's standing on the individuality chain? — and answers it from **one pinned +finalized block**. It returns a `Result`, per the +SDK-wide error model, so nothing throws. Two of the six underlying values (`Score.PersonhoodThreshold` and +`Score.AbsenceGraceRatio`) are session-updated with schedules behind them, so an unpinned +batch can mix eras and still look valid. The block used is reported back on every result. + +The answer is a closed union of seven states, discriminated by `tag`: + +| `tag` | Payload | +|---|---| +| `NotEnrolled` | — | +| `Lite` | — | +| `Candidate` | `score`, `personhoodThreshold` | +| `MembershipReady` | — | +| `Member` | `activeWeeks`, `lastAttendedGame` | +| `Caution` | `misses`, `allowedMisses`, `window`, `lastAttendedGame` | +| `Suspended` | — | + +wrapped by `UsernameUnowned | Resolved`, both carrying `{ blockHash, blockNumber }`. + +**`UsernameUnowned` is a success value, not an error.** The chain was asked and answered +that nobody owns that username, so it arrives as `ok({ tag: "UsernameUnowned", ... })`. + +**Everything that can fail arrives on the `err` channel**, typed as +`ProductIndividualityError` and recognised by `isSdkError`. Two kinds reach it: +`IndividualityDecodeError` when the chain returns a shape the descriptor says is impossible, +and the base error carrying anything else as its `cause` — an unreachable node, an aborted +signal, or the pinned block leaving the follower's window mid-read. Error messages are fixed +strings and never interpolate chain data. + +The grace-policy decode enforces the runtime's own invariants (`window <= 8` and +`allowedMisses < window`), so a byte order that was ever wrong fails loudly rather than +silently making `Caution` unreachable for every member. + +**Not an authorization oracle.** This is a client-side read in a client-side library, and a +backend that trusts "the SDK said `Member`" is trivially spoofed. Anything gating value must +verify on chain itself. Stated again in the module doc and the package skill. + +**The derivation is exported separately from the read.** `derivePersonhoodState(snapshot)` is +pure — no chain client, no host container — so callers doing their own reads, and the +eligibility half tracked in #291, can consume the state machine on its own. Also exported: +`decodeAbsenceGracePolicy` and `toPersonhoodParticipant` for turning raw +`Score.Participants` and `Score.AbsenceGraceRatio` values into domain shapes. + +**Chain resolution stays with the caller.** The package accepts an already-connected client +rather than resolving an environment itself, so which individuality chain is read is the +caller's choice: + +```ts +const chain = await getChainAPI("paseo"); +const result = await readPersonhoodState(chain, { username: "alice.dot" }); +``` + +The parameter is typed structurally — anything exposing the storage entries satisfies it, +including a test double — matching how `getBalance` and `resolvePeopleUsernameOwner` already +type their chain arguments. A compile-time assertion in `@parity/product-sdk` checks that a +real `getChainAPI` client still satisfies it, so a descriptor regeneration that changes an +entry fails the typecheck. That also means no runtime dependency on +`@parity/product-sdk-chain-client`: this package depends only on +`@parity/product-sdk-errors`, `@parity/result` and `polkadot-api`. + +The alias is read from both `People.AccountToAlias` and `PeopleLite.AccountToAlias`, +preferring the former. Both pallets carry the entry with the same shape, and a Lite person's +alias lives in the second, so consulting only `People` would leave their alias-keyed +participant record invisible and report them as `Lite`. + +Two traps worth knowing if you read these entries yourself, both invisible to the compiler +and both verified against the committed metadata: `Score.PersonhoodThreshold` is a `u8` +(PAPI types `u8` and `u32` alike as `number`), and `Score.AbsenceGraceRatio`'s byte order is +`(allowed_misses, window)` — the metadata tuple is anonymous, so the order comes from the +pallet's doc comment rather than the type. Use `decodeAbsenceGracePolicy` rather than parsing +the hex yourself. + +Reading `game` or `airdrop` state, the eligibility derivation, and transaction construction +are all out of scope here — see #291 and #290. + +Re-exported from the umbrella as `@parity/product-sdk/individuality`. Documented by the +`product-sdk-individuality` skill. diff --git a/product-sdk/pnpm-lock.yaml b/product-sdk/pnpm-lock.yaml index 6e8c815..cef80a0 100644 --- a/product-sdk/pnpm-lock.yaml +++ b/product-sdk/pnpm-lock.yaml @@ -559,6 +559,28 @@ importers: specifier: ^3.0.0 version: 3.2.6(@types/node@25.9.1) + packages/individuality: + dependencies: + '@parity/product-sdk-errors': + specifier: workspace:* + version: link:../errors + '@parity/result': + specifier: workspace:* + version: link:../result + polkadot-api: + specifier: 'catalog:' + version: 2.1.6(esbuild@0.28.1)(rxjs@7.8.2) + devDependencies: + tsup: + specifier: 'catalog:' + version: 8.5.1(postcss@8.5.15)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 3.2.6(@types/node@25.9.1) + packages/keys: dependencies: '@parity/product-sdk-address': @@ -653,6 +675,9 @@ importers: '@parity/product-sdk-host': specifier: workspace:* version: link:../host + '@parity/product-sdk-individuality': + specifier: workspace:* + version: link:../individuality '@parity/product-sdk-keys': specifier: workspace:* version: link:../keys diff --git a/product-sdk/skills/product-sdk-individuality/SKILL.md b/product-sdk/skills/product-sdk-individuality/SKILL.md new file mode 100644 index 0000000..0fae196 --- /dev/null +++ b/product-sdk/skills/product-sdk-individuality/SKILL.md @@ -0,0 +1,159 @@ +--- +name: product-sdk-individuality +description: > + Use when reading a person's personhood or membership standing on the individuality chain + from a DotNS username. Covers readPersonhoodState and its Result return, the seven-state + PersonhoodState union, why UsernameUnowned is a success value rather than an error, using + the pure derivation without a chain client, and the decode helpers for raw + Score.Participants values. +--- + +# Product SDK Individuality + +Answers one question: **for a DotNS username, what is that person's personhood state on the individuality chain, as of one pinned finalized block?** + +Package: `@parity/product-sdk-individuality` (also re-exported from `@parity/product-sdk/individuality`) + +> **NOT AN AUTHORIZATION ORACLE.** This is a client-side read in a client-side library. A backend that trusts "the SDK said `Member`" is trivially spoofed. Anything gating value must verify on chain itself. + +> **RETURNS A `Result`**, per the SDK-wide error model. `ok` carries the answer, `err` carries a `ProductIndividualityError`. Nothing throws. + +> **`UsernameUnowned` IS A SUCCESS VALUE**, not an error. The chain was asked and answered that nobody owns that username, so it arrives as `ok({ tag: "UsernameUnowned", ... })`. + +> **ALL READS SHARE ONE FINALIZED BLOCK.** Two of the six underlying values move on a session cadence, so mixing blocks would silently mix eras. The block used is reported back on every result. + +## Quick Start + +```ts +import { getChainAPI } from "@parity/product-sdk-chain-client"; +import { readPersonhoodState } from "@parity/product-sdk-individuality"; + +const chain = await getChainAPI("paseo"); +const result = await readPersonhoodState(chain, { username: "alice.dot" }); + +if (!result.ok) { + // Unreachable node, aborted signal, or the chain returned an impossible shape. + console.error(result.error); +} else if (result.value.tag === "UsernameUnowned") { + console.log(`nobody owns that username as of block ${result.value.at.blockNumber}`); +} else { + const { accountAddress, state } = result.value; + console.log(accountAddress, state.tag); + if (state.tag === "Member") { + console.log(`member for ${state.activeWeeks} weeks`); + } +} +``` + +This package does **not** resolve a chain. It takes an already-connected client, so the environment choice stays with you — see the `product-sdk-chain-connection` skill for `getChainAPI`. + +## The Seven States + +`result.value.state` is a closed union discriminated by `tag`. + +| `tag` | Means | Payload | +|---|---|---| +| `NotEnrolled` | No participant record and not a Lite person — unknown to both pallets | — | +| `Lite` | Present in `PeopleLite.LitePeople` with no participant record | — | +| `Candidate` | Enrolled and accruing score, personhood not yet reached | `score`, `personhoodThreshold` | +| `MembershipReady` | Personhood reached, recognition not yet granted | — | +| `Member` | Full member in good standing | `activeWeeks`, `lastAttendedGame` | +| `Caution` | A member whose **next** absence would breach the grace policy | `misses`, `allowedMisses`, `window`, `lastAttendedGame` | +| `Suspended` | Suspended by the chain, or recognized without personhood | — | + +Three rules that are not obvious from the table: + +- **A participant record always beats Lite.** `Lite` applies only when there is no record at all. +- **External recognition is permanent.** An externally-recognized person stays `Member` even when the personhood flag is unset, and is never cautioned. +- **`Suspended` is also the fail-safe.** "Recognized without personhood" is inconsistent state; the derivation returns `Suspended` rather than throwing, so a caller never has to render a broken state. + +## The Result Shape + +```ts +type PersonhoodResult = + | { tag: "UsernameUnowned"; at: FinalizedSnapshot } + | { + tag: "Resolved"; + at: FinalizedSnapshot; // { blockHash, blockNumber } + accountAddress: string; // owner of the DotNS username + alias: string | null; // contextual People alias, or null + state: PersonhoodState; + }; +``` + +`at` is on both arms, so you can cache against it or compare two results and know which is newer. The whole union sits inside `result.value`. + +The alias is read from **both** `People.AccountToAlias` and `PeopleLite.AccountToAlias`, preferring the former. A Lite person's alias lives in the second, and without it the alias-keyed participant lookup would never run for them. + +## Cancellation + +```ts +const controller = new AbortController(); +const result = await readPersonhoodState(chain, { + username: "alice.dot", + signal: controller.signal, +}); +``` + +The signal is checked before the first call and then forwarded into every underlying pull, so an already cancelled read costs no round trip. A cancellation arrives on the `err` channel like any other failure. **No deadline is applied**, so wrap the call yourself if you need one. + +## Using the Derivation Without a Chain + +The state machine is pure and exported separately, so you can derive a state from a snapshot you already hold — no chain client, no host container. This is the entry point for callers doing their own reads. + +```ts +import { + derivePersonhoodState, + decodeAbsenceGracePolicy, + toPersonhoodParticipant, +} from "@parity/product-sdk-individuality"; + +const state = derivePersonhoodState({ + isLitePerson: litePersonValue != null, + participant: rawParticipant == null ? null : toPersonhoodParticipant(rawParticipant), + personhoodThreshold, // Score.PersonhoodThreshold + policy: decodeAbsenceGracePolicy(absenceGraceRatio), // Score.AbsenceGraceRatio +}); +``` + +## Chain Data Gotchas + +Two traps the compiler cannot catch, both verified against the committed metadata: + +- **`Score.PersonhoodThreshold` is a `u8`.** PAPI types both `u8` and `u32` as `number`, so a width mistake typechecks *and* passes tests. Nothing guards this one, so read it at the right width. +- **`Score.AbsenceGraceRatio` byte order is `(allowed_misses, window)`.** The metadata tuple is anonymous, so the order comes from the pallet's doc comment, not the type. Use `decodeAbsenceGracePolicy` rather than parsing the hex yourself: it enforces the runtime's own invariants (`window <= 8` and `allowedMisses < window`), so a swapped order fails loudly instead of silently disabling `Caution` for everyone. + +Unknown `streak` or `recognition` variants throw `IndividualityDecodeError` rather than mapping to something plausible — the pallet is under active development, and a variant added by a runtime upgrade should fail loudly. + +## Error Handling + +```ts +import { isErrorOf } from "@parity/result"; +import { + ProductIndividualityError, // package base, carries any other failure as `cause` + IndividualityDecodeError, // the chain returned an impossible shape +} from "@parity/product-sdk-individuality"; + +if (!result.ok) { + if (isErrorOf(result.error, IndividualityDecodeError)) { + // The chain and the committed metadata disagree. + } else { + // Transport, cancellation, or the pinned block aged out. `cause` has the original. + console.error(result.error.cause); + } +} +``` + +Both implement the cross-package `SdkError` marker, so `isSdkError(e)` from `@parity/product-sdk-errors` recognizes them. Error messages are fixed strings and never interpolate chain data. + +## Common Mistakes + +1. **Forgetting to check `result.ok` first** — the answer is inside `result.value`, and a `result.tag` check on the outer object is always undefined. +2. **Treating `UsernameUnowned` as an error** — it is a valid answer on the ok channel. +3. **Comparing `score` to `personhoodThreshold` to decide membership** — the chain owns `reachedPersonhood`; both numbers are reported, never compared. Someone sitting exactly on the threshold is still `Candidate`. +4. **Reading `Caution.misses` as misses already taken** — it is a *projection* of what the window would hold after one more absence. +5. **Assuming `window === 0` behaves like other windows** — it means no grace at all, so the next absence suspends regardless of the count. `Caution` there can carry a `misses` value *below* `allowedMisses`. +6. **Using this to gate value server-side** — see the first callout. +7. **Normalizing the username first** — it is UTF-8 encoded as-is. Pass the exact byte string the chain stores, `.dot` suffix included. +8. **Expecting `alias` to be the DotNS text** — it is the contextual People alias, or `null`. Never the username. +9. **Reading the six values at different blocks** if you roll your own read — the threshold and grace ratio are session-updated, so an unpinned batch can mix eras and look valid.