# Lexicon Resolver Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add optional lexicon validation to record writes with dynamic schema resolution. **Architecture:** Core defines a `LexiconResolverPort` interface and wires it into write handlers (createRecord, putRecord, applyWrites). A separate `@pdsjs/lexicon-resolver` package wraps `@atproto/lex-resolver` for schema resolution and `@atproto/lexicon` for validation. **Tech Stack:** `@atproto/lexicon` (validation), `@atproto/lex-resolver` (DNS + PDS schema fetching) --- ## Task 1: Add LexiconResolverPort to ports.js **Files:** - Modify: `packages/core/src/ports.js:127-129` **Step 1: Add the port typedef** Add before the final `export {}`: ```javascript /** * @typedef {'valid' | 'unknown'} ValidationStatus */ /** * Lexicon resolver port for validating records against schemas. * Implementations may resolve schemas dynamically (DNS + fetch) or use static schemas. * * @typedef {Object} LexiconResolverPort * @property {(collection: string, record: object) => Promise} assertValid * Resolves schema if needed, validates record. * Returns 'valid' on success, 'unknown' if schema unresolvable. * Throws Error with descriptive message if record is invalid against known schema. */ ``` **Step 2: Commit** ```bash git add packages/core/src/ports.js git commit -m "feat(core): add LexiconResolverPort typedef Co-Authored-By: Claude Opus 4.5 " ``` --- ## Task 2: Add lexiconResolver to PDS constructor **Files:** - Modify: `packages/core/src/pds.js:300-340` **Step 1: Add JSDoc for the new parameter** Add to the constructor JSDoc (after line 311): ```javascript * @param {import('./ports.js').LexiconResolverPort} [config.lexiconResolver] - Optional lexicon resolver for record validation ``` **Step 2: Add to constructor destructuring** Add `lexiconResolver` to the destructuring (after `webSocket,`): ```javascript webSocket, lexiconResolver, ``` **Step 3: Store on instance** Add after `this.webSocket = webSocket;` (line 334): ```javascript this.lexiconResolver = lexiconResolver; ``` **Step 4: Commit** ```bash git add packages/core/src/pds.js git commit -m "feat(core): add lexiconResolver to PDS constructor Co-Authored-By: Claude Opus 4.5 " ``` --- ## Task 3: Add validation to handleCreateRecord **Files:** - Modify: `packages/core/src/pds.js:742-819` - Test: `packages/core/test/lexicon.test.js` (create) **Step 1: Write failing test** Create `packages/core/test/lexicon.test.js`: ```javascript import { describe, expect, test } from 'vitest'; // Mock resolver that tracks calls function createMockResolver(behavior = 'valid') { const calls = []; return { calls, async assertValid(collection, record) { calls.push({ collection, record }); if (behavior === 'valid') return 'valid'; if (behavior === 'unknown') return 'unknown'; if (behavior === 'invalid') throw new Error('Invalid record: missing required field'); return 'valid'; }, }; } describe('Lexicon Validation', () => { describe('LexiconResolverPort mock', () => { test('calls resolver with collection and record', async () => { const resolver = createMockResolver('valid'); expect(resolver.calls).toHaveLength(0); const status = await resolver.assertValid('app.bsky.feed.post', { text: 'hello' }); expect(resolver.calls).toHaveLength(1); expect(resolver.calls[0].collection).toBe('app.bsky.feed.post'); expect(resolver.calls[0].record).toEqual({ text: 'hello' }); expect(status).toBe('valid'); }); test('returns unknown when schema not found', async () => { const resolver = createMockResolver('unknown'); const status = await resolver.assertValid('com.example.unknown', {}); expect(status).toBe('unknown'); }); test('throws when record is invalid', async () => { const resolver = createMockResolver('invalid'); await expect( resolver.assertValid('app.bsky.feed.post', {}) ).rejects.toThrow('Invalid record'); }); }); }); ``` **Step 2: Run test to verify it passes** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm test packages/core/test/lexicon.test.js ``` Expected: PASS (tests the mock behavior) **Step 3: Update handleCreateRecord to use resolver** In `handleCreateRecord`, replace the current record handling (around lines 785-818) with: ```javascript const rkey = providedRkey || createTid(); const uri = `at://${did}/${collection}/${rkey}`; // Ensure $type is set const recordWithType = { $type: collection, ...record }; // Validate record against schema if resolver provided const shouldValidate = _validate !== false; let validationStatus; if (shouldValidate && this.lexiconResolver) { try { validationStatus = await this.lexiconResolver.assertValid( collection, recordWithType, ); } catch (err) { return Response.json( { error: 'InvalidRecord', message: `Invalid ${collection} record: ${err.message}`, }, { status: 400 }, ); } } else if (shouldValidate && !this.lexiconResolver) { validationStatus = 'unknown'; } // If _validate === false, validationStatus remains undefined // Encode record and create CID const encoded = cborEncodeDagCbor(recordWithType); const cid = cidToString(await createCid(encoded)); // Store block await this.actorStorage.putBlock(cid, encoded); // Store record await this.actorStorage.putRecord(uri, cid, collection, rkey, encoded); // Link blobs to record const blobRefs = findBlobRefs(record); for (const blobCid of blobRefs) { await this.actorStorage.linkBlobToRecord(blobCid, uri); } // Create commit await this.createCommit(did, [{ action: 'create', uri, cid }]); // Get commit info const latestCommit = await this.actorStorage.getLatestCommit(); return Response.json({ uri, cid, commit: latestCommit ? { cid: latestCommit.cid, rev: latestCommit.rev } : null, validationStatus, }); ``` **Step 4: Run tests** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm test ``` **Step 5: Commit** ```bash git add packages/core/src/pds.js packages/core/test/lexicon.test.js git commit -m "feat(core): add lexicon validation to createRecord - Calls lexiconResolver.assertValid when resolver provided - Returns InvalidRecord error when validation fails - Returns validationStatus in response (valid/unknown/undefined) Co-Authored-By: Claude Opus 4.5 " ``` --- ## Task 4: Add validation to handlePutRecord **Files:** - Modify: `packages/core/src/pds.js:915-1018` **Step 1: Add validation logic to handlePutRecord** Find the section after scope permissions check (around line 950) and add the same validation pattern. Update to include `_validate` in destructuring and add validation logic before encoding: ```javascript const { repo, collection, rkey, record, validate: _validate, } = await request.json(); ``` Then after scope check, before encoding: ```javascript // Ensure $type is set const recordWithType = { $type: collection, ...record }; // Validate record against schema if resolver provided const shouldValidate = _validate !== false; let validationStatus; if (shouldValidate && this.lexiconResolver) { try { validationStatus = await this.lexiconResolver.assertValid( collection, recordWithType, ); } catch (err) { return Response.json( { error: 'InvalidRecord', message: `Invalid ${collection} record: ${err.message}`, }, { status: 400 }, ); } } else if (shouldValidate && !this.lexiconResolver) { validationStatus = 'unknown'; } ``` **Step 2: Update response to include validationStatus** Add `validationStatus` to the response object. **Step 3: Run tests** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm test ``` **Step 4: Commit** ```bash git add packages/core/src/pds.js git commit -m "feat(core): add lexicon validation to putRecord Co-Authored-By: Claude Opus 4.5 " ``` --- ## Task 5: Add validation to handleApplyWrites **Files:** - Modify: `packages/core/src/pds.js:1021-1170` **Step 1: Extract validate param from request body** Add `validate` to the destructuring: ```javascript const { repo, writes, validate, swapCommit: _swapCommit } = await request.json(); ``` **Step 2: Add validation inside create case** For create case (around line 1070), add validation before storing: ```javascript if ($type === 'com.atproto.repo.applyWrites#create') { const { collection, rkey: providedRkey, value } = write; // Validate if resolver provided and validate !== false const shouldValidate = validate !== false; let validationStatus; const recordWithType = { $type: collection, ...value }; if (shouldValidate && this.lexiconResolver) { try { validationStatus = await this.lexiconResolver.assertValid( collection, recordWithType, ); } catch (err) { return Response.json( { error: 'InvalidRecord', message: `Invalid ${collection} record: ${err.message}`, }, { status: 400 }, ); } } else if (shouldValidate && !this.lexiconResolver) { validationStatus = 'unknown'; } // ... rest of create logic, using recordWithType for encoding ``` **Step 3: Add validation to update case** Same pattern for the update case. **Step 4: Add validationStatus to create/update results** ```javascript results.push({ $type: 'com.atproto.repo.applyWrites#createResult', uri, cid, validationStatus, }); ``` **Step 5: Run tests** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm test ``` **Step 6: Commit** ```bash git add packages/core/src/pds.js git commit -m "feat(core): add lexicon validation to applyWrites Co-Authored-By: Claude Opus 4.5 " ``` --- ## Task 6: Create lexicon-resolver package **Files:** - Create: `packages/lexicon-resolver/package.json` - Create: `packages/lexicon-resolver/src/index.js` - Create: `packages/lexicon-resolver/src/resolver.js` **Step 1: Create package.json** ```json { "name": "@pdsjs/lexicon-resolver", "version": "0.1.0", "type": "module", "main": "src/index.js", "exports": { ".": "./src/index.js", "./resolver": "./src/resolver.js" }, "dependencies": { "@atproto/lexicon": "^0.4.0", "@atproto/lex-resolver": "^0.1.0" }, "devDependencies": { "vitest": "^2.1.8" } } ``` **Step 2: Create src/index.js** ```javascript export { LexiconResolver } from './resolver.js'; ``` **Step 3: Create src/resolver.js** ```javascript import { Lexicons } from '@atproto/lexicon'; import { LexResolver } from '@atproto/lex-resolver'; /** * @typedef {import('@pdsjs/core/ports').ValidationStatus} ValidationStatus */ /** * Lexicon resolver using @atproto/lex-resolver for schema resolution * and @atproto/lexicon for validation. * Implements LexiconResolverPort. */ export class LexiconResolver { /** * @param {Object} [opts] * @param {number} [opts.ttl=3600000] - Cache TTL in ms (default 1 hour) * @param {typeof fetch} [opts.fetch] - Fetch implementation * @param {string} [opts.plcDirectoryUrl] - PLC directory URL * @param {Array} [opts.schemas] - Pre-loaded static schemas (LexiconDoc[]) */ constructor(opts = {}) { this.lexicons = new Lexicons(); this.resolver = new LexResolver({ fetch: opts.fetch, plcDirectoryUrl: opts.plcDirectoryUrl, }); this.ttl = opts.ttl ?? 3600000; this.cache = new Map(); // nsid -> { fetchedAt } // Pre-load static schemas for (const schema of opts.schemas ?? []) { try { this.lexicons.add(schema); this.cache.set(schema.id, { fetchedAt: Date.now() }); } catch { // Schema already added or invalid, skip } } } /** * Validate a record against its schema. * @param {string} collection - The collection/NSID (e.g., 'app.bsky.feed.post') * @param {object} record - The record to validate * @returns {Promise} */ async assertValid(collection, record) { // Ensure schema is loaded const hasSchema = await this.ensureSchema(collection); if (!hasSchema) { return 'unknown'; } // Validate using @atproto/lexicon this.lexicons.assertValidRecord(collection, record); return 'valid'; } /** * Ensure a schema is loaded, fetching if necessary. * @param {string} nsid * @returns {Promise} - true if schema is available */ async ensureSchema(nsid) { // Check if already loaded and not stale const cached = this.cache.get(nsid); if (cached && Date.now() - cached.fetchedAt < this.ttl) { return this.lexicons.get(nsid) !== undefined; } // Already have it but checking staleness if (this.lexicons.get(nsid)) { return true; } // Try to resolve try { const result = await this.resolver.get(nsid); if (result?.lexicon) { this.lexicons.add(result.lexicon); this.cache.set(nsid, { fetchedAt: Date.now() }); return true; } } catch { // Resolution failed } return false; } } ``` **Step 4: Commit** ```bash git add packages/lexicon-resolver/ git commit -m "feat(lexicon-resolver): create package with @atproto/lexicon + lex-resolver - LexiconResolver class implementing LexiconResolverPort - Uses @atproto/lex-resolver for DNS + PDS schema fetching - Uses @atproto/lexicon for validation - In-memory caching with TTL Co-Authored-By: Claude Opus 4.5 " ``` --- ## Task 7: Add tests for LexiconResolver **Files:** - Create: `packages/lexicon-resolver/test/resolver.test.js` **Step 1: Create test file** ```javascript import { describe, expect, test, vi } from 'vitest'; import { LexiconResolver } from '../src/resolver.js'; // Sample lexicon schema for testing const postSchema = { lexicon: 1, id: 'app.bsky.feed.post', defs: { main: { type: 'record', key: 'tid', record: { type: 'object', required: ['text', 'createdAt'], properties: { text: { type: 'string', maxLength: 3000 }, createdAt: { type: 'string', format: 'datetime' }, }, }, }, }, }; describe('LexiconResolver', () => { describe('with static schemas', () => { test('validates record against pre-loaded schema', async () => { const resolver = new LexiconResolver({ schemas: [postSchema], }); const status = await resolver.assertValid('app.bsky.feed.post', { $type: 'app.bsky.feed.post', text: 'Hello world', createdAt: new Date().toISOString(), }); expect(status).toBe('valid'); }); test('throws on invalid record', async () => { const resolver = new LexiconResolver({ schemas: [postSchema], }); await expect( resolver.assertValid('app.bsky.feed.post', { $type: 'app.bsky.feed.post', // missing required fields }) ).rejects.toThrow(); }); test('returns unknown for unloaded schema', async () => { const resolver = new LexiconResolver({ schemas: [postSchema], }); const status = await resolver.assertValid('com.example.unknown', { $type: 'com.example.unknown', }); expect(status).toBe('unknown'); }); }); describe('ensureSchema', () => { test('returns true for pre-loaded schema', async () => { const resolver = new LexiconResolver({ schemas: [postSchema], }); const result = await resolver.ensureSchema('app.bsky.feed.post'); expect(result).toBe(true); }); test('returns false for unknown schema when resolution fails', async () => { const resolver = new LexiconResolver(); // Mock the resolver to fail resolver.resolver.get = vi.fn().mockRejectedValue(new Error('Not found')); const result = await resolver.ensureSchema('com.example.unknown'); expect(result).toBe(false); }); }); }); ``` **Step 2: Run tests** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm test packages/lexicon-resolver/test/resolver.test.js ``` **Step 3: Commit** ```bash git add packages/lexicon-resolver/test/ git commit -m "test(lexicon-resolver): add unit tests Co-Authored-By: Claude Opus 4.5 " ``` --- ## Task 8: Install dependencies and update workspace **Step 1: Install dependencies** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm install ``` **Step 2: Run full test suite** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm test ``` **Step 3: Run linter** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm lint ``` **Step 4: Fix any issues and commit** ```bash git add pnpm-lock.yaml git commit -m "chore: install lexicon-resolver dependencies Co-Authored-By: Claude Opus 4.5 " ``` --- ## Task 9: Update endpoint comparison docs **Files:** - Modify: `docs/endpoint-comparison.md` **Step 1: Update the missing parameters table** Change from: ```markdown | repo.createRecord | collection, record, rkey | **repo**, **validate**, swapCommit | | repo.putRecord | collection, rkey, record | **repo**, **validate**, swapCommit, swapRecord | | repo.applyWrites | writes | **repo**, validate, swapCommit | ``` To: ```markdown | repo.createRecord | collection, record, rkey, **validate** | **repo**, swapCommit | | repo.putRecord | collection, rkey, record, **validate** | **repo**, swapCommit, swapRecord | | repo.applyWrites | writes, **validate** | **repo**, swapCommit | ``` **Step 2: Commit** ```bash git add docs/endpoint-comparison.md git commit -m "docs: update endpoint comparison with validate param support Co-Authored-By: Claude Opus 4.5 " ``` --- ## Summary After completing all tasks: 1. **@pdsjs/core** has: - `LexiconResolverPort` typedef in `ports.js` - Optional `lexiconResolver` in PDS constructor - `validate` param on createRecord, putRecord, applyWrites - `validationStatus` in responses 2. **@pdsjs/lexicon-resolver** provides: - `LexiconResolver` class implementing the port - Uses `@atproto/lex-resolver` for DNS + PDS schema fetching - Uses `@atproto/lexicon` for validation - In-memory caching with TTL - Pre-loadable static schemas 3. **Usage:** ```javascript import { PersonalDataServer } from '@pdsjs/core'; import { LexiconResolver } from '@pdsjs/lexicon-resolver'; const pds = new PersonalDataServer({ // ... other config lexiconResolver: new LexiconResolver({ schemas: [/* optional static schemas */], }), }); ```